Improve client and network views
This commit is contained in:
+98
-15
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user