diff --git a/UI_CHECKLIST.md b/UI_CHECKLIST.md index ea01b1d..f59787a 100644 --- a/UI_CHECKLIST.md +++ b/UI_CHECKLIST.md @@ -61,7 +61,7 @@ - [x] Показать load, память, uptime и число клиентов. - [x] Убрать дублирующиеся и технические метрики. - [ ] Показать активные предупреждения с приоритетами. -- [ ] Добавить компактные графики метрик. +- [x] Добавить компактные графики метрик. - [ ] Добавить выбор периода графиков. - [x] Добавить основную кнопку открытия LuCI. - [x] Добавить кнопку перехода к диагностике. @@ -76,7 +76,7 @@ - [x] Создать таблицу клиентов. - [~] Показывать online/offline состояние. - [x] Показывать имя, IP, MAC и тип подключения. -- [ ] Для Wi-Fi показывать сеть, сигнал и скорость. +- [~] Для Wi-Fi показывать сеть, сигнал и скорость. - [ ] Для Ethernet показывать интерфейс или порт, если доступно. - [x] Добавить поиск по имени, IP и MAC. - [~] Добавить фильтры по состоянию и типу подключения. @@ -137,8 +137,8 @@ ## Убираем и объединяем -- [ ] Убрать локальные Reload / Refresh из отдельных панелей. -- [ ] Добавить автообновление данных. +- [x] Убрать локальные Reload / Refresh из отдельных панелей. +- [x] Добавить автообновление данных. - [x] Оставить одну глобальную кнопку обновления на экране объектов. - [x] Убрать постоянный выбор массовой команды. - [ ] Показывать массовые действия только при выбранных объектах. @@ -152,7 +152,7 @@ ## Общие состояния и качество - [ ] Добавить skeleton loading. -- [ ] Добавить единый компонент пустого состояния. +- [~] Добавить единый компонент пустого состояния. - [ ] Добавить единый компонент ошибки. - [ ] Добавить toast-уведомления. - [ ] Добавить единые диалоги подтверждения. @@ -169,7 +169,7 @@ ## Требования к API -- [ ] Нормализовать данные клиентов. +- [~] Нормализовать данные клиентов. - [ ] Нормализовать состояние интерфейсов. - [ ] Подготовить временные ряды для графиков. - [ ] Добавить операторские операции поверх сырых команд. diff --git a/agent/openwrt/rmm-agent.sh b/agent/openwrt/rmm-agent.sh index 458fb8e..9c74924 100644 --- a/agent/openwrt/rmm-agent.sh +++ b/agent/openwrt/rmm-agent.sh @@ -223,10 +223,31 @@ wifi_clients_json() { if command -v iwinfo >/dev/null 2>&1; then for iface in $(iwinfo 2>/dev/null | awk '/^[^ ]/ { print $1 }'); do iwinfo "$iface" assoclist 2>/dev/null | awk -v iface="$iface" ' + function flush() { + if (mac != "") { + print iface "\t" mac "\t" signal "\t" rx "\t" tx + } + } /^[0-9A-Fa-f][0-9A-Fa-f]:/ { + flush() gsub(/,$/, "", $1) - print iface "\t" $1 - }' + mac=$1 + signal=$2 + rx="" + tx="" + next + } + /^[[:space:]]*RX:/ { + sub(/^[[:space:]]*RX:[[:space:]]*/, "") + rx=$0 + next + } + /^[[:space:]]*TX:/ { + sub(/^[[:space:]]*TX:[[:space:]]*/, "") + tx=$0 + next + } + END { flush() }' done | awk ' function esc(v) { gsub(/\\/, "\\\\", v) @@ -235,11 +256,15 @@ wifi_clients_json() { gsub(/\r/, "\\r", v) return v } - BEGIN { printf "[" } + BEGIN { FS="\t"; printf "[" } { if (count > 0) { printf "," } count++ - printf "{\"interface\":\"%s\",\"mac\":\"%s\"}", esc($1), esc($2) + printf "{\"interface\":\"%s\",\"mac\":\"%s\"", esc($1), esc($2) + if ($3 != "") { printf ",\"signal_dbm\":\"%s\"", esc($3) } + if ($4 != "") { printf ",\"rx_rate\":\"%s\"", esc($4) } + if ($5 != "") { printf ",\"tx_rate\":\"%s\"", esc($5) } + printf "}" } END { printf "]" }' return diff --git a/agent/package/rmm-agent/files/usr/bin/rmm-agent b/agent/package/rmm-agent/files/usr/bin/rmm-agent index 42df273..ca45e8e 100644 --- a/agent/package/rmm-agent/files/usr/bin/rmm-agent +++ b/agent/package/rmm-agent/files/usr/bin/rmm-agent @@ -222,10 +222,31 @@ wifi_clients_json() { if command -v iwinfo >/dev/null 2>&1; then for iface in $(iwinfo 2>/dev/null | awk '/^[^ ]/ { print $1 }'); do iwinfo "$iface" assoclist 2>/dev/null | awk -v iface="$iface" ' + function flush() { + if (mac != "") { + print iface "\t" mac "\t" signal "\t" rx "\t" tx + } + } /^[0-9A-Fa-f][0-9A-Fa-f]:/ { + flush() gsub(/,$/, "", $1) - print iface "\t" $1 - }' + mac=$1 + signal=$2 + rx="" + tx="" + next + } + /^[[:space:]]*RX:/ { + sub(/^[[:space:]]*RX:[[:space:]]*/, "") + rx=$0 + next + } + /^[[:space:]]*TX:/ { + sub(/^[[:space:]]*TX:[[:space:]]*/, "") + tx=$0 + next + } + END { flush() }' done | awk ' function esc(v) { gsub(/\\/, "\\\\", v) @@ -234,11 +255,15 @@ wifi_clients_json() { gsub(/\r/, "\\r", v) return v } - BEGIN { printf "[" } + BEGIN { FS="\t"; printf "[" } { if (count > 0) { printf "," } count++ - printf "{\"interface\":\"%s\",\"mac\":\"%s\"}", esc($1), esc($2) + printf "{\"interface\":\"%s\",\"mac\":\"%s\"", esc($1), esc($2) + if ($3 != "") { printf ",\"signal_dbm\":\"%s\"", esc($3) } + if ($4 != "") { printf ",\"rx_rate\":\"%s\"", esc($4) } + if ($5 != "") { printf ",\"tx_rate\":\"%s\"", esc($5) } + printf "}" } END { printf "]" }' return diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 7e2b2fe..176e5b2 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -839,7 +839,8 @@ func (a *App) handleListCommands(w http.ResponseWriter, r *http.Request) { } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - commands, found, err := a.store.ListCommands(r.Context(), deviceID, store.CommandListOptions{Limit: limit}) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + commands, found, err := a.store.ListCommands(r.Context(), deviceID, store.CommandListOptions{Limit: limit, Offset: offset}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load commands") return @@ -1243,9 +1244,11 @@ func (a *App) handleCloseRemoteSession(w http.ResponseWriter, r *http.Request) { func (a *App) handleListAuditEvents(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) events, err := a.store.ListAuditEvents(r.Context(), store.AuditListOptions{ DeviceID: r.URL.Query().Get("device_id"), Limit: limit, + Offset: offset, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load audit events") diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 61b7232..c1bae21 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -21,7 +21,8 @@ type Store struct { } type CommandListOptions struct { - Limit int + Limit int + Offset int } type MetricHistoryOptions struct { @@ -31,6 +32,7 @@ type MetricHistoryOptions struct { type AuditListOptions struct { DeviceID string Limit int + Offset int } type AlertListOptions struct { @@ -741,14 +743,18 @@ func (s *Store) ListCommands(ctx context.Context, deviceID string, opts CommandL if limit <= 0 || limit > 200 { limit = 50 } + offset := opts.Offset + if offset < 0 { + offset = 0 + } rows, err := s.db.QueryContext(ctx, ` SELECT id, device_id, type, args_json, status, result_json, output, exit_code, attempt_count, max_attempts, created_at, expires_at, claimed_at, completed_at, cancelled_at, expired_at FROM commands WHERE device_id = ? ORDER BY created_at DESC -LIMIT ? -`, deviceID, limit) +LIMIT ? OFFSET ? +`, deviceID, limit, offset) if err != nil { return nil, false, err } @@ -988,6 +994,10 @@ func (s *Store) ListAuditEvents(ctx context.Context, opts AuditListOptions) ([]m if limit <= 0 || limit > 200 { limit = 50 } + offset := opts.Offset + if offset < 0 { + offset = 0 + } query := ` SELECT id, actor, action, device_id, command_id, details_json, created_at @@ -998,8 +1008,8 @@ FROM audit_events query += `WHERE device_id = ?` + "\n" args = append(args, opts.DeviceID) } - query += `ORDER BY created_at DESC LIMIT ?` - args = append(args, limit) + query += `ORDER BY created_at DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { diff --git a/web/app.js b/web/app.js index a031f65..de049dc 100644 --- a/web/app.js +++ b/web/app.js @@ -7,7 +7,14 @@ const state = { clientFilter: "all", commandFilter: "all", commandStatusFilter: "", + commandLimit: 25, + commandOffset: 0, + commandHasMore: false, + auditLimit: 25, + auditOffset: 0, + auditHasMore: false, commands: [], + auditEvents: [], alerts: [], remoteSessions: [], selectedCommand: null, @@ -83,10 +90,9 @@ const els = { fleetGroup: document.querySelector("#fleetGroup"), fleetTags: document.querySelector("#fleetTags"), saveFleetBtn: document.querySelector("#saveFleetBtn"), - reloadAlertsBtn: document.querySelector("#reloadAlertsBtn"), alertSummary: document.querySelector("#alertSummary"), alertList: document.querySelector("#alertList"), - reloadMetricsHistoryBtn: document.querySelector("#reloadMetricsHistoryBtn"), + metricsHistorySummary: document.querySelector("#metricsHistorySummary"), metricsHistory: document.querySelector("#metricsHistory"), commandType: document.querySelector("#commandType"), commandTarget: document.querySelector("#commandTarget"), @@ -101,7 +107,6 @@ const els = { remoteLuCIScheme: document.querySelector("#remoteLuCIScheme"), remoteDuration: document.querySelector("#remoteDuration"), createRemoteSessionBtn: document.querySelector("#createRemoteSessionBtn"), - reloadRemoteSessionsBtn: document.querySelector("#reloadRemoteSessionsBtn"), remoteSummary: document.querySelector("#remoteSummary"), remoteSessionList: document.querySelector("#remoteSessionList"), uciConfig: document.querySelector("#uciConfig"), @@ -129,10 +134,10 @@ const els = { presetReviewOutput: document.querySelector("#presetReviewOutput"), cancelPresetReviewBtn: document.querySelector("#cancelPresetReviewBtn"), applyPresetReviewBtn: document.querySelector("#applyPresetReviewBtn"), - reloadCommandsBtn: document.querySelector("#reloadCommandsBtn"), - reloadAuditBtn: document.querySelector("#reloadAuditBtn"), commandSummary: document.querySelector("#commandSummary"), commandStatusFilter: document.querySelector("#commandStatusFilter"), + loadMoreCommandsBtn: document.querySelector("#loadMoreCommandsBtn"), + loadMoreAuditBtn: document.querySelector("#loadMoreAuditBtn"), auditSummary: document.querySelector("#auditSummary"), commandList: document.querySelector("#commandList"), commandDetailPanel: document.querySelector("#commandDetailPanel"), @@ -598,6 +603,8 @@ function normalizedClients(device) { ip: existing.ip || "-", mac: station.mac || existing.mac || "-", connection: `Wi-Fi ${station.interface || ""}`.trim(), + signal: station.signal_dbm ? `${station.signal_dbm} dBm` : "", + rate: [station.rx_rate ? `RX ${station.rx_rate}` : "", station.tx_rate ? `TX ${station.tx_rate}` : ""].filter(Boolean).join(" / "), type: "wifi", online: true, }); @@ -627,6 +634,7 @@ function renderClients(device) { ${escapeHtml(client.ip)} ${escapeHtml(client.mac)} ${escapeHtml(client.connection)} + ${escapeHtml([client.signal, client.rate].filter(Boolean).join(" · ") || "-")} В сети `; els.clientList.appendChild(row); @@ -676,15 +684,41 @@ async function loadMetricsHistory() { function renderMetricsHistory(samples) { els.metricsHistory.innerHTML = ""; if (samples.length === 0) { - els.metricsHistory.textContent = "No history yet"; + els.metricsHistorySummary.textContent = "Нет замеров"; + els.metricsHistory.innerHTML = '
История мониторинга появится после нескольких heartbeat от агента
'; return; } - for (const sample of samples) { - const row = document.createElement("div"); - row.className = "mini-row"; - row.innerHTML = `${escapeHtml(formatDate(sample.created_at))}${escapeHtml(formatMemory(sample.metrics && sample.metrics.memory))}${escapeHtml(formatDisk(sample.metrics && sample.metrics.disk))}`; - els.metricsHistory.appendChild(row); - } + els.metricsHistorySummary.textContent = `${samples.length} замеров`; + const ordered = [...samples].reverse(); + 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; + }); + const diskPoints = ordered.map((sample) => { + const disk = sample.metrics && sample.metrics.disk; + return 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))); + }); + els.metricsHistory.appendChild(metricChart("Память", "%", memoryPoints, "warn")); + els.metricsHistory.appendChild(metricChart("Хранилище", "%", diskPoints, "good")); + els.metricsHistory.appendChild(metricChart("Задержка", "ms", latencyPoints, "accent")); +} + +function metricChart(label, unit, points, tone) { + const max = Math.max(1, ...points); + const card = document.createElement("div"); + card.className = `metric-chart ${tone}`; + const latest = points.length ? points[points.length - 1] : 0; + card.innerHTML = ` +
${escapeHtml(label)}${escapeHtml(latest)} ${escapeHtml(unit)}
+
+ ${points.map((point) => ``).join("")} +
+ `; + return card; } async function loadAlerts() { @@ -746,6 +780,10 @@ async function selectDevice(id) { state.selectedDeviceId = id; state.deviceTab = "overview"; state.presetReview = null; + state.commandOffset = 0; + state.auditOffset = 0; + state.commands = []; + state.auditEvents = []; renderDevices(); renderDeviceDetail(currentDevice()); await Promise.all([loadCommands(), loadAudit(), loadMetricsHistory(), loadAlerts(), loadRemoteSessions()]); @@ -755,6 +793,10 @@ function showFleet() { state.selectedDeviceId = null; state.selectedCommand = null; state.presetReview = null; + state.commandOffset = 0; + state.auditOffset = 0; + state.commands = []; + state.auditEvents = []; renderDevices(); renderDeviceDetail(null); } @@ -784,11 +826,17 @@ function openLuciOrRemoteAccess() { setStatus("Создайте удаленную сессию, затем откройте LuCI"); } -async function loadCommands() { +async function loadCommands(options = {}) { if (!state.selectedDeviceId) return; - const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands?limit=50`); - state.commands = data.commands || []; + const append = Boolean(options.append); + const offset = append ? state.commandOffset : 0; + const data = await api(`/api/devices/${encodeURIComponent(state.selectedDeviceId)}/commands?limit=${state.commandLimit}&offset=${offset}`); + const nextCommands = data.commands || []; + state.commands = append ? [...state.commands, ...nextCommands] : nextCommands; + state.commandOffset = offset + nextCommands.length; + state.commandHasMore = nextCommands.length === state.commandLimit; renderCommands(state.commands); + renderCommandLoadMore(); renderPresetReview(); if (state.selectedCommand) { const refreshed = state.commands.find((command) => command.id === state.selectedCommand.id); @@ -797,6 +845,10 @@ async function loadCommands() { } } +function renderCommandLoadMore() { + els.loadMoreCommandsBtn.classList.toggle("is-hidden", !state.commandHasMore); +} + function renderCommands(commands) { els.commandList.innerHTML = ""; renderCommandSummary(commands); @@ -878,10 +930,22 @@ function outputSummary(output) { return firstLine.length > 90 ? `${firstLine.slice(0, 90)}...` : firstLine; } -async function loadAudit() { +async function loadAudit(options = {}) { if (!state.selectedDeviceId) return; - const data = await api(`/api/audit-events?device_id=${encodeURIComponent(state.selectedDeviceId)}&limit=50`); - renderAudit(data.audit_events || []); + const append = Boolean(options.append); + const offset = append ? state.auditOffset : 0; + const data = await api(`/api/audit-events?device_id=${encodeURIComponent(state.selectedDeviceId)}&limit=${state.auditLimit}&offset=${offset}`); + const events = data.audit_events || []; + const allEvents = append ? [...state.auditEvents || [], ...events] : events; + state.auditEvents = allEvents; + state.auditOffset = offset + events.length; + state.auditHasMore = events.length === state.auditLimit; + renderAudit(allEvents); + renderAuditLoadMore(); +} + +function renderAuditLoadMore() { + els.loadMoreAuditBtn.classList.toggle("is-hidden", !state.auditHasMore); } function renderAudit(events) { @@ -1382,11 +1446,8 @@ els.backToFleetBtn.addEventListener("click", showFleet); els.quickDiagnosticBtn.addEventListener("click", () => selectDeviceTab("operations")); els.openLuciBtn.addEventListener("click", openLuciOrRemoteAccess); els.runFullDiagnosticBtn.addEventListener("click", () => runFullDiagnostic().catch((error) => setStatus(error.message))); -els.reloadCommandsBtn.addEventListener("click", () => loadCommands().catch((error) => setStatus(error.message))); -els.reloadAuditBtn.addEventListener("click", () => loadAudit().catch((error) => setStatus(error.message))); -els.reloadAlertsBtn.addEventListener("click", () => loadAlerts().catch((error) => setStatus(error.message))); -els.reloadMetricsHistoryBtn.addEventListener("click", () => loadMetricsHistory().catch((error) => setStatus(error.message))); -els.reloadRemoteSessionsBtn.addEventListener("click", () => loadRemoteSessions().catch((error) => setStatus(error.message))); +els.loadMoreCommandsBtn.addEventListener("click", () => loadCommands({ append: true }).catch((error) => setStatus(error.message))); +els.loadMoreAuditBtn.addEventListener("click", () => loadAudit({ append: true }).catch((error) => setStatus(error.message))); els.sendCommandBtn.addEventListener("click", () => sendCommand().catch((error) => setStatus(error.message))); els.sendBulkCommandBtn.addEventListener("click", () => sendBulkCommand().catch((error) => setStatus(error.message))); els.saveFleetBtn.addEventListener("click", () => saveFleetMetadata().catch((error) => setStatus(error.message))); diff --git a/web/index.html b/web/index.html index eeccf8f..bbba0e2 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ OpenWrt RMM - +
@@ -381,6 +381,7 @@ IP-адрес MAC-адрес Подключение + Сигнал / скорость Состояние
@@ -410,7 +411,6 @@

Проблемы

0 active -
@@ -419,9 +419,9 @@

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

- + 12 замеров
-
+
@@ -493,7 +493,6 @@
Нет активных сессий -
@@ -604,7 +603,6 @@

История команд

0 total -
@@ -623,6 +621,7 @@
+
- + diff --git a/web/styles.css b/web/styles.css index f5f4fdb..4bcfde5 100644 --- a/web/styles.css +++ b/web/styles.css @@ -870,10 +870,10 @@ select { .client-table-head, .client-row { display: grid; - grid-template-columns: minmax(220px, 1.3fr) minmax(130px, 0.7fr) minmax(160px, 0.9fr) minmax(160px, 0.9fr) 100px; + grid-template-columns: minmax(220px, 1.3fr) minmax(130px, 0.7fr) minmax(160px, 0.9fr) minmax(150px, 0.8fr) minmax(180px, 1fr) 100px; gap: 14px; align-items: center; - min-width: 900px; + min-width: 1100px; } .client-table-head, @@ -1001,6 +1001,62 @@ select { text-align: center; } +.metrics-chart-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.metric-chart { + display: grid; + gap: 12px; + min-height: 170px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-raised); + padding: 12px; +} + +.metric-chart-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.metric-chart-title span { + color: var(--muted); + font-size: 12px; +} + +.metric-bars { + display: flex; + align-items: end; + gap: 4px; + height: 110px; + border-bottom: 1px solid var(--line); +} + +.metric-bars i { + flex: 1; + min-width: 4px; + border-radius: 3px 3px 0 0; + background: var(--accent); +} + +.metric-chart.warn .metric-bars i { + background: var(--warn); +} + +.metric-chart.good .metric-bars i { + background: var(--good); +} + +.load-more-btn { + width: 100%; + margin-top: 10px; +} + .filters { display: grid; grid-template-columns: repeat(4, 1fr); @@ -1593,7 +1649,8 @@ select { .client-toolbar, .network-health, - .information-grid { + .information-grid, + .metrics-chart-grid { grid-template-columns: 1fr; }