diff --git a/internal/api/handler.go b/internal/api/handler.go index 5bda749..7e76f48 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -67,6 +67,7 @@ func (h *Handler) Routes() http.Handler { mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts) mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP) mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP) + mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces) mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks) mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology) return withJSON(mux) @@ -274,6 +275,35 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "scan id is required") + return + } + if _, ok := h.store.GetScan(id); !ok { + writeError(w, http.StatusNotFound, "scan not found") + return + } + ip := strings.TrimSpace(r.URL.Query().Get("ip")) + if ip == "" { + writeError(w, http.StatusBadRequest, "query parameter ip is required") + return + } + limit := 4096 + if raw := r.URL.Query().Get("limit"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 10000 { + writeError(w, http.StatusBadRequest, "limit must be between 1 and 10000") + return + } + limit = n + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": h.store.ListInterfaceResults(id, ip, limit), + }) +} + func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { @@ -295,7 +325,7 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { limit = n } - snmpItems := h.store.ListSNMPResults(id, 10000) + snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) @@ -358,7 +388,7 @@ func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { limit = n } - snmpItems := h.store.ListSNMPResults(id, 10000) + snmpItems := h.store.ListSNMPResults(id, 200000) lldpItems := h.store.ListLLDPResults(id, limit) sysNameToIP := make(map[string]string, len(snmpItems)) diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 02536f5..ae88c59 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -475,3 +475,60 @@ limit $2` } return out } + +func (s *PostgresStore) SaveInterfaceResult(scanID string, result InterfaceResult) error { + query := ` +insert into scan_interfaces (scan_id, ip, if_index, if_descr, if_name, if_oper_status, checked_at) +values ($1, $2, $3, $4, $5, $6, $7)` + _, err := s.db.ExecContext( + context.Background(), + query, + scanID, + result.IP, + result.IfIndex, + result.IfDescr, + result.IfName, + result.IfOperStatus, + result.CheckedAt, + ) + return err +} + +func (s *PostgresStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult { + if limit <= 0 { + limit = 4096 + } + var rows *sql.Rows + var err error + if filterIP != "" { + rows, err = s.db.QueryContext(context.Background(), ` +select ip::text, if_index, coalesce(if_descr, ''), coalesce(if_name, ''), coalesce(if_oper_status, ''), checked_at +from scan_interfaces +where scan_id = $1 and ip = $2::inet +order by if_index asc +limit $3`, + scanID, filterIP, limit) + } else { + rows, err = s.db.QueryContext(context.Background(), ` +select ip::text, if_index, coalesce(if_descr, ''), coalesce(if_name, ''), coalesce(if_oper_status, ''), checked_at +from scan_interfaces +where scan_id = $1 +order by ip asc, if_index asc +limit $2`, + scanID, limit) + } + if err != nil { + return nil + } + defer rows.Close() + + out := make([]InterfaceResult, 0, 256) + for rows.Next() { + var r InterfaceResult + if err = rows.Scan(&r.IP, &r.IfIndex, &r.IfDescr, &r.IfName, &r.IfOperStatus, &r.CheckedAt); err != nil { + return nil + } + out = append(out, r) + } + return out +} diff --git a/internal/scans/runner.go b/internal/scans/runner.go index 83a1fbb..838cac6 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "runtime" + "sort" "strconv" "strings" "sync" @@ -101,13 +102,18 @@ func (r *Runner) run(job ScanJob) { } } if isUp && r.cfg.SNMPEnabled { - snmpRes, lldpRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) + snmpRes, lldpRes, ifRes := probeSNMPAndLLDP(ip, r.cfg.SNMPCommunity, checkedAt, job.Options.PingTimeoutMS) _ = r.store.SaveSNMPResult(job.ID, snmpRes) for _, lr := range lldpRes { if err := r.store.SaveLLDPResult(job.ID, lr); err != nil { log.Printf("scan %s: save LLDP для %s: %v", job.ID, lr.IP, err) } } + for _, iface := range ifRes { + if err := r.store.SaveInterfaceResult(job.ID, iface); err != nil { + log.Printf("scan %s: save interface %s ifIndex=%d: %v", job.ID, iface.IP, iface.IfIndex, err) + } + } } mu.Lock() @@ -147,7 +153,7 @@ func (r *Runner) run(job ScanJob) { }) } -func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult) { +func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) (SNMPResult, []LLDPResult, []InterfaceResult) { if timeoutMS <= 0 { timeoutMS = 700 } @@ -164,7 +170,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) Retries: 1, } if err := client.Connect(); err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil } defer client.Conn.Close() @@ -175,7 +181,7 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) } pkt, err := client.Get(oids) if err != nil { - return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil + return SNMPResult{IP: ip, Success: false, Error: err.Error(), CheckedAt: checkedAt}, nil, nil } out := SNMPResult{IP: ip, Success: true, CheckedAt: checkedAt} @@ -200,7 +206,84 @@ func probeSNMPAndLLDP(ip, community string, checkedAt time.Time, timeoutMS int) client.MaxRepetitions = 12 client.Retries = 2 - return out, probeLLDP(client, ip, checkedAt) + lldpRes := probeLLDP(client, ip, checkedAt) + var ifList []InterfaceResult + if out.Success { + ifList = probeIfTable(client, ip, checkedAt) + } + return out, lldpRes, ifList +} + +// probeIfTable собирает ifDescr, ifName (ifXTable), ifOperStatus по IF-MIB для списка портов в UI. +func probeIfTable(client *gosnmp.GoSNMP, ip string, checkedAt time.Time) []InterfaceResult { + descr := walkAsMap(client, ".1.3.6.1.2.1.2.2.1.2") + names := walkAsMap(client, ".1.3.6.1.2.1.31.1.1.1.1") + oper := walkAsMap(client, ".1.3.6.1.2.1.2.2.1.8") + idxs := mergeIFMIBKeys(descr, names, oper) + const maxIF = 2048 + if len(idxs) > maxIF { + idxs = idxs[:maxIF] + } + out := make([]InterfaceResult, 0, len(idxs)) + for _, ks := range idxs { + idx, err := strconv.Atoi(ks) + if err != nil || idx <= 0 { + continue + } + out = append(out, InterfaceResult{ + IP: ip, + IfIndex: idx, + IfDescr: descr[ks], + IfName: names[ks], + IfOperStatus: mapIfOperStatus(oper[ks]), + CheckedAt: checkedAt, + }) + } + return out +} + +func mergeIFMIBKeys(maps ...map[string]string) []string { + seen := make(map[string]struct{}) + for _, m := range maps { + for k := range m { + seen[k] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for k := range seen { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { + ai, e1 := strconv.Atoi(out[i]) + bj, e2 := strconv.Atoi(out[j]) + if e1 != nil || e2 != nil { + return out[i] < out[j] + } + return ai < bj + }) + return out +} + +func mapIfOperStatus(s string) string { + s = strings.TrimSpace(s) + switch s { + case "1": + return "up" + case "2": + return "down" + case "3": + return "testing" + case "4": + return "unknown" + case "5": + return "dormant" + case "6": + return "notPresent" + case "7": + return "lowerLayerDown" + default: + return s + } } // snmpBytesToDisplay строка для OCTET STRING SNMP: UTF-8 текст, иначе MAC (6 байт) или 0x+hex. diff --git a/internal/scans/store.go b/internal/scans/store.go index 642bb17..0124a19 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -67,6 +67,8 @@ type Store interface { ListSNMPResults(scanID string, limit int) []SNMPResult SaveLLDPResult(scanID string, result LLDPResult) error ListLLDPResults(scanID string, limit int) []LLDPResult + SaveInterfaceResult(scanID string, result InterfaceResult) error + ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult } type HostResult struct { @@ -101,22 +103,34 @@ type LLDPResult struct { CheckedAt time.Time `json:"checked_at"` } +// InterfaceResult — строка IF-MIB (interfaces/ifTable + ifName) для устройства. +type InterfaceResult struct { + IP string `json:"ip"` + IfIndex int `json:"if_index"` + IfDescr string `json:"if_descr"` + IfName string `json:"if_name"` + IfOperStatus string `json:"if_oper_status"` + CheckedAt time.Time `json:"checked_at"` +} + type MemoryStore struct { - mu sync.RWMutex - jobs map[string]ScanJob - hosts map[string][]HostResult - ports map[string][]OpenPortResult - snmp map[string][]SNMPResult - lldp map[string][]LLDPResult + mu sync.RWMutex + jobs map[string]ScanJob + hosts map[string][]HostResult + ports map[string][]OpenPortResult + snmp map[string][]SNMPResult + lldp map[string][]LLDPResult + ifaces map[string][]InterfaceResult } func NewMemoryStore() *MemoryStore { return &MemoryStore{ - jobs: make(map[string]ScanJob), - hosts: make(map[string][]HostResult), - ports: make(map[string][]OpenPortResult), - snmp: make(map[string][]SNMPResult), - lldp: make(map[string][]LLDPResult), + jobs: make(map[string]ScanJob), + hosts: make(map[string][]HostResult), + ports: make(map[string][]OpenPortResult), + snmp: make(map[string][]SNMPResult), + lldp: make(map[string][]LLDPResult), + ifaces: make(map[string][]InterfaceResult), } } @@ -305,6 +319,46 @@ func (s *MemoryStore) ListLLDPResults(scanID string, limit int) []LLDPResult { return out } +func (s *MemoryStore) SaveInterfaceResult(scanID string, result InterfaceResult) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.jobs[scanID]; !ok { + return errors.New("scan not found") + } + s.ifaces[scanID] = append(s.ifaces[scanID], result) + return nil +} + +func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult { + if limit <= 0 { + limit = 4096 + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := s.ifaces[scanID] + var filtered []InterfaceResult + if filterIP != "" { + for _, r := range items { + if r.IP == filterIP { + filtered = append(filtered, r) + } + } + } else { + filtered = append(filtered, items...) + } + sort.Slice(filtered, func(i, j int) bool { return filtered[i].IfIndex < filtered[j].IfIndex }) + if len(filtered) <= limit { + out := make([]InterfaceResult, len(filtered)) + copy(out, filtered) + return out + } + out := make([]InterfaceResult, limit) + copy(out, filtered[:limit]) + return out +} + func applyDefaultOptions(opts *ScanOptions) { if opts.PingTimeoutMS <= 0 { opts.PingTimeoutMS = 700 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 8edf486..3e43225 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -8,7 +8,15 @@ import ( //go:embed sql/001_init.sql var initSQL string +//go:embed sql/002_scan_interfaces.sql +var scanInterfacesSQL string + func RunMigrations(db *sql.DB) error { - _, err := db.Exec(initSQL) - return err + if _, err := db.Exec(initSQL); err != nil { + return err + } + if _, err := db.Exec(scanInterfacesSQL); err != nil { + return err + } + return nil } diff --git a/internal/store/sql/002_scan_interfaces.sql b/internal/store/sql/002_scan_interfaces.sql new file mode 100644 index 0000000..5e0da9e --- /dev/null +++ b/internal/store/sql/002_scan_interfaces.sql @@ -0,0 +1,13 @@ +create table if not exists scan_interfaces ( + id bigserial primary key, + scan_id text not null references scan_jobs(id) on delete cascade, + ip inet not null, + if_index int not null, + if_descr text null, + if_name text null, + if_oper_status text null, + checked_at timestamptz not null +); + +create index if not exists idx_scan_interfaces_scan_ip_idx + on scan_interfaces (scan_id, ip, if_index); diff --git a/internal/webui/index.html b/internal/webui/index.html index c285345..0afc93f 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -83,15 +83,16 @@
Связи выбранного устройства: -

Кликните по строке в таблице «Найденные устройства». Первая таблица — связи с попыткой сопоставить IP соседа (данные /links); вторая — каждая запись LLDP одной текстовой строкой.

+

Кликните по строке в таблице «Найденные устройства». Таблица 1 — все интерфейсы из IF-MIB (SNMP) и LLDP-соседи по ifIndex; сортировка по «Локальный порт». Таблица 2 — сырые строки LLDP.

-
Таблица 1. Связи (порты, сосед, IP если найден)
+
Таблица 1. Связи (IF-MIB + LLDP по портам)
- + - + + @@ -100,7 +101,7 @@ - +
Локальный портЛокальный порт SNMP (интерфейс) Имя соседа (sysName) IP соседа Порт соседа
Выберите устройство выше.
Выберите устройство выше.
@@ -179,6 +180,9 @@ let deviceSort = { key: "ip", dir: 1 }; let selectedDeviceIP = null; let lastGraphViewBox = "0 0 1200 620"; + /** @type {{sortKey:number,localPort:string,snmpInfo:string,targetName:string,targetIP:string,targetPort:string,chassis:string,resolved:string}[]} */ + let deviceLinksRowsCache = []; + let deviceLinksSort = { key: "port", dir: 1 }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -302,16 +306,19 @@ } const deviceLinksTableBody = document.getElementById("deviceLinksTableBody"); + const deviceLinksTableHead = document.getElementById("deviceLinksTableHead"); const deviceLldpTextTableBody = document.getElementById("deviceLldpTextTableBody"); const selectedDeviceLabel = document.getElementById("selectedDeviceLabel"); function clearDeviceDetailPanel() { selectedDeviceIP = null; selectedDeviceLabel.textContent = "—"; + deviceLinksRowsCache = []; deviceLinksTableBody.innerHTML = - 'Выберите устройство в таблице выше.'; + 'Выберите устройство в таблице выше.'; deviceLldpTextTableBody.innerHTML = 'Выберите устройство в таблице выше.'; + updateDeviceLinksSortIndicators(); } function resolvedLabelRu(resolvedBy) { @@ -320,6 +327,140 @@ return resolvedBy || "—"; } + function formatSnmpIfaceRow(i) { + const parts = [`ifIndex ${i.if_index}`]; + if (i.if_name) parts.push(i.if_name); + if (i.if_descr) parts.push(i.if_descr); + if (i.if_oper_status) parts.push(`(${i.if_oper_status})`); + return parts.join(" · "); + } + + /** Числовой ifIndex из локального порта LLDP (префикс до « — » или «-»). */ + function parseLldpLocalIfIndex(sourcePort) { + if (!sourcePort) return null; + const head = String(sourcePort).trim().split(/\s*[\u2014\-]\s*/)[0].trim(); + const m = head.match(/^(\d+)/); + return m ? parseInt(m[1], 10) : null; + } + + function buildMergedPortRows(interfaces, links) { + const byIdx = new Map(); + for (const L of links) { + const idx = parseLldpLocalIfIndex(L.source_port); + if (idx == null || !Number.isFinite(idx)) continue; + if (!byIdx.has(idx)) byIdx.set(idx, []); + byIdx.get(idx).push(L); + } + const rows = []; + const seenIfaceIdx = new Set(); + + if (interfaces && interfaces.length > 0) { + const sortedIf = [...interfaces].sort((a, b) => a.if_index - b.if_index); + for (const iface of sortedIf) { + const idx = iface.if_index; + seenIfaceIdx.add(idx); + const snmpText = formatSnmpIfaceRow(iface); + const ll = byIdx.get(idx) || []; + const localLabel = iface.if_name || iface.if_descr || `if${idx}`; + if (ll.length === 0) { + rows.push({ + sortKey: idx, + localPort: localLabel, + snmpInfo: snmpText, + targetName: "—", + targetIP: "—", + targetPort: "—", + chassis: "—", + resolved: "нет LLDP на этом ifIndex", + }); + } else { + for (const L of ll) { + rows.push({ + sortKey: idx, + localPort: L.source_port || localLabel, + snmpInfo: snmpText, + targetName: L.target_sys_name || "—", + targetIP: L.target_ip || "—", + targetPort: L.target_port || "—", + chassis: L.remote_chassis_id || "—", + resolved: resolvedLabelRu(L.resolved_by), + }); + } + } + } + } + + for (const [idx, list] of byIdx) { + if (interfaces && interfaces.length > 0 && seenIfaceIdx.has(idx)) continue; + for (const L of list) { + rows.push({ + sortKey: idx, + localPort: L.source_port || `if${idx}`, + snmpInfo: "—", + targetName: L.target_sys_name || "—", + targetIP: L.target_ip || "—", + targetPort: L.target_port || "—", + chassis: L.remote_chassis_id || "—", + resolved: resolvedLabelRu(L.resolved_by), + }); + } + } + + for (const L of links) { + const idx = parseLldpLocalIfIndex(L.source_port); + if (idx != null && Number.isFinite(idx)) continue; + rows.push({ + sortKey: 1e9, + localPort: L.source_port || "—", + snmpInfo: "—", + targetName: L.target_sys_name || "—", + targetIP: L.target_ip || "—", + targetPort: L.target_port || "—", + chassis: L.remote_chassis_id || "—", + resolved: resolvedLabelRu(L.resolved_by), + }); + } + + return rows; + } + + function updateDeviceLinksSortIndicators() { + if (!deviceLinksTableHead) return; + deviceLinksTableHead.querySelectorAll("[data-sort-link]").forEach((th) => { + const sp = th.querySelector(".sort-ind-link"); + if (!sp) return; + const k = th.getAttribute("data-sort-link"); + if (deviceLinksSort.key !== k) { + sp.textContent = ""; + return; + } + sp.textContent = deviceLinksSort.dir > 0 ? " ▲" : " ▼"; + }); + } + + function renderDeviceLinksTableBody() { + const arr = [...deviceLinksRowsCache]; + arr.sort((a, b) => { + let cmp = 0; + if (deviceLinksSort.key === "port") { + cmp = (a.sortKey || 0) - (b.sortKey || 0); + if (cmp === 0) cmp = String(a.localPort).localeCompare(String(b.localPort), "ru", { sensitivity: "base" }); + } + return cmp * deviceLinksSort.dir; + }); + if (arr.length === 0) { + deviceLinksTableBody.innerHTML = + 'Нет строк для отображения.'; + return; + } + deviceLinksTableBody.innerHTML = arr + .map( + (row) => + `${escapeHtml(row.localPort)}${escapeHtml(row.snmpInfo)}${escapeHtml(row.targetName)}${escapeHtml(row.targetIP)}${escapeHtml(row.targetPort)}${escapeHtml(row.chassis)}${escapeHtml(row.resolved)}` + ) + .join(""); + } + function formatLldpTextLine(item) { const chunks = []; chunks.push(`Источник ${item.ip}`); @@ -340,31 +481,35 @@ selectedDeviceLabel.textContent = ip; renderDeviceTableBody(); - deviceLinksTableBody.innerHTML = 'Загрузка…'; + deviceLinksTableBody.innerHTML = 'Загрузка…'; deviceLldpTextTableBody.innerHTML = 'Загрузка…'; try { - const [linksRes, lldpRes] = await Promise.all([ - fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=10000`), - fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=10000`), + const [linksRes, lldpRes, ifRes] = await Promise.all([ + fetch(`/api/scans/${encodeURIComponent(scanId)}/links?limit=20000`), + fetch(`/api/scans/${encodeURIComponent(scanId)}/lldp?limit=20000`), + fetch( + `/api/scans/${encodeURIComponent(scanId)}/interfaces?ip=${encodeURIComponent(ip)}&limit=8192` + ), ]); if (!linksRes.ok) throw new Error("links: " + (await linksRes.text())); if (!lldpRes.ok) throw new Error("lldp: " + (await lldpRes.text())); const linkItems = (await linksRes.json()).items || []; const lldpItems = (await lldpRes.json()).items || []; + let ifItems = []; + if (ifRes.ok) { + ifItems = ((await ifRes.json()) || {}).items || []; + } const myLinks = linkItems.filter((x) => x.source_ip === ip); const myLldp = lldpItems.filter((x) => x.ip === ip); - if (myLinks.length === 0) { + deviceLinksRowsCache = buildMergedPortRows(ifItems, myLinks); + updateDeviceLinksSortIndicators(); + if (deviceLinksRowsCache.length === 0) { deviceLinksTableBody.innerHTML = - 'Нет записей /links для этого IP (нет LLDP-соседей или скан без SNMP/LLDP).'; + 'Нет данных: для этого IP нет интерфейсов IF-MIB и нет строк /links (пересканируйте после обновления сервера для сбора IF-MIB).'; } else { - deviceLinksTableBody.innerHTML = myLinks - .map( - (L) => - `${escapeHtml(L.source_port || "—")}${escapeHtml(L.target_sys_name || "—")}${escapeHtml(L.target_ip || "—")}${escapeHtml(L.target_port || "—")}${escapeHtml(L.remote_chassis_id || "—")}${escapeHtml(resolvedLabelRu(L.resolved_by))}` - ) - .join(""); + renderDeviceLinksTableBody(); } if (myLldp.length === 0) { @@ -378,13 +523,31 @@ ) .join(""); } - setStatus(`Связи для ${ip}: /links ${myLinks.length}, запись(ей) LLDP ${myLldp.length}.`); + setStatus( + `Связи для ${ip}: строк табл.1 ${deviceLinksRowsCache.length} (IF-MIB ${ifItems.length}, /links ${myLinks.length}), LLDP записей ${myLldp.length}.` + ); } catch (e) { - deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; + deviceLinksTableBody.innerHTML = `${escapeHtml(e.message)}`; deviceLldpTextTableBody.innerHTML = `${escapeHtml(e.message)}`; } } + if (deviceLinksTableHead) { + deviceLinksTableHead.addEventListener("click", (ev) => { + const th = ev.target.closest("[data-sort-link]"); + if (!th || !deviceLinksTableHead.contains(th)) return; + const key = th.getAttribute("data-sort-link"); + if (!key) return; + if (deviceLinksSort.key === key) deviceLinksSort.dir *= -1; + else { + deviceLinksSort.key = key; + deviceLinksSort.dir = 1; + } + renderDeviceLinksTableBody(); + updateDeviceLinksSortIndicators(); + }); + } + deviceTableBody.addEventListener("click", (ev) => { const tr = ev.target.closest("tr.device-row"); if (!tr) return;