feat(ui): highlight port/MAC rows, FDB MAC/IP columns in table 1
Made-with: Cursor
This commit is contained in:
+118
-27
@@ -24,6 +24,12 @@
|
|||||||
tr.device-row { cursor: pointer; }
|
tr.device-row { cursor: pointer; }
|
||||||
tr.device-row:hover { background: #f1f5f9; }
|
tr.device-row:hover { background: #f1f5f9; }
|
||||||
tr.device-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; }
|
tr.device-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; }
|
||||||
|
tr.port-row { cursor: pointer; }
|
||||||
|
tr.port-row:hover { background: #f1f5f9; }
|
||||||
|
tr.port-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; }
|
||||||
|
tr.fdb-row { cursor: pointer; }
|
||||||
|
tr.fdb-row:hover { background: #f1f5f9; }
|
||||||
|
tr.fdb-row.selected { background: #dbeafe; box-shadow: inset 0.15em 0 0 #2563eb; }
|
||||||
.device-detail-table th, .device-detail-table td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; font-size: 13px; }
|
.device-detail-table th, .device-detail-table td { border-bottom: 1px solid #e5e7eb; padding: 6px 8px; font-size: 13px; }
|
||||||
.subtable-wrap { overflow: auto; max-height: 200px; margin-top: 6px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
.subtable-wrap { overflow: auto; max-height: 200px; margin-top: 6px; border: 1px solid #e5e7eb; border-radius: 8px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -91,7 +97,7 @@
|
|||||||
|
|
||||||
<div class="panel" id="deviceDetailPanel">
|
<div class="panel" id="deviceDetailPanel">
|
||||||
<strong>Связи выбранного устройства: <span id="selectedDeviceLabel">—</span></strong>
|
<strong>Связи выбранного устройства: <span id="selectedDeviceLabel">—</span></strong>
|
||||||
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Клик по строке Таблицы 1 загружает Таблицу 2 (MAC/IP за выбранным портом).</p>
|
<p class="hint" style="margin:6px 0 10px;">Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Колонки MAC/IP за портом — из FDB+ARP этого коммутатора (IP только если он есть в ARP на свитче). Клик по строке Таблицы 1 выделяет порт и заполняет Таблицу 2.</p>
|
||||||
|
|
||||||
<div style="margin-bottom: 14px;">
|
<div style="margin-bottom: 14px;">
|
||||||
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (IF-MIB + LLDP по портам)</div>
|
<div style="font-weight:600; margin-bottom:4px; color:#334155;">Таблица 1. Связи (IF-MIB + LLDP по портам)</div>
|
||||||
@@ -101,6 +107,8 @@
|
|||||||
<tr style="background:#f1f5f9;">
|
<tr style="background:#f1f5f9;">
|
||||||
<th class="sortable-th-link" data-sort-link="port" style="text-align:left; cursor:pointer;">Локальный порт <span class="sort-ind-link"></span></th>
|
<th class="sortable-th-link" data-sort-link="port" style="text-align:left; cursor:pointer;">Локальный порт <span class="sort-ind-link"></span></th>
|
||||||
<th style="text-align:left;">SNMP (интерфейс)</th>
|
<th style="text-align:left;">SNMP (интерфейс)</th>
|
||||||
|
<th style="text-align:left;" title="MAC из FDB за портом (ifIndex / bridge port)">MAC за портом</th>
|
||||||
|
<th style="text-align:left;" title="IP из ARP на этом коммутаторе, если агент SNMP его отдаёт">IP (ARP)</th>
|
||||||
<th style="text-align:left;">Имя соседа (sysName)</th>
|
<th style="text-align:left;">Имя соседа (sysName)</th>
|
||||||
<th style="text-align:left;">IP соседа</th>
|
<th style="text-align:left;">IP соседа</th>
|
||||||
<th style="text-align:left;">Порт соседа</th>
|
<th style="text-align:left;">Порт соседа</th>
|
||||||
@@ -109,7 +117,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="deviceLinksTableBody">
|
<tbody id="deviceLinksTableBody">
|
||||||
<tr><td colspan="7" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
<tr><td colspan="9" style="padding:10px;color:#64748b;">Выберите устройство выше.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,10 +197,13 @@
|
|||||||
let deviceSort = { key: "ip", dir: 1 };
|
let deviceSort = { key: "ip", dir: 1 };
|
||||||
let selectedDeviceIP = null;
|
let selectedDeviceIP = null;
|
||||||
let lastGraphViewBox = "0 0 1200 620";
|
let lastGraphViewBox = "0 0 1200 620";
|
||||||
/** @type {{sortKey:number,localPort:string,snmpInfo:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */
|
/** @type {{sortKey:number,localPort:string,snmpInfo:string,fdbMac:string,fdbIp:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */
|
||||||
let deviceLinksRowsCache = [];
|
let deviceLinksRowsCache = [];
|
||||||
let deviceLinksSort = { key: "port", dir: 1 };
|
let deviceLinksSort = { key: "port", dir: 1 };
|
||||||
let selectedIfIndex = null;
|
let selectedIfIndex = null;
|
||||||
|
/** @type {{if_index:number,bridge_port:number,mac:string,learned_ip:string}[]} */
|
||||||
|
let lastPortDevItems = [];
|
||||||
|
let selectedFdbKey = null;
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
@@ -323,10 +334,12 @@
|
|||||||
function clearDeviceDetailPanel() {
|
function clearDeviceDetailPanel() {
|
||||||
selectedDeviceIP = null;
|
selectedDeviceIP = null;
|
||||||
selectedIfIndex = null;
|
selectedIfIndex = null;
|
||||||
|
lastPortDevItems = [];
|
||||||
|
selectedFdbKey = null;
|
||||||
selectedDeviceLabel.textContent = "—";
|
selectedDeviceLabel.textContent = "—";
|
||||||
deviceLinksRowsCache = [];
|
deviceLinksRowsCache = [];
|
||||||
deviceLinksTableBody.innerHTML =
|
deviceLinksTableBody.innerHTML =
|
||||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Выберите устройство в таблице выше.</td></tr>';
|
||||||
deviceLldpTextTableBody.innerHTML =
|
deviceLldpTextTableBody.innerHTML =
|
||||||
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</td></tr>';
|
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите устройство и порт в Таблице 1.</td></tr>';
|
||||||
updateDeviceLinksSortIndicators();
|
updateDeviceLinksSortIndicators();
|
||||||
@@ -354,6 +367,50 @@
|
|||||||
return m ? parseInt(m[1], 10) : null;
|
return m ? parseInt(m[1], 10) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMacLocal(m) {
|
||||||
|
return String(m || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/-/g, ":")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregatePortFdbSummary(sortKey, portItems) {
|
||||||
|
if (!portItems || !sortKey || sortKey <= 0 || sortKey >= 1e9) {
|
||||||
|
return { macText: "—", ipText: "—" };
|
||||||
|
}
|
||||||
|
const hits = [];
|
||||||
|
for (const p of portItems) {
|
||||||
|
const br = Number(p.bridge_port) || 0;
|
||||||
|
const ifi = Number(p.if_index) || 0;
|
||||||
|
if (ifi === sortKey || br === sortKey) hits.push(p);
|
||||||
|
}
|
||||||
|
const byMac = new Map();
|
||||||
|
for (const p of hits) {
|
||||||
|
const mk = normalizeMacLocal(p.mac);
|
||||||
|
if (!mk) continue;
|
||||||
|
const ip = String(p.learned_ip || "").trim();
|
||||||
|
const cur = byMac.get(mk);
|
||||||
|
const macDisp = String(p.mac || "").trim() || mk;
|
||||||
|
if (!cur || (ip && !cur.ip)) byMac.set(mk, { mac: macDisp, ip });
|
||||||
|
}
|
||||||
|
const rows = [...byMac.values()];
|
||||||
|
const n = rows.length;
|
||||||
|
if (n === 0) return { macText: "—", ipText: "—" };
|
||||||
|
if (n === 1) {
|
||||||
|
const r = rows[0];
|
||||||
|
return { macText: r.mac, ipText: r.ip || "—" };
|
||||||
|
}
|
||||||
|
return { macText: `несколько MAC (${n})`, ipText: "—" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachFdbSummaries(rows, portItems) {
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = aggregatePortFdbSummary(r.sortKey, portItems);
|
||||||
|
r.fdbMac = s.macText;
|
||||||
|
r.fdbIp = s.ipText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildMergedPortRows(interfaces, links) {
|
function buildMergedPortRows(interfaces, links) {
|
||||||
const byIdx = new Map();
|
const byIdx = new Map();
|
||||||
for (const L of links) {
|
for (const L of links) {
|
||||||
@@ -449,6 +506,13 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePortRowSelectionHighlight() {
|
||||||
|
deviceLinksTableBody.querySelectorAll("tr.port-row").forEach((tr) => {
|
||||||
|
const idx = parseInt(tr.getAttribute("data-if-index") || "", 10);
|
||||||
|
tr.classList.toggle("selected", selectedIfIndex != null && Number.isFinite(idx) && idx === selectedIfIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderDeviceLinksTableBody() {
|
function renderDeviceLinksTableBody() {
|
||||||
const arr = [...deviceLinksRowsCache];
|
const arr = [...deviceLinksRowsCache];
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
@@ -461,38 +525,48 @@
|
|||||||
});
|
});
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
deviceLinksTableBody.innerHTML =
|
deviceLinksTableBody.innerHTML =
|
||||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Нет строк для отображения.</td></tr>';
|
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Нет строк для отображения.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deviceLinksTableBody.innerHTML = arr
|
deviceLinksTableBody.innerHTML = arr
|
||||||
.map(
|
.map(
|
||||||
(row) =>
|
(row) =>
|
||||||
`<tr class="port-row" data-if-index="${escapeHtml(row.sortKey)}"><td>${escapeHtml(row.localPort)}</td><td style="font-size:12px;color:#475569;">${escapeHtml(row.snmpInfo)}</td><td>${escapeHtml(row.targetName)}</td><td>${escapeHtml(row.targetIP)}</td><td>${escapeHtml(row.targetPort)}</td><td>${escapeHtml(row.chassis)}</td><td>${escapeHtml(row.resolved)}</td></tr>`
|
`<tr class="port-row" data-if-index="${escapeHtml(row.sortKey)}"><td>${escapeHtml(row.localPort)}</td><td style="font-size:12px;color:#475569;">${escapeHtml(row.snmpInfo)}</td><td>${escapeHtml(row.fdbMac || "—")}</td><td>${escapeHtml(row.fdbIp || "—")}</td><td>${escapeHtml(row.targetName)}</td><td>${escapeHtml(row.targetIP)}</td><td>${escapeHtml(row.targetPort)}</td><td>${escapeHtml(row.chassis)}</td><td>${escapeHtml(row.resolved)}</td></tr>`
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
updatePortRowSelectionHighlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPortDevicesForSelectedPort() {
|
function renderFdbTable2Body(items) {
|
||||||
const scanId = scanIdInput.value.trim();
|
selectedFdbKey = null;
|
||||||
if (!scanId || !selectedDeviceIP || !selectedIfIndex) return;
|
if (!items || items.length === 0) {
|
||||||
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(selectedDeviceIP)}&if_index=${encodeURIComponent(selectedIfIndex)}&limit=20000`
|
|
||||||
);
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const items = ((await res.json()) || {}).items || [];
|
|
||||||
if (items.length === 0) {
|
|
||||||
deviceLldpTextTableBody.innerHTML =
|
deviceLldpTextTableBody.innerHTML =
|
||||||
'<tr><td colspan="3" style="padding:10px;color:#64748b;">За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).</td></tr>';
|
'<tr><td colspan="3" style="padding:10px;color:#64748b;">За этим портом MAC/IP не найдены (или устройство не отдаёт BRIDGE-MIB/ARP).</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deviceLldpTextTableBody.innerHTML = items
|
const sorted = [...items].sort((a, b) => {
|
||||||
.map(
|
const ma = String(a.mac || "").localeCompare(String(b.mac || ""));
|
||||||
(row) =>
|
if (ma !== 0) return ma;
|
||||||
`<tr><td>${escapeHtml(row.if_index)}</td><td>${escapeHtml(row.mac || "—")}</td><td>${escapeHtml(row.learned_ip || "—")}</td></tr>`
|
return String(a.learned_ip || "").localeCompare(String(b.learned_ip || ""));
|
||||||
)
|
});
|
||||||
|
deviceLldpTextTableBody.innerHTML = sorted
|
||||||
|
.map((row) => {
|
||||||
|
const mk = normalizeMacLocal(row.mac);
|
||||||
|
const ip = String(row.learned_ip || "").trim();
|
||||||
|
const key = `${mk}|${ip}`;
|
||||||
|
return `<tr class="fdb-row" data-fdb-key="${escapeHtml(key)}"><td>${escapeHtml(row.if_index)}</td><td>${escapeHtml(row.mac || "—")}</td><td>${escapeHtml(row.learned_ip || "—")}</td></tr>`;
|
||||||
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPortDevicesForSelectedPort() {
|
||||||
|
if (!selectedDeviceIP || selectedIfIndex == null) return;
|
||||||
|
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||||
|
try {
|
||||||
|
const items = lastPortDevItems.filter(
|
||||||
|
(p) => Number(p.if_index) === selectedIfIndex || Number(p.bridge_port) === selectedIfIndex
|
||||||
|
);
|
||||||
|
renderFdbTable2Body(items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(
|
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(
|
||||||
e.message
|
e.message
|
||||||
@@ -510,15 +584,18 @@
|
|||||||
selectedDeviceLabel.textContent = ip;
|
selectedDeviceLabel.textContent = ip;
|
||||||
renderDeviceTableBody();
|
renderDeviceTableBody();
|
||||||
|
|
||||||
deviceLinksTableBody.innerHTML = '<tr><td colspan="7" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
deviceLinksTableBody.innerHTML = '<tr><td colspan="9" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||||
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
deviceLldpTextTableBody.innerHTML = '<tr><td colspan="3" style="padding:10px;color:#64748b;">Загрузка…</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [linksRes, ifRes] = await Promise.all([
|
const [linksRes, ifRes, pdRes] = await Promise.all([
|
||||||
fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`),
|
fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`),
|
||||||
fetch(
|
fetch(
|
||||||
`/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192`
|
`/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192`
|
||||||
),
|
),
|
||||||
|
fetch(
|
||||||
|
`/api/scans/${encodeURIComponent(scanId)}/port-devices?ip=${encodeURIComponent(ip)}&limit=20000`
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
if (!linksRes.ok) throw new Error("links: " + (await linksRes.text()));
|
if (!linksRes.ok) throw new Error("links: " + (await linksRes.text()));
|
||||||
const linkItems = (await linksRes.json()).items || [];
|
const linkItems = (await linksRes.json()).items || [];
|
||||||
@@ -526,25 +603,28 @@
|
|||||||
if (ifRes.ok) {
|
if (ifRes.ok) {
|
||||||
ifItems = ((await ifRes.json()) || {}).items || [];
|
ifItems = ((await ifRes.json()) || {}).items || [];
|
||||||
}
|
}
|
||||||
|
lastPortDevItems = pdRes.ok ? ((await pdRes.json()) || {}).items || [] : [];
|
||||||
const myLinks = linkItems.filter((x) => x.source_ip === ip);
|
const myLinks = linkItems.filter((x) => x.source_ip === ip);
|
||||||
|
|
||||||
deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks);
|
deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks);
|
||||||
|
attachFdbSummaries(deviceLinksRowsCache, lastPortDevItems);
|
||||||
updateDeviceLinksSortIndicators();
|
updateDeviceLinksSortIndicators();
|
||||||
if (deviceLinksRowsCache.length === 0) {
|
if (deviceLinksRowsCache.length === 0) {
|
||||||
deviceLinksTableBody.innerHTML =
|
deviceLinksTableBody.innerHTML =
|
||||||
'<tr><td colspan="7" style="padding:10px;color:#64748b;">Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).</td></tr>';
|
'<tr><td colspan="9" style="padding:10px;color:#64748b;">Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
renderDeviceLinksTableBody();
|
renderDeviceLinksTableBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedIfIndex = null;
|
selectedIfIndex = null;
|
||||||
|
selectedFdbKey = null;
|
||||||
deviceLldpTextTableBody.innerHTML =
|
deviceLldpTextTableBody.innerHTML =
|
||||||
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.</td></tr>';
|
'<tr><td colspan="3" style="padding:10px;color:#64748b;">Выберите строку в Таблице 1, чтобы увидеть MAC/IP за портом.</td></tr>';
|
||||||
setStatus(
|
setStatus(
|
||||||
`Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}).`
|
`Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}, FDB строк ${lastPortDevItems.length}).`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
deviceLinksTableBody.innerHTML = `<tr><td colspan="7" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
|
deviceLinksTableBody.innerHTML = `<tr><td colspan="9" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
|
||||||
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
|
deviceLldpTextTableBody.innerHTML = `<tr><td colspan="3" style="padding:10px;color:#b91c1c;">${escapeHtml(e.message)}</td></tr>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,6 +655,17 @@
|
|||||||
}
|
}
|
||||||
selectedIfIndex = idx;
|
selectedIfIndex = idx;
|
||||||
loadPortDevicesForSelectedPort();
|
loadPortDevicesForSelectedPort();
|
||||||
|
updatePortRowSelectionHighlight();
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceLldpTextTableBody.addEventListener("click", (ev) => {
|
||||||
|
const tr = ev.target.closest("tr.fdb-row");
|
||||||
|
if (!tr) return;
|
||||||
|
const k = tr.getAttribute("data-fdb-key") || "";
|
||||||
|
selectedFdbKey = k;
|
||||||
|
deviceLldpTextTableBody.querySelectorAll("tr.fdb-row").forEach((r) => {
|
||||||
|
r.classList.toggle("selected", (r.getAttribute("data-fdb-key") || "") === k);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
deviceTableBody.addEventListener("click", (ev) => {
|
deviceTableBody.addEventListener("click", (ev) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user