diff --git a/agent/README.md b/agent/README.md
index 479d231..eeba52b 100644
--- a/agent/README.md
+++ b/agent/README.md
@@ -1,5 +1,8 @@
# OpenWrt RMM Agent
+Current stable Go agent: `0.5.2`. It reports runtime health, pending command results,
+and the last heartbeat transport error after connectivity is restored.
+
Production Go agent for OpenWrt, with the shell implementation retained as a fallback runtime.
The agent uses outbound HTTP polling:
diff --git a/agent/go/cmd/rmm-agent/main.go b/agent/go/cmd/rmm-agent/main.go
index 1e557ee..de8d57e 100644
--- a/agent/go/cmd/rmm-agent/main.go
+++ b/agent/go/cmd/rmm-agent/main.go
@@ -23,7 +23,45 @@ import (
"time"
)
-const agentVersion = "0.5.1"
+const agentVersion = "0.5.2"
+
+type agentRuntimeHealth struct {
+ StartedAt time.Time
+ ConsecutiveFailures int
+ LastHeartbeatError string
+ LastHeartbeatErrorAt time.Time
+ LastHeartbeatSuccess time.Time
+}
+
+func (h *agentRuntimeHealth) recordFailure(err error) {
+ h.ConsecutiveFailures++
+ h.LastHeartbeatError = strings.TrimSpace(err.Error())
+ if len(h.LastHeartbeatError) > 512 {
+ h.LastHeartbeatError = h.LastHeartbeatError[:512]
+ }
+ h.LastHeartbeatErrorAt = time.Now().UTC()
+}
+
+func (h *agentRuntimeHealth) recordSuccess() {
+ h.ConsecutiveFailures = 0
+ h.LastHeartbeatSuccess = time.Now().UTC()
+}
+
+func (h *agentRuntimeHealth) snapshot(spoolDir string) map[string]any {
+ result := map[string]any{
+ "started_at": h.StartedAt.UTC().Format(time.RFC3339),
+ "consecutive_failures": h.ConsecutiveFailures,
+ "pending_results": pendingResultCount(spoolDir),
+ }
+ if !h.LastHeartbeatSuccess.IsZero() {
+ result["last_heartbeat_success_at"] = h.LastHeartbeatSuccess.UTC().Format(time.RFC3339)
+ }
+ if h.LastHeartbeatError != "" {
+ result["last_heartbeat_error"] = h.LastHeartbeatError
+ result["last_heartbeat_error_at"] = h.LastHeartbeatErrorAt.UTC().Format(time.RFC3339)
+ }
+ return result
+}
type config struct {
ServerURL string
@@ -99,14 +137,17 @@ func main() {
}
backoff := time.Duration(cfg.IntervalSeconds) * time.Second
+ health := &agentRuntimeHealth{StartedAt: time.Now().UTC()}
for {
- if err := heartbeatOnce(ctx, client, cfg); err != nil {
+ if err := heartbeatOnce(ctx, client, cfg, health.snapshot(cfg.SpoolDir)); err != nil {
+ health.recordFailure(err)
logf("heartbeat failed: %v", err)
backoff *= 2
if backoff > 5*time.Minute {
backoff = 5 * time.Minute
}
} else {
+ health.recordSuccess()
backoff = time.Duration(cfg.IntervalSeconds) * time.Second
}
if *once {
@@ -271,14 +312,14 @@ func enroll(ctx context.Context, client *http.Client, cfg *config) error {
return nil
}
-func heartbeatOnce(ctx context.Context, client *http.Client, cfg config) error {
+func heartbeatOnce(ctx context.Context, client *http.Client, cfg config, agentHealth map[string]any) error {
if err := flushSpooledResults(ctx, client, cfg); err != nil {
logf("spool flush warning: %v", err)
}
body := map[string]any{
"device_id": cfg.DeviceID,
"inventory": buildInventory(cfg),
- "metrics": buildMetrics(cfg),
+ "metrics": buildMetrics(cfg, agentHealth),
}
var resp heartbeatResponse
if err := postJSON(ctx, client, cfg.ServerURL+"/api/agent/heartbeat", cfg.DeviceToken, body, &resp); err != nil {
@@ -313,7 +354,7 @@ func (cfg config) displayHostname() string {
return hostnameValue() + strings.TrimSpace(cfg.HostnameSuffix)
}
-func buildMetrics(cfg config) map[string]any {
+func buildMetrics(cfg config, agentHealth map[string]any) map[string]any {
serverTarget := serverCheckTarget(cfg.ServerURL)
return map[string]any{
"system": jsonObjectOrEmpty(commandOutput("ubus", "call", "system", "info")),
@@ -324,9 +365,24 @@ func buildMetrics(cfg config) map[string]any {
"interface_counters": interfaceCounters(),
"connectivity_checks": connectivityChecks(effectiveCheckTargets(cfg.CheckTargets, serverTarget)),
"server_check_target": serverTarget,
+ "agent_health": agentHealth,
}
}
+func pendingResultCount(spoolDir string) int {
+ entries, err := os.ReadDir(spoolDir)
+ if err != nil {
+ return 0
+ }
+ count := 0
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
+ count++
+ }
+ }
+ return count
+}
+
func effectiveCheckTargets(targets []string, serverTarget string) []string {
result := make([]string, 0, len(targets)+1)
seen := map[string]bool{}
diff --git a/agent/go/cmd/rmm-agent/main_test.go b/agent/go/cmd/rmm-agent/main_test.go
index d30ec37..f9c91cd 100644
--- a/agent/go/cmd/rmm-agent/main_test.go
+++ b/agent/go/cmd/rmm-agent/main_test.go
@@ -5,14 +5,35 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
)
func TestAgentVersionIsStable(t *testing.T) {
- if agentVersion != "0.5.1" {
+ if agentVersion != "0.5.2" {
t.Fatalf("unexpected agent version %q", agentVersion)
}
}
+func TestAgentRuntimeHealthSnapshot(t *testing.T) {
+ spoolDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(spoolDir, "pending.json"), []byte(`{}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ health := &agentRuntimeHealth{StartedAt: time.Unix(100, 0).UTC()}
+ health.recordFailure(errors.New("temporary connection failure"))
+ snapshot := health.snapshot(spoolDir)
+ if snapshot["consecutive_failures"] != 1 || snapshot["pending_results"] != 1 {
+ t.Fatalf("unexpected health snapshot: %#v", snapshot)
+ }
+ if snapshot["last_heartbeat_error"] != "temporary connection failure" {
+ t.Fatalf("unexpected heartbeat error: %#v", snapshot)
+ }
+ health.recordSuccess()
+ if health.ConsecutiveFailures != 0 || health.LastHeartbeatSuccess.IsZero() {
+ t.Fatalf("recordSuccess did not reset runtime state: %#v", health)
+ }
+}
+
func TestAcquireLockRemovesDirectoryOnUnlock(t *testing.T) {
lockPath := filepath.Join(t.TempDir(), "rmm-agent.lock")
unlock, err := acquireLock(lockPath)
diff --git a/agent/package/luci-app-rmm-agent/README.md b/agent/package/luci-app-rmm-agent/README.md
index 3ea2f41..8c9db0f 100644
--- a/agent/package/luci-app-rmm-agent/README.md
+++ b/agent/package/luci-app-rmm-agent/README.md
@@ -67,7 +67,7 @@ application to the router. Do not copy or install the shell runtime at the same
cd dist/rmm-openwrt-25.12.4-ramips-mt7621
sha256sum -c SHA256SUMS
scp \
- rmm-agent-go-production-0.5.1-r1.apk \
+ rmm-agent-go-production-0.5.2-r1.apk \
luci-app-rmm-agent-0.1.0-r1.apk \
root@ROUTER_IP:/tmp/
```
@@ -76,7 +76,7 @@ Then install the locally built, unsigned packages over SSH:
```sh
apk add --allow-untrusted \
- /tmp/rmm-agent-go-production-0.5.1-r1.apk \
+ /tmp/rmm-agent-go-production-0.5.2-r1.apk \
/tmp/luci-app-rmm-agent-0.1.0-r1.apk
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
diff --git a/agent/package/rmm-agent-go-production/Makefile b/agent/package/rmm-agent-go-production/Makefile
index 1f74845..1d32a3f 100644
--- a/agent/package/rmm-agent-go-production/Makefile
+++ b/agent/package/rmm-agent-go-production/Makefile
@@ -1,7 +1,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rmm-agent-go-production
-PKG_VERSION:=0.5.1
+PKG_VERSION:=0.5.2
PKG_RELEASE:=1
PKG_MAINTAINER:=RMM OpenWrt
diff --git a/agent/package/rmm-agent-go/Makefile b/agent/package/rmm-agent-go/Makefile
index d0d9faf..6273425 100644
--- a/agent/package/rmm-agent-go/Makefile
+++ b/agent/package/rmm-agent-go/Makefile
@@ -1,7 +1,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=rmm-agent-go
-PKG_VERSION:=0.5.1
+PKG_VERSION:=0.5.2
PKG_RELEASE:=1
PKG_MAINTAINER:=RMM OpenWrt
diff --git a/web/app.js b/web/app.js
index 7812b71..e9fdd87 100644
--- a/web/app.js
+++ b/web/app.js
@@ -28,7 +28,7 @@ const state = {
previousMobileRoute: "fleet",
};
-const EXPECTED_AGENT_VERSION = "0.5.1";
+const EXPECTED_AGENT_VERSION = "0.5.2";
const els = {
loginView: document.querySelector("#loginView"),
@@ -91,6 +91,8 @@ const els = {
infoBoardName: document.querySelector("#infoBoardName"),
infoRootfs: document.querySelector("#infoRootfs"),
healthSummary: document.querySelector("#healthSummary"),
+ deviceStatusHero: document.querySelector("#deviceStatusHero"),
+ agentHealthSummary: document.querySelector("#agentHealthSummary"),
lastSeen: document.querySelector("#lastSeen"),
loadAvg: document.querySelector("#loadAvg"),
uptime: document.querySelector("#uptime"),
@@ -522,15 +524,16 @@ function deviceAgentVersion(device) {
}
function agentVersionLabel(version) {
- if (!version) return "неизвестна";
- if (version === EXPECTED_AGENT_VERSION) return `${version} актуальная`;
- return `${version} требуется обновление`;
+ if (!version) return "Версия не определена";
+ if (version === EXPECTED_AGENT_VERSION) return `${version} · актуальная`;
+ return `${version} · доступна ${EXPECTED_AGENT_VERSION}`;
}
function deviceClientCount(device) {
- const leases = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases.length : 0;
- const wifi = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients.length : 0;
- return { leases, wifi, total: Math.max(leases, wifi) };
+ const clients = normalizedClients(device);
+ const online = clients.filter((client) => client.presence === "online").length;
+ const unconfirmed = clients.length - online;
+ return { online, unconfirmed, total: clients.length };
}
function deviceWanSummary(device) {
@@ -696,8 +699,8 @@ function renderDevices() {
${escapeHtml(wan.label)}
- Известно клиентов
- ${clients.total}
+ Онлайн / всего
+ ${clients.online} / ${clients.total}
${device.active_alerts ? `${device.active_alerts} активн.` : "Нет"}
@@ -739,14 +742,14 @@ function renderDeviceDetail(device) {
els.wanIp.textContent = device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "-";
els.memoryUsage.textContent = formatMemory(device.metrics && device.metrics.memory);
els.diskUsage.textContent = formatDisk(device.metrics && device.metrics.disk);
- const leaseCount = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases.length : 0;
- const wifiCount = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients.length : 0;
- els.clientCounts.textContent = `${leaseCount} DHCP-аренды / ${wifiCount} Wi-Fi онлайн`;
+ const clientCounts = deviceClientCount(device);
+ els.clientCounts.textContent = `${clientCounts.online} в сети / ${clientCounts.total} известно`;
els.connectivityStatus.textContent = formatConnectivity(device.metrics && device.metrics.connectivity_checks);
els.fleetGroup.value = device.group || "";
els.fleetTags.value = Array.isArray(device.tags) ? device.tags.join(", ") : "";
els.inventoryJson.textContent = JSON.stringify(device.inventory || {}, null, 2);
renderDeviceInformation(device);
+ renderDeviceStatus(device);
renderHealthSummary(device);
renderClients(device);
renderInterfaceCounters(device);
@@ -798,6 +801,117 @@ function formatConnectivity(checks) {
return `${reachable}/${checks.length} up / ${worstReachableLatency} ms / no loss`;
}
+function formatRelativeTime(value) {
+ const timestamp = Date.parse(value || "");
+ if (!Number.isFinite(timestamp)) return "время неизвестно";
+ const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
+ if (seconds < 60) return `${seconds} сек. назад`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes} мин. назад`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours} ч. назад`;
+ return `${Math.floor(hours / 24)} дн. назад`;
+}
+
+function humanizeAgentError(value) {
+ const message = String(value || "").trim();
+ if (!message) return "Ошибок обмена не зафиксировано";
+ const normalized = message.toLowerCase();
+ if (normalized.includes("x509") || normalized.includes("certificate")) {
+ return "Ошибка TLS-сертификата: имя или цепочка сертификата не совпадает с адресом сервера";
+ }
+ if (normalized.includes("no such host") || normalized.includes("bad address")) {
+ return "DNS не смог определить адрес RMM-сервера";
+ }
+ if (normalized.includes("connection refused")) return "RMM-сервер отклонил подключение";
+ if (normalized.includes("timeout") || normalized.includes("deadline exceeded")) return "RMM-сервер не ответил вовремя";
+ if (normalized.includes("401") || normalized.includes("unauthorized")) return "Сервер отклонил ключ устройства";
+ if (normalized.includes("403") || normalized.includes("forbidden")) return "Сервер запретил запрос агента";
+ return message;
+}
+
+function renderDeviceStatus(device) {
+ const checks = Array.isArray(device.metrics && device.metrics.connectivity_checks) ? device.metrics.connectivity_checks : [];
+ const serverHost = (device.metrics && device.metrics.server_check_target) || window.location.hostname;
+ const serverCheck = checks.find((check) => check.target === serverHost);
+ const wanChecks = checks.filter((check) => check.target !== serverHost);
+ const version = deviceAgentVersion(device);
+ const agentHealth = device.metrics && device.metrics.agent_health ? device.metrics.agent_health : null;
+ const pending = Number(agentHealth && agentHealth.pending_results || 0);
+ const failures = Number(agentHealth && agentHealth.consecutive_failures || 0);
+ let tone = "good";
+ let title = "Роутер работает нормально";
+ let description = `Heartbeat получен ${formatRelativeTime(device.last_seen_at)}. Интернет и связь с сервером доступны.`;
+ let icon = "✓";
+
+ if (!device.online) {
+ tone = "bad";
+ title = "Агент не выходит на связь";
+ description = `Последний heartbeat был ${formatRelativeTime(device.last_seen_at)}. Проверьте службу агента, DNS, время роутера и TLS-сертификат.`;
+ icon = "!";
+ } else if (serverCheck && !serverCheck.reachable) {
+ tone = "bad";
+ title = "Проблема связи с RMM-сервером";
+ description = "Heartbeat дошёл, но проверка адреса сервера с роутера завершается ошибкой.";
+ icon = "!";
+ } else if (wanChecks.length && !wanChecks.some((check) => check.reachable)) {
+ tone = "bad";
+ title = "Интернет с роутера недоступен";
+ description = "Агент подключён к серверу, но внешние контрольные адреса не отвечают.";
+ icon = "!";
+ } else if (failures > 0) {
+ tone = "warn";
+ title = "Агент восстанавливает связь";
+ description = `Неудачных попыток подряд: ${failures}. Интервал повторов временно увеличен.`;
+ icon = "↻";
+ } else if (pending > 0) {
+ tone = "warn";
+ title = "Есть данные, ожидающие отправки";
+ description = `Агент хранит ${pending} ${resultWord(pending)} выполнения команд локально и повторит отправку автоматически.`;
+ icon = "↻";
+ } else if (Number(device.active_alerts || 0) > 0) {
+ tone = "warn";
+ title = "Роутер требует внимания";
+ description = `Активных проблем: ${device.active_alerts}. Подробности находятся ниже в журнале проблем.`;
+ icon = "!";
+ } else if (version && version !== EXPECTED_AGENT_VERSION) {
+ tone = "warn";
+ title = "Работает, доступно обновление агента";
+ description = `Установлена версия ${version}; актуальная стабильная версия — ${EXPECTED_AGENT_VERSION}.`;
+ icon = "↻";
+ }
+
+ els.deviceStatusHero.className = `device-status-hero ${tone}`;
+ els.deviceStatusHero.innerHTML = `
+ ${icon}
+ ${escapeHtml(title)}${escapeHtml(description)}
+ `;
+
+ const lastError = agentHealth && agentHealth.last_heartbeat_error;
+ const errorAt = agentHealth && agentHealth.last_heartbeat_error_at;
+ const errorText = agentHealth
+ ? (lastError
+ ? `${failures ? "Последняя ошибка" : "Последний восстановленный сбой"}: ${humanizeAgentError(lastError)} · ${formatRelativeTime(errorAt)}`
+ : "Ошибок обмена не зафиксировано")
+ : `Расширенная диагностика появится после обновления агента до ${EXPECTED_AGENT_VERSION}`;
+ els.agentHealthSummary.innerHTML = `
+ Агент${escapeHtml(agentVersionLabel(version))}
+ Runtime${escapeHtml(device.inventory && device.inventory.agent_runtime || "неизвестно")}
+ Обмен с сервером${failures ? `${failures} ошибок подряд` : "Стабильно"}
+ Ожидают отправки${pending} ${resultWord(pending)}
+ ${escapeHtml(errorText)}
+ `;
+}
+
+function resultWord(value) {
+ const number = Math.abs(Number(value || 0));
+ const lastTwo = number % 100;
+ if (lastTwo >= 11 && lastTwo <= 14) return "результатов";
+ if (number % 10 === 1) return "результат";
+ if (number % 10 >= 2 && number % 10 <= 4) return "результата";
+ return "результатов";
+}
+
function renderHealthSummary(device) {
const checks = Array.isArray(device.metrics && device.metrics.connectivity_checks) ? device.metrics.connectivity_checks : [];
const serverHost = (device.metrics && device.metrics.server_check_target) || window.location.hostname;
@@ -843,19 +957,21 @@ function normalizedClients(device) {
const neighbors = Array.isArray(device.inventory && device.inventory.neighbors) ? device.inventory.neighbors : [];
const neighborsByMAC = new Map(neighbors.filter((neighbor) => neighbor.mac).map((neighbor) => [String(neighbor.mac).toLowerCase(), neighbor]));
const neighborsByIP = new Map(neighbors.filter((neighbor) => neighbor.ip).map((neighbor) => [String(neighbor.ip), neighbor]));
- const activeNeighborStates = new Set(["REACHABLE", "DELAY", "PROBE", "PERMANENT", "NOARP"]);
+ // Only states backed by recent neighbour discovery count as online.
+ // STALE, PERMANENT and NOARP can survive after a client disconnects.
+ const activeNeighborStates = new Set(["REACHABLE", "DELAY", "PROBE"]);
const byMac = new Map();
for (const lease of leases) {
const mac = String(lease.mac || "").toLowerCase();
- const key = mac || `ip:${lease.ip || Math.random()}`;
+ const key = mac || `ip:${lease.ip || "unknown"}`;
const neighbor = neighborsByMAC.get(mac) || neighborsByIP.get(String(lease.ip || ""));
const neighborState = String(neighbor && neighbor.state || "").toUpperCase();
- const presence = activeNeighborStates.has(neighborState) ? "online" : (neighborState === "STALE" ? "recent" : "reserved");
+ const presence = activeNeighborStates.has(neighborState) ? "online" : (neighborState === "STALE" ? "stale" : "reserved");
byMac.set(key, {
name: lease.hostname && lease.hostname !== "*" ? lease.hostname : "",
ip: lease.ip || "-",
mac: lease.mac || "-",
- connection: "Проводное / DHCP",
+ connection: presence === "online" ? `LAN ${neighbor && neighbor.interface || ""}`.trim() : "DHCP-запись",
type: "wired",
online: presence === "online",
presence,
@@ -877,26 +993,34 @@ function normalizedClients(device) {
presence: "online",
});
}
- return [...byMac.values()];
+ const presenceRank = { online: 0, stale: 1, reserved: 2 };
+ return [...byMac.values()].sort((left, right) => {
+ const rank = (presenceRank[left.presence] ?? 3) - (presenceRank[right.presence] ?? 3);
+ if (rank !== 0) return rank;
+ return String(left.name || left.ip).localeCompare(String(right.name || right.ip), undefined, { numeric: true, sensitivity: "base" });
+ });
}
function renderClients(device) {
const clients = normalizedClients(device);
const search = els.clientSearch.value.trim().toLowerCase();
const filtered = clients.filter((client) => {
- if (state.clientFilter !== "all" && client.type !== state.clientFilter) return false;
+ if (state.clientFilter === "online" && client.presence !== "online") return false;
+ if (state.clientFilter === "unconfirmed" && client.presence === "online") return false;
+ if (["wifi", "wired"].includes(state.clientFilter) && client.type !== state.clientFilter) return false;
return !search || [client.name, client.ip, client.mac, client.connection].join(" ").toLowerCase().includes(search);
});
els.clientList.innerHTML = "";
- const wifiCount = clients.filter((client) => client.type === "wifi").length;
- els.clientSummary.textContent = `${clients.length} всего / ${wifiCount} Wi-Fi`;
+ const onlineCount = clients.filter((client) => client.presence === "online").length;
+ const wifiOnline = clients.filter((client) => client.type === "wifi" && client.presence === "online").length;
+ els.clientSummary.textContent = `${onlineCount} в сети · ${wifiOnline} Wi-Fi · ${clients.length} известно`;
if (filtered.length === 0) {
els.clientList.innerHTML = inlineStateMarkup("Клиенты не найдены", "Проверьте фильтр или дождитесь обновления DHCP и Wi-Fi данных.");
return;
}
for (const client of filtered) {
const presence = client.presence || (client.online ? "online" : "reserved");
- const presenceLabel = presence === "online" ? "В сети" : (presence === "recent" ? "Недавно" : "DHCP-аренда");
+ const presenceLabel = presence === "online" ? "В сети" : (presence === "stale" ? "Не подтверждён" : "Нет в сети");
const row = document.createElement("div");
row.className = "client-row";
row.innerHTML = `
@@ -947,7 +1071,7 @@ function renderInterfaceCounters(device) {
async function loadMetricsHistory() {
if (!state.selectedDeviceId) return;
- const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/metrics-history?limit=12`);
+ const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/metrics-history?limit=48`);
renderMetricsHistory(data.samples || []);
}
@@ -958,35 +1082,61 @@ function renderMetricsHistory(samples) {
els.metricsHistory.innerHTML = inlineStateMarkup("История мониторинга пока пуста", "Графики появятся после нескольких heartbeat от агента.");
return;
}
- els.metricsHistorySummary.textContent = `${samples.length} замеров`;
const ordered = [...samples].reverse();
+ const historyRange = ordered.length > 1
+ ? `${formatRelativeTime(ordered[0].created_at)} — сейчас`
+ : "первый замер";
+ els.metricsHistorySummary.textContent = `${samples.length} точек · ${historyRange}`;
+ const point = (sample, value) => ({ value: Number.isFinite(Number(value)) ? Number(value) : 0, time: sample.created_at });
const memoryPoints = ordered.map((sample) => {
const memory = sample.metrics && sample.metrics.memory;
- return memory && memory.total_kb ? Math.round((Number(memory.used_kb || 0) / Number(memory.total_kb)) * 100) : 0;
+ return point(sample, memory && memory.total_kb ? Math.round((Number(memory.used_kb || 0) / Number(memory.total_kb)) * 100) : 0);
});
const diskPoints = ordered.map((sample) => {
const disk = sample.metrics && sample.metrics.disk;
- return disk && disk.used_percent ? Number(String(disk.used_percent).replace("%", "")) : 0;
+ return point(sample, disk && disk.used_percent ? Number(String(disk.used_percent).replace("%", "")) : 0);
});
const latencyPoints = ordered.map((sample) => {
const checks = Array.isArray(sample.metrics && sample.metrics.connectivity_checks) ? sample.metrics.connectivity_checks : [];
- return Math.max(0, ...checks.filter((check) => check.reachable).map((check) => Number(check.latency_ms || 0)));
+ return point(sample, Math.max(0, ...checks.filter((check) => check.reachable).map((check) => Number(check.latency_ms || 0))));
});
- els.metricsHistory.appendChild(metricChart("Память", "%", memoryPoints, "warn"));
- els.metricsHistory.appendChild(metricChart("Хранилище", "%", diskPoints, "good"));
+ const loadPoints = ordered.map((sample) => point(sample, Number.parseFloat(String(sample.metrics && sample.metrics.loadavg || "0")) || 0));
+ els.metricsHistory.appendChild(metricChart("Память", "%", memoryPoints, "warn", 100));
+ els.metricsHistory.appendChild(metricChart("Хранилище", "%", diskPoints, "good", 100));
els.metricsHistory.appendChild(metricChart("Задержка", "ms", latencyPoints, "accent"));
+ els.metricsHistory.appendChild(metricChart("Нагрузка", "", loadPoints, "violet"));
}
-function metricChart(label, unit, points, tone) {
- const max = Math.max(1, ...points);
+function metricChart(label, unit, points, tone, fixedMax = 0) {
+ const values = points.map((point) => Number(point.value || 0));
+ const max = fixedMax || Math.max(1, ...values);
+ const min = Math.min(...values);
+ const width = 320;
+ const height = 92;
+ const coordinates = values.map((value, index) => {
+ const x = values.length <= 1 ? width / 2 : (index / (values.length - 1)) * width;
+ const y = height - Math.max(2, Math.min(height - 2, (value / max) * (height - 8) + 4));
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ const linePoints = coordinates.join(" ");
+ const areaPoints = values.length ? `0,${height} ${linePoints} ${width},${height}` : "";
const card = document.createElement("div");
card.className = `metric-chart ${tone}`;
- const latest = points.length ? points[points.length - 1] : 0;
+ const latest = values.length ? values[values.length - 1] : 0;
+ const formatValue = (value) => Number(value).toLocaleString(undefined, { maximumFractionDigits: unit === "%" ? 0 : 2 });
+ const startTime = points.length ? formatShortDate(points[0].time) : "-";
+ const endTime = points.length ? formatShortDate(points[points.length - 1].time) : "-";
card.innerHTML = `
- ${escapeHtml(label)}${escapeHtml(latest)} ${escapeHtml(unit)}
-
- ${points.map((point) => `
`).join("")}
+
${escapeHtml(label)}${escapeHtml(formatValue(latest))}${unit ? ` ${escapeHtml(unit)}` : ""}
+
мин. ${escapeHtml(formatValue(min))}макс. ${escapeHtml(formatValue(Math.max(...values)))}
+
+
${escapeHtml(startTime)}${escapeHtml(endTime)}
`;
return card;
}
diff --git a/web/index.html b/web/index.html
index 4e33bba..cec7206 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,7 +10,7 @@
-
+
@@ -183,7 +183,7 @@
Объект
Модель и группа
WAN / интернет
- Известно клиентов
+ Онлайн / всего
Проблемы
Последний контакт
@@ -226,7 +226,9 @@
Состояние объекта
обновляется автоматически
+
+