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 @@

Состояние объекта

обновляется автоматически
+
+
@@ -432,8 +434,10 @@
+ +
@@ -483,7 +487,10 @@
-

История мониторинга

+
+

История мониторинга

+

Последние показатели с временной шкалой

+
12 замеров
@@ -827,6 +834,6 @@
- + diff --git a/web/styles.css b/web/styles.css index ecfd0f0..d3d4f78 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1248,6 +1248,10 @@ select { color: var(--warn); } +.client-online.stale { + color: var(--warn); +} + .client-online.reserved { color: var(--muted); } @@ -2392,6 +2396,135 @@ select { } } +/* Device cockpit v2. */ +.device-status-hero { + display: flex; + gap: 15px; + align-items: center; + border: 1px solid var(--line); + border-radius: 15px; + margin-bottom: 12px; + padding: 16px; +} + +.device-status-hero.good { + border-color: rgb(85 214 135 / 42%); + background: linear-gradient(100deg, rgb(85 214 135 / 12%), transparent 72%); +} + +.device-status-hero.warn { + border-color: rgb(245 189 87 / 50%); + background: linear-gradient(100deg, rgb(245 189 87 / 13%), transparent 72%); +} + +.device-status-hero.bad { + border-color: rgb(255 100 119 / 50%); + background: linear-gradient(100deg, rgb(255 100 119 / 13%), transparent 72%); +} + +.device-status-icon { + display: grid; + width: 45px; + height: 45px; + flex: 0 0 45px; + place-items: center; + border-radius: 13px; + background: rgb(255 255 255 / 8%); + color: var(--good); + font-size: 21px; + font-weight: 900; +} + +.device-status-hero.warn .device-status-icon { color: var(--warn); } +.device-status-hero.bad .device-status-icon { color: var(--bad); } +.device-status-hero > div { display: grid; gap: 4px; } +.device-status-hero strong { font-size: 17px; } +.device-status-hero p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; } + +.agent-health-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 14px; + margin-top: 12px; + background: var(--line); +} + +.agent-health-summary > div { + display: grid; + min-width: 0; + gap: 5px; + background: var(--surface-raised); + padding: 12px; +} + +.agent-health-summary span { color: var(--muted); font-size: 11px; } +.agent-health-summary strong { overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.agent-health-summary .has-agent-error strong { color: var(--bad); } +.agent-health-summary .has-agent-warning strong { color: var(--warn); } +.agent-health-message { + grid-column: 1 / -1; + margin: 0; + background: var(--surface-raised); + color: var(--muted); + font-size: 12px; + line-height: 1.45; + padding: 11px 12px; +} + +.metrics-chart-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric-chart { + min-height: 205px; + border-radius: 14px; + background: linear-gradient(145deg, var(--surface-raised), rgb(11 23 34 / 96%)); + padding: 15px; +} + +.metric-chart-title strong { font-size: 20px; } +.metric-chart-meta, +.metric-chart-axis { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 10px; +} + +.metric-line-chart { + height: 92px; + border-block: 1px solid rgb(72 96 119 / 45%); + background: linear-gradient(to bottom, transparent 49%, rgb(72 96 119 / 22%) 50%, transparent 51%); +} + +.metric-line-chart svg { display: block; width: 100%; height: 100%; overflow: visible; } +.metric-line-chart line { stroke: rgb(94 119 143 / 22%); stroke-width: 1; vector-effect: non-scaling-stroke; } +.metric-line-chart polygon { fill: rgb(39 182 230 / 10%); } +.metric-line-chart polyline { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } +.metric-chart.warn .metric-line-chart polygon { fill: rgb(245 189 87 / 10%); } +.metric-chart.warn .metric-line-chart polyline { stroke: var(--warn); } +.metric-chart.good .metric-line-chart polygon { fill: rgb(85 214 135 / 10%); } +.metric-chart.good .metric-line-chart polyline { stroke: var(--good); } +.metric-chart.violet .metric-line-chart polygon { fill: rgb(126 118 255 / 10%); } +.metric-chart.violet .metric-line-chart polyline { stroke: #8f88ff; } + +.client-filters { flex-wrap: wrap; } + +@media (max-width: 720px) { + .device-status-hero { align-items: flex-start; padding: 14px; } + .device-status-hero p { font-size: 12px; } + .health-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .health-item:last-child { grid-column: 1 / -1; } + .agent-health-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metrics-chart-grid { grid-template-columns: 1fr; } + .metric-chart { min-height: 190px; } + .client-filters { display: flex; overflow-x: auto; flex-wrap: nowrap; } + .client-filter { flex: 0 0 auto; } +} + dialog { border: 0; border-radius: 16px; @@ -3128,3 +3261,96 @@ select:focus-visible, overflow-wrap: anywhere; } } + +@media (max-width: 1200px) { + .fleet-view { grid-template-columns: minmax(0, 1fr); } + .fleet-summary, + .fleet-toolbar, + .fleet-table-shell, + .device-list { width: 100%; min-width: 0; max-width: 100%; } + .fleet-toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .fleet-toolbar > input:first-child, + .fleet-toolbar .filters { grid-column: 1 / -1; } + .client-toolbar { grid-template-columns: minmax(0, 1fr); } + .client-filters { min-width: 0; max-width: 100%; } + .client-table-head { display: none; } + .client-list { display: grid; gap: 10px; overflow: visible; } + .client-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface-raised); + padding: 14px; + } + .client-row .client-name { grid-column: 1 / -1; } + .client-row > :not(:first-child) { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; } + .client-row > :nth-child(2)::before { content: "IP"; } + .client-row > :nth-child(3)::before { content: "MAC"; } + .client-row > :nth-child(4)::before { content: "Подключение"; } + .client-row > :nth-child(5)::before { content: "Сигнал / скорость"; } + .client-row > :nth-child(6)::before { content: "Статус"; } + .client-row > :not(:first-child)::before { + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; + } +} + +@media (max-width: 720px) { + .fleet-view, + .fleet-summary, + .fleet-toolbar, + .fleet-table-shell, + .device-list { + width: 100%; + min-width: 0; + max-width: 100%; + } + + .fleet-table-shell { + overflow-x: hidden; + } + + .device-item { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + width: 100%; + min-width: 0; + min-height: auto; + } + + .device-item > .fleet-status, + .device-item > .device-identity, + .device-item > .device-context { + grid-column: 1 / -1; + } + + .device-item > .problem-count, + .device-item > .last-contact, + .device-item > .device-field { + display: grid; + min-width: 0; + gap: 4px; + align-content: start; + } + + .row-chevron { + display: none; + } + + .client-row { grid-template-columns: 1fr; } + .client-row .client-name { grid-column: 1; } + .client-filters { + display: flex; + max-width: 100%; + flex-direction: row; + align-items: center; + overflow-x: auto; + padding-bottom: 2px; + } + .client-filter { width: auto; flex: 0 0 auto; } +}