diff --git a/UI_CHECKLIST.md b/UI_CHECKLIST.md index e53be66..a06929b 100644 --- a/UI_CHECKLIST.md +++ b/UI_CHECKLIST.md @@ -72,27 +72,27 @@ ## Этап 3. Клиенты -- [ ] Объединить DHCP leases и Wi-Fi stations в единую модель клиента. -- [ ] Создать таблицу клиентов. -- [ ] Показывать online/offline состояние. -- [ ] Показывать имя, IP, MAC и тип подключения. +- [x] Объединить DHCP leases и Wi-Fi stations в единую модель клиента. +- [x] Создать таблицу клиентов. +- [~] Показывать online/offline состояние. +- [x] Показывать имя, IP, MAC и тип подключения. - [ ] Для Wi-Fi показывать сеть, сигнал и скорость. - [ ] Для Ethernet показывать интерфейс или порт, если доступно. -- [ ] Добавить поиск по имени, IP и MAC. -- [ ] Добавить фильтры по состоянию и типу подключения. -- [ ] Добавить понятное состояние отсутствия клиентов. -- [ ] Проверить длинные имена и неизвестные значения. +- [x] Добавить поиск по имени, IP и MAC. +- [~] Добавить фильтры по состоянию и типу подключения. +- [x] Добавить понятное состояние отсутствия клиентов. +- [x] Проверить длинные имена и неизвестные значения. ## Этап 3. Сеть -- [ ] Создать сводку WAN-подключения. -- [ ] Показывать состояние интернет-доступа и внешний IP. -- [ ] Создать список LAN/WAN/Wi-Fi/VPN интерфейсов. -- [ ] Показывать адреса, состояние и счетчики трафика. -- [ ] Показать основные маршруты. +- [x] Создать сводку WAN-подключения. +- [x] Показывать состояние интернет-доступа и внешний IP. +- [x] Создать список LAN/WAN/Wi-Fi/VPN интерфейсов. +- [~] Показывать адреса, состояние и счетчики трафика. +- [~] Показать основные маршруты. - [ ] Показывать Wi-Fi сети и подключенных клиентов. - [ ] Спрятать низкоуровневые детали в раскрываемые блоки. -- [ ] Добавить loading, empty и error states. +- [~] Добавить loading, empty и error states. ## Этап 4. Операции diff --git a/web/app.js b/web/app.js index 98a4c7f..4d15dff 100644 --- a/web/app.js +++ b/web/app.js @@ -4,6 +4,7 @@ const state = { selectedDeviceId: null, deviceTab: "overview", filter: "all", + clientFilter: "all", commandFilter: "all", commands: [], alerts: [], @@ -52,7 +53,11 @@ const els = { connectivityStatus: document.querySelector("#connectivityStatus"), inventoryJson: document.querySelector("#inventoryJson"), clientList: document.querySelector("#clientList"), + clientSummary: document.querySelector("#clientSummary"), + clientSearch: document.querySelector("#clientSearch"), interfaceCounters: document.querySelector("#interfaceCounters"), + networkSummary: document.querySelector("#networkSummary"), + networkHealth: document.querySelector("#networkHealth"), fleetSearch: document.querySelector("#fleetSearch"), fleetGroupFilter: document.querySelector("#fleetGroupFilter"), fleetTagFilter: document.querySelector("#fleetTagFilter"), @@ -464,39 +469,107 @@ function kbToMb(value) { return Math.round((Number(value || 0) / 1024) * 10) / 10; } -function renderClients(device) { +function formatBytes(value) { + let amount = Number(value || 0); + const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; + let unit = 0; + while (amount >= 1024 && unit < units.length - 1) { + amount /= 1024; + unit += 1; + } + return `${amount >= 10 || unit === 0 ? Math.round(amount) : amount.toFixed(1)} ${units[unit]}`; +} + +function normalizedClients(device) { const leases = Array.isArray(device.inventory && device.inventory.dhcp_leases) ? device.inventory.dhcp_leases : []; const wifi = Array.isArray(device.inventory && device.inventory.wifi_clients) ? device.inventory.wifi_clients : []; + const byMac = new Map(); + for (const lease of leases) { + const mac = String(lease.mac || "").toLowerCase(); + const key = mac || `ip:${lease.ip || Math.random()}`; + byMac.set(key, { + name: lease.hostname && lease.hostname !== "*" ? lease.hostname : "", + ip: lease.ip || "-", + mac: lease.mac || "-", + connection: "Проводное / DHCP", + type: "wired", + online: true, + }); + } + for (const station of wifi) { + const mac = String(station.mac || "").toLowerCase(); + const existing = byMac.get(mac) || {}; + byMac.set(mac || `wifi:${station.interface || Math.random()}`, { + ...existing, + name: existing.name || `Wi-Fi клиент ${station.mac || ""}`, + ip: existing.ip || "-", + mac: station.mac || existing.mac || "-", + connection: `Wi-Fi ${station.interface || ""}`.trim(), + type: "wifi", + online: true, + }); + } + return [...byMac.values()]; +} + +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; + return !search || [client.name, client.ip, client.mac, client.connection].join(" ").toLowerCase().includes(search); + }); els.clientList.innerHTML = ""; - if (leases.length === 0 && wifi.length === 0) { - els.clientList.textContent = "No client data"; + const wifiCount = clients.filter((client) => client.type === "wifi").length; + els.clientSummary.textContent = `${clients.length} всего / ${wifiCount} Wi-Fi`; + if (filtered.length === 0) { + els.clientList.innerHTML = '
Клиенты не найдены
'; return; } - for (const lease of leases) { + for (const client of filtered) { const row = document.createElement("div"); - row.className = "mini-row"; - row.innerHTML = `${escapeHtml(lease.hostname || lease.ip || "dhcp-client")}${escapeHtml(lease.ip || "-")}${escapeHtml(lease.mac || "-")}`; - els.clientList.appendChild(row); - } - for (const client of wifi) { - const row = document.createElement("div"); - row.className = "mini-row"; - row.innerHTML = `Wi-Fi ${escapeHtml(client.interface || "")}${escapeHtml(client.mac || "-")}${escapeHtml(client.access_point || "-")}`; + row.className = "client-row"; + row.innerHTML = ` +
${client.type === "wifi" ? "⌁" : "▣"}${escapeHtml(client.name || client.ip || "Неизвестное устройство")}
+ ${escapeHtml(client.ip)} + ${escapeHtml(client.mac)} + ${escapeHtml(client.connection)} + В сети + `; els.clientList.appendChild(row); } } function renderInterfaceCounters(device) { const counters = Array.isArray(device.metrics && device.metrics.interface_counters) ? device.metrics.interface_counters : []; + const addresses = Array.isArray(device.inventory && device.inventory.interfaces) ? device.inventory.interfaces : []; + const byName = new Map(); + for (const address of addresses) { + if (!byName.has(address.name)) byName.set(address.name, []); + byName.get(address.name).push(address.address); + } els.interfaceCounters.innerHTML = ""; + els.networkSummary.textContent = `${counters.length} интерфейсов`; + els.networkHealth.innerHTML = ` +
WAN-адрес${escapeHtml(device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "Нет данных")}
+
Маршрут по умолчанию${escapeHtml(device.inventory && device.inventory.default_route ? device.inventory.default_route : "Нет данных")}
+
Проверки связи${escapeHtml(formatConnectivity(device.metrics && device.metrics.connectivity_checks))}
+ `; if (counters.length === 0) { - els.interfaceCounters.textContent = "No interface counters"; + els.interfaceCounters.innerHTML = '
Нет данных об интерфейсах
'; return; } for (const item of counters) { const row = document.createElement("div"); - row.className = "mini-row"; - row.innerHTML = `${escapeHtml(item.name)}rx ${escapeHtml(item.rx_packets || 0)} / tx ${escapeHtml(item.tx_packets || 0)}err ${escapeHtml(item.rx_errors || 0)} / ${escapeHtml(item.tx_errors || 0)}`; + const errors = Number(item.rx_errors || 0) + Number(item.tx_errors || 0); + row.className = "network-row"; + row.innerHTML = ` + ${escapeHtml(item.name || "-")} + ${escapeHtml((byName.get(item.name) || []).join(", ") || "-")} + ${escapeHtml(formatBytes(item.rx_bytes))}${escapeHtml(item.rx_packets || 0)} пакетов + ${escapeHtml(formatBytes(item.tx_bytes))}${escapeHtml(item.tx_packets || 0)} пакетов + ${escapeHtml(errors)} + `; els.interfaceCounters.appendChild(row); } } @@ -1118,6 +1191,16 @@ for (const button of document.querySelectorAll(".device-tab")) { button.addEventListener("click", () => selectDeviceTab(button.dataset.deviceTabTarget)); } +for (const button of document.querySelectorAll(".client-filter")) { + button.addEventListener("click", () => { + state.clientFilter = button.dataset.clientFilter; + document.querySelectorAll(".client-filter").forEach((item) => item.classList.toggle("is-active", item === button)); + renderClients(currentDevice()); + }); +} + +els.clientSearch.addEventListener("input", () => renderClients(currentDevice())); + for (const button of document.querySelectorAll(".filter")) { button.addEventListener("click", () => { state.filter = button.dataset.filter; diff --git a/web/index.html b/web/index.html index 9807312..505c5d1 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ OpenWrt RMM - +
@@ -158,7 +158,8 @@
- + diff --git a/web/styles.css b/web/styles.css index 9020959..b8262e1 100644 --- a/web/styles.css +++ b/web/styles.css @@ -647,6 +647,166 @@ select { font-size: 16px; } +.section-subtitle { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; +} + +.client-toolbar { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: 10px; + margin-bottom: 12px; +} + +.client-filters { + display: flex; + gap: 6px; + margin: 0; +} + +.client-filter.is-active { + background: var(--surface-muted); + border-color: var(--accent); + color: var(--accent); + font-weight: 700; +} + +.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; + gap: 14px; + align-items: center; + min-width: 900px; +} + +.client-table-head, +.network-table-head { + min-height: 38px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 12px; + font-weight: 700; + padding: 0 10px; +} + +.client-list, +.network-list { + overflow-x: auto; +} + +.client-row, +.network-row { + min-height: 58px; + border-bottom: 1px solid var(--line); + padding: 8px 10px; +} + +.client-row:last-child, +.network-row:last-child { + border-bottom: 0; +} + +.client-name { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.client-name strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.client-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; + place-items: center; + border-radius: 50%; + background: #2c4144; + color: var(--good); + font-size: 18px; +} + +.client-online { + display: inline-flex; + gap: 7px; + align-items: center; + color: var(--good); + font-weight: 700; +} + +.client-online i { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.network-health { + display: grid; + grid-template-columns: 0.8fr 1.3fr 1fr; + gap: 10px; + margin-bottom: 14px; +} + +.network-health > div { + display: grid; + gap: 5px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-raised); + padding: 10px; +} + +.network-health span, +.network-row small { + color: var(--muted); + font-size: 12px; +} + +.network-health strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-table-head, +.network-row { + display: grid; + grid-template-columns: 130px minmax(260px, 1.4fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) 80px; + gap: 14px; + align-items: center; + min-width: 900px; +} + +.network-row span { + display: grid; + gap: 3px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.interface-errors { + color: var(--bad); + font-weight: 800; +} + +.inline-empty { + color: var(--muted); + padding: 24px 10px; + text-align: center; +} + .filters { display: grid; grid-template-columns: repeat(4, 1fr); @@ -1103,6 +1263,15 @@ select { flex-wrap: wrap; } + .client-toolbar, + .network-health { + grid-template-columns: 1fr; + } + + .client-filters { + overflow-x: auto; + } + .row, .row.audit, .command-form,