Improve client and network views

This commit is contained in:
benya
2026-06-04 20:21:09 +03:00
parent 50bcac4c8d
commit b4149b86f6
4 changed files with 326 additions and 44 deletions
+98 -15
View File
@@ -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 = '<div class="inline-empty">Клиенты не найдены</div>';
return;
}
for (const lease of leases) {
for (const client of filtered) {
const row = document.createElement("div");
row.className = "mini-row";
row.innerHTML = `<strong>${escapeHtml(lease.hostname || lease.ip || "dhcp-client")}</strong><span>${escapeHtml(lease.ip || "-")}</span><small>${escapeHtml(lease.mac || "-")}</small>`;
els.clientList.appendChild(row);
}
for (const client of wifi) {
const row = document.createElement("div");
row.className = "mini-row";
row.innerHTML = `<strong>Wi-Fi ${escapeHtml(client.interface || "")}</strong><span>${escapeHtml(client.mac || "-")}</span><small>${escapeHtml(client.access_point || "-")}</small>`;
row.className = "client-row";
row.innerHTML = `
<div class="client-name"><span class="client-icon">${client.type === "wifi" ? "⌁" : "▣"}</span><strong>${escapeHtml(client.name || client.ip || "Неизвестное устройство")}</strong></div>
<span>${escapeHtml(client.ip)}</span>
<code>${escapeHtml(client.mac)}</code>
<span>${escapeHtml(client.connection)}</span>
<span class="client-online"><i></i>В сети</span>
`;
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 = `
<div><span>WAN-адрес</span><strong>${escapeHtml(device.inventory && device.inventory.wan_ip ? device.inventory.wan_ip : "Нет данных")}</strong></div>
<div><span>Маршрут по умолчанию</span><strong>${escapeHtml(device.inventory && device.inventory.default_route ? device.inventory.default_route : "Нет данных")}</strong></div>
<div><span>Проверки связи</span><strong>${escapeHtml(formatConnectivity(device.metrics && device.metrics.connectivity_checks))}</strong></div>
`;
if (counters.length === 0) {
els.interfaceCounters.textContent = "No interface counters";
els.interfaceCounters.innerHTML = '<div class="inline-empty">Нет данных об интерфейсах</div>';
return;
}
for (const item of counters) {
const row = document.createElement("div");
row.className = "mini-row";
row.innerHTML = `<strong>${escapeHtml(item.name)}</strong><span>rx ${escapeHtml(item.rx_packets || 0)} / tx ${escapeHtml(item.tx_packets || 0)}</span><small>err ${escapeHtml(item.rx_errors || 0)} / ${escapeHtml(item.tx_errors || 0)}</small>`;
const errors = Number(item.rx_errors || 0) + Number(item.tx_errors || 0);
row.className = "network-row";
row.innerHTML = `
<strong>${escapeHtml(item.name || "-")}</strong>
<span>${escapeHtml((byName.get(item.name) || []).join(", ") || "-")}</span>
<span>${escapeHtml(formatBytes(item.rx_bytes))}<small>${escapeHtml(item.rx_packets || 0)} пакетов</small></span>
<span>${escapeHtml(formatBytes(item.tx_bytes))}<small>${escapeHtml(item.tx_packets || 0)} пакетов</small></span>
<span class="${errors ? "interface-errors" : ""}">${escapeHtml(errors)}</span>
`;
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;
+45 -15
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenWrt RMM</title>
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/styles.css?v=3">
</head>
<body>
<main id="loginView" class="login-view">
@@ -158,7 +158,8 @@
<nav class="device-tabs" aria-label="Разделы объекта">
<button class="device-tab is-active" data-device-tab-target="overview" type="button">Обзор</button>
<button class="device-tab" data-device-tab-target="clients" type="button">Клиенты и сеть</button>
<button class="device-tab" data-device-tab-target="clients" type="button">Клиенты</button>
<button class="device-tab" data-device-tab-target="network" type="button">Сеть</button>
<button class="device-tab" data-device-tab-target="operations" type="button">Операции</button>
<button class="device-tab" data-device-tab-target="information" type="button">Сведения</button>
<button class="device-tab" data-device-tab-target="expert" type="button">Эксперт</button>
@@ -269,21 +270,50 @@
</div>
</section>
<div class="two-column" data-device-tab="clients">
<section class="panel">
<div class="section-title">
<h2>Leases and Wi-Fi</h2>
<section class="panel" data-device-tab="clients">
<div class="section-title">
<div>
<h2>Подключенные устройства</h2>
<p class="section-subtitle">Клиенты DHCP и Wi-Fi объединены по MAC-адресу</p>
</div>
<div id="clientList" class="table-list"></div>
</section>
<span id="clientSummary" class="summary-pill">0 клиентов</span>
</div>
<div class="client-toolbar">
<input id="clientSearch" type="search" placeholder="Поиск по имени, IP или MAC">
<div class="filters client-filters">
<button class="client-filter is-active" data-client-filter="all" type="button">Все</button>
<button class="client-filter" data-client-filter="wifi" type="button">Wi-Fi</button>
<button class="client-filter" data-client-filter="wired" type="button">Проводные</button>
</div>
</div>
<div class="client-table-head">
<span>Устройство</span>
<span>IP-адрес</span>
<span>MAC-адрес</span>
<span>Подключение</span>
<span>Состояние</span>
</div>
<div id="clientList" class="client-list"></div>
</section>
<section class="panel">
<div class="section-title">
<h2>Interface counters</h2>
<section class="panel" data-device-tab="network">
<div class="section-title">
<div>
<h2>Сеть и интерфейсы</h2>
<p class="section-subtitle">Адреса и трафик интерфейсов роутера</p>
</div>
<div id="interfaceCounters" class="table-list"></div>
</section>
</div>
<span id="networkSummary" class="summary-pill">0 интерфейсов</span>
</div>
<div id="networkHealth" class="network-health"></div>
<div class="network-table-head">
<span>Интерфейс</span>
<span>Адреса</span>
<span>Получено</span>
<span>Передано</span>
<span>Ошибки</span>
</div>
<div id="interfaceCounters" class="network-list"></div>
</section>
<section class="panel" data-device-tab="overview">
<div class="section-title">
@@ -503,6 +533,6 @@
</main>
</div>
<script src="/app.js?v=9" defer></script>
<script src="/app.js?v=10" defer></script>
</body>
</html>
+169
View File
@@ -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,