feat: MAC lookup in scan FDB (API mac-locations, UI table, index)
Made-with: Cursor
This commit is contained in:
@@ -50,6 +50,19 @@ type ScanDiffResponse struct {
|
|||||||
MissingLinks []string `json:"missing_links"`
|
MissingLinks []string `json:"missing_links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MacLocationRow — где в FDB найден MAC: коммутатор (ip), порт, клиентский IP из ARP.
|
||||||
|
type MacLocationRow struct {
|
||||||
|
SwitchIP string `json:"switch_ip"`
|
||||||
|
SwitchSysName string `json:"switch_sys_name,omitempty"`
|
||||||
|
IfIndex int `json:"if_index"`
|
||||||
|
BridgePort int `json:"bridge_port,omitempty"`
|
||||||
|
Vlan int `json:"vlan"`
|
||||||
|
MAC string `json:"mac"`
|
||||||
|
ClientIP string `json:"client_ip,omitempty"`
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
|
func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
|
||||||
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
|
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
|
||||||
}
|
}
|
||||||
@@ -70,6 +83,7 @@ func (h *Handler) Routes() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP)
|
mux.HandleFunc("GET /api/scans/{id}/lldp", h.getScanLLDP)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces)
|
mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/port-devices", h.getScanPortDevices)
|
mux.HandleFunc("GET /api/scans/{id}/port-devices", h.getScanPortDevices)
|
||||||
|
mux.HandleFunc("GET /api/scans/{id}/mac-locations", h.getScanMacLocations)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
|
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
|
||||||
mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
|
mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
|
||||||
@@ -391,6 +405,77 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getScanMacLocations(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
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(r.URL.Query().Get("mac"))
|
||||||
|
if raw == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "query parameter mac is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
norm := scans.NormalizeMAC(raw)
|
||||||
|
if norm == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mac address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 2000
|
||||||
|
if lim := strings.TrimSpace(r.URL.Query().Get("limit")); lim != "" {
|
||||||
|
n, err := strconv.Atoi(lim)
|
||||||
|
if err != nil || n <= 0 || n > 10000 {
|
||||||
|
writeError(w, http.StatusBadRequest, "limit must be between 1 and 10000")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
snmpItems := h.store.ListSNMPResults(id, 200000)
|
||||||
|
ipToName := make(map[string]string, len(snmpItems))
|
||||||
|
for _, s := range snmpItems {
|
||||||
|
if !s.Success {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(s.SysName)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := ipToName[s.IP]; !ok {
|
||||||
|
ipToName[s.IP] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := h.store.ListPortDevicesByMac(id, norm, limit)
|
||||||
|
out := make([]MacLocationRow, 0, len(items))
|
||||||
|
for _, r := range items {
|
||||||
|
row := MacLocationRow{
|
||||||
|
SwitchIP: r.IP,
|
||||||
|
IfIndex: r.IfIndex,
|
||||||
|
BridgePort: r.BridgePort,
|
||||||
|
Vlan: r.Vlan,
|
||||||
|
MAC: r.MAC,
|
||||||
|
ClientIP: strings.TrimSpace(r.LearnedIP),
|
||||||
|
CheckedAt: r.CheckedAt,
|
||||||
|
}
|
||||||
|
if nm, ok := ipToName[r.IP]; ok {
|
||||||
|
row.SwitchSysName = nm
|
||||||
|
}
|
||||||
|
if row.ClientIP != "" {
|
||||||
|
if nm, ok := ipToName[row.ClientIP]; ok {
|
||||||
|
row.ClientName = nm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"mac": norm,
|
||||||
|
"items": out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if id == "" {
|
if id == "" {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package scans
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeMAC приводит ввод пользователя к виду aa:bb:cc:dd:ee:ff (как в FDB/ARP).
|
||||||
|
func NormalizeMAC(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s = strings.ReplaceAll(s, "-", ":")
|
||||||
|
if strings.Count(s, ":") == 5 {
|
||||||
|
parts := strings.Split(s, ":")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b [6]byte
|
||||||
|
for i, p := range parts {
|
||||||
|
n, err := strconv.ParseUint(strings.TrimSpace(p), 16, 8)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b[i] = byte(n)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "0x") {
|
||||||
|
h := strings.TrimPrefix(s, "0x")
|
||||||
|
h = strings.ReplaceAll(h, ":", "")
|
||||||
|
if len(h) != 12 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(h)
|
||||||
|
if err != nil || len(raw) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5])
|
||||||
|
}
|
||||||
|
if len(s) == 12 {
|
||||||
|
raw, err := hex.DecodeString(s)
|
||||||
|
if err != nil || len(raw) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -534,6 +534,37 @@ limit $2`,
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostgresStore) ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
if mac == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
select ip::text, if_index, bridge_port, vlan, coalesce(mac, ''), coalesce(learned_ip::text, ''), checked_at
|
||||||
|
from scan_port_devices
|
||||||
|
where scan_id = $1
|
||||||
|
and lower(replace(trim(coalesce(mac, '')), '-', ':')) = lower(replace(trim($2::text), '-', ':'))
|
||||||
|
order by ip::text asc, if_index asc, vlan asc, mac asc, learned_ip asc
|
||||||
|
limit $3`
|
||||||
|
rows, err := s.db.QueryContext(context.Background(), query, scanID, mac, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make([]PortDeviceResult, 0, 32)
|
||||||
|
for rows.Next() {
|
||||||
|
var r PortDeviceResult
|
||||||
|
if err = rows.Scan(&r.IP, &r.IfIndex, &r.BridgePort, &r.Vlan, &r.MAC, &r.LearnedIP, &r.CheckedAt); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostgresStore) PurgeAllScanData() error {
|
func (s *PostgresStore) PurgeAllScanData() error {
|
||||||
// Дочерние таблицы ссылаются на scan_jobs — CASCADE очищает всё; RESTART IDENTITY сбрасывает bigserial.
|
// Дочерние таблицы ссылаются на scan_jobs — CASCADE очищает всё; RESTART IDENTITY сбрасывает bigserial.
|
||||||
_, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`)
|
_, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`)
|
||||||
|
|||||||
@@ -364,48 +364,8 @@ func snmpNumericString(v any) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeMACKey приводит MAC к виду aa:bb:cc:dd:ee:ff для сопоставления FDB и ARP.
|
|
||||||
func normalizeMACKey(s string) string {
|
func normalizeMACKey(s string) string {
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
return NormalizeMAC(s)
|
||||||
if s == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s = strings.ReplaceAll(s, "-", ":")
|
|
||||||
if strings.Count(s, ":") == 5 {
|
|
||||||
parts := strings.Split(s, ":")
|
|
||||||
if len(parts) != 6 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b [6]byte
|
|
||||||
for i, p := range parts {
|
|
||||||
n, err := strconv.ParseUint(strings.TrimSpace(p), 16, 8)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
b[i] = byte(n)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b[0], b[1], b[2], b[3], b[4], b[5])
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(s, "0x") {
|
|
||||||
h := strings.TrimPrefix(s, "0x")
|
|
||||||
h = strings.ReplaceAll(h, ":", "")
|
|
||||||
if len(h) != 12 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw, err := hex.DecodeString(h)
|
|
||||||
if err != nil || len(raw) != 6 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5])
|
|
||||||
}
|
|
||||||
if len(s) == 12 {
|
|
||||||
raw, err := hex.DecodeString(s)
|
|
||||||
if err != nil || len(raw) != 6 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", raw[0], raw[1], raw[2], raw[3], raw[4], raw[5])
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dottedDecimalToMACTail(s string) string {
|
func dottedDecimalToMACTail(s string) string {
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ type Store interface {
|
|||||||
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
|
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
|
||||||
SavePortDeviceResult(scanID string, result PortDeviceResult) error
|
SavePortDeviceResult(scanID string, result PortDeviceResult) error
|
||||||
ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult
|
ListPortDeviceResults(scanID string, filterIP string, filterIfIndex int, limit int) []PortDeviceResult
|
||||||
|
// ListPortDevicesByMac — строки FDB по точному MAC (mac уже в каноническом виде, см. NormalizeMAC).
|
||||||
|
ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult
|
||||||
// PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
|
// PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
|
||||||
PurgeAllScanData() error
|
PurgeAllScanData() error
|
||||||
}
|
}
|
||||||
@@ -439,6 +441,47 @@ func (s *MemoryStore) ListPortDeviceResults(scanID string, filterIP string, filt
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) ListPortDevicesByMac(scanID string, mac string, limit int) []PortDeviceResult {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5000
|
||||||
|
}
|
||||||
|
if mac == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
items := s.fdb[scanID]
|
||||||
|
filtered := make([]PortDeviceResult, 0, 8)
|
||||||
|
for _, r := range items {
|
||||||
|
if NormalizeMAC(r.MAC) == mac {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(filtered, func(i, j int) bool {
|
||||||
|
if filtered[i].IP != filtered[j].IP {
|
||||||
|
return filtered[i].IP < filtered[j].IP
|
||||||
|
}
|
||||||
|
if filtered[i].IfIndex != filtered[j].IfIndex {
|
||||||
|
return filtered[i].IfIndex < filtered[j].IfIndex
|
||||||
|
}
|
||||||
|
if filtered[i].Vlan != filtered[j].Vlan {
|
||||||
|
return filtered[i].Vlan < filtered[j].Vlan
|
||||||
|
}
|
||||||
|
if filtered[i].MAC != filtered[j].MAC {
|
||||||
|
return filtered[i].MAC < filtered[j].MAC
|
||||||
|
}
|
||||||
|
return filtered[i].LearnedIP < filtered[j].LearnedIP
|
||||||
|
})
|
||||||
|
if len(filtered) <= limit {
|
||||||
|
out := make([]PortDeviceResult, len(filtered))
|
||||||
|
copy(out, filtered)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out := make([]PortDeviceResult, limit)
|
||||||
|
copy(out, filtered[:limit])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func applyDefaultOptions(opts *ScanOptions) {
|
func applyDefaultOptions(opts *ScanOptions) {
|
||||||
if opts.PingTimeoutMS <= 0 {
|
if opts.PingTimeoutMS <= 0 {
|
||||||
opts.PingTimeoutMS = 700
|
opts.PingTimeoutMS = 700
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ var scanPortDevicesBridgePortSQL string
|
|||||||
//go:embed sql/005_scan_port_devices_vlan.sql
|
//go:embed sql/005_scan_port_devices_vlan.sql
|
||||||
var scanPortDevicesVlanSQL string
|
var scanPortDevicesVlanSQL string
|
||||||
|
|
||||||
|
//go:embed sql/006_scan_port_devices_mac_index.sql
|
||||||
|
var scanPortDevicesMacIndexSQL string
|
||||||
|
|
||||||
func RunMigrations(db *sql.DB) error {
|
func RunMigrations(db *sql.DB) error {
|
||||||
if _, err := db.Exec(initSQL); err != nil {
|
if _, err := db.Exec(initSQL); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -36,5 +39,8 @@ func RunMigrations(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(scanPortDevicesVlanSQL); err != nil {
|
if _, err := db.Exec(scanPortDevicesVlanSQL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := db.Exec(scanPortDevicesMacIndexSQL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ create index if not exists idx_scan_port_devices_scan_ip_if
|
|||||||
|
|
||||||
create index if not exists idx_scan_port_devices_scan_ip_mac
|
create index if not exists idx_scan_port_devices_scan_ip_mac
|
||||||
on scan_port_devices (scan_id, ip, mac);
|
on scan_port_devices (scan_id, ip, mac);
|
||||||
|
|
||||||
|
create index if not exists idx_scan_port_devices_scan_mac
|
||||||
|
on scan_port_devices (scan_id, mac);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Ускорение поиска MAC по scan_id (таблица FDB).
|
||||||
|
create index if not exists idx_scan_port_devices_scan_mac
|
||||||
|
on scan_port_devices (scan_id, mac);
|
||||||
@@ -71,6 +71,37 @@
|
|||||||
</details>
|
</details>
|
||||||
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
|
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
|
||||||
|
|
||||||
|
<div class="panel" id="macSearchPanel" style="background:#fafafa;border:1px solid #e5e7eb;border-radius:8px;padding:10px 12px;">
|
||||||
|
<div class="row" style="margin-bottom:8px;align-items:center;">
|
||||||
|
<strong>Поиск MAC в сохранённом скане</strong>
|
||||||
|
<span class="hint" style="margin:0;">По FDB в БД: коммутатор (IP коммутатора из скана), порт (ifIndex / bridge), VLAN; IP и имя клиента — из ARP на коммутаторе и SNMP этого scan.</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input id="macSearchInput" placeholder="MAC, напр. aa:99:0f:91:76:76" style="min-width:280px;" autocomplete="off" />
|
||||||
|
<button type="button" id="macSearchBtn">Найти</button>
|
||||||
|
<span id="macSearchSummary" class="hint" style="margin:0;"></span>
|
||||||
|
</div>
|
||||||
|
<div style="overflow:auto; max-height:240px; margin-top:8px; border:1px solid #e5e7eb; border-radius:8px;">
|
||||||
|
<table id="macSearchTable" style="width:100%; border-collapse:collapse; font-size:13px;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f1f5f9;">
|
||||||
|
<th style="text-align:left;padding:6px 8px;">Коммутатор (IP)</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">Имя (sysName)</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">ifIndex</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">bridge</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">VLAN</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">MAC</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">IP за портом</th>
|
||||||
|
<th style="text-align:left;padding:6px 8px;">Имя клиента</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="macSearchTableBody">
|
||||||
|
<tr><td colspan="8" style="padding:8px;color:#64748b;">Укажите scan_id выше и MAC, нажмите «Найти».</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="row" style="margin-bottom: 6px;">
|
<div class="row" style="margin-bottom: 6px;">
|
||||||
<strong>Найденные устройства</strong>
|
<strong>Найденные устройства</strong>
|
||||||
@@ -188,6 +219,10 @@
|
|||||||
const deviceTableBody = document.getElementById("deviceTableBody");
|
const deviceTableBody = document.getElementById("deviceTableBody");
|
||||||
const deviceTableHead = document.getElementById("deviceTableHead");
|
const deviceTableHead = document.getElementById("deviceTableHead");
|
||||||
const refreshDevicesBtn = document.getElementById("refreshDevicesBtn");
|
const refreshDevicesBtn = document.getElementById("refreshDevicesBtn");
|
||||||
|
const macSearchInput = document.getElementById("macSearchInput");
|
||||||
|
const macSearchBtn = document.getElementById("macSearchBtn");
|
||||||
|
const macSearchTableBody = document.getElementById("macSearchTableBody");
|
||||||
|
const macSearchSummary = document.getElementById("macSearchSummary");
|
||||||
const edgeListPanel = document.getElementById("edgeListPanel");
|
const edgeListPanel = document.getElementById("edgeListPanel");
|
||||||
const edgeListPre = document.getElementById("edgeListPre");
|
const edgeListPre = document.getElementById("edgeListPre");
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
@@ -1221,11 +1256,84 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runMacSearch() {
|
||||||
|
const scanId = scanIdInput.value.trim();
|
||||||
|
const mac = (macSearchInput && macSearchInput.value.trim()) || "";
|
||||||
|
if (!scanId) {
|
||||||
|
setStatus("Укажите scan_id для поиска MAC.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mac) {
|
||||||
|
setStatus("Введите MAC-адрес.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (macSearchSummary) macSearchSummary.textContent = "";
|
||||||
|
if (macSearchTableBody) {
|
||||||
|
macSearchTableBody.innerHTML =
|
||||||
|
'<tr><td colspan="8" style="padding:8px;color:#64748b;">Загрузка…</td></tr>';
|
||||||
|
}
|
||||||
|
setStatus(`Поиск MAC в scan ${scanId}…`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/scans/${encodeURIComponent(scanId)}/mac-locations?mac=${encodeURIComponent(mac)}&limit=2000`
|
||||||
|
);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (macSearchTableBody) {
|
||||||
|
macSearchTableBody.innerHTML = `<tr><td colspan="8" style="padding:8px;color:#b91c1c;">${escapeHtml(
|
||||||
|
text || res.statusText
|
||||||
|
)}</td></tr>`;
|
||||||
|
}
|
||||||
|
setStatus(`Ошибка поиска MAC: ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const items = data.items || [];
|
||||||
|
const canon = data.mac || mac;
|
||||||
|
if (macSearchSummary) {
|
||||||
|
macSearchSummary.textContent = items.length
|
||||||
|
? `Нормализовано: ${canon} · записей: ${items.length}`
|
||||||
|
: `Нормализовано: ${canon} · в FDB этого скана нет`;
|
||||||
|
}
|
||||||
|
if (!macSearchTableBody) return;
|
||||||
|
if (items.length === 0) {
|
||||||
|
macSearchTableBody.innerHTML =
|
||||||
|
'<tr><td colspan="8" style="padding:8px;color:#64748b;">Нет совпадений в scan_port_devices.</td></tr>';
|
||||||
|
setStatus(`Поиск MAC: совпадений нет (${canon}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
macSearchTableBody.innerHTML = items
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`<tr><td>${escapeHtml(row.switch_ip || "")}</td><td>${escapeHtml(
|
||||||
|
row.switch_sys_name || "—"
|
||||||
|
)}</td><td>${escapeHtml(String(row.if_index ?? ""))}</td><td>${escapeHtml(
|
||||||
|
String(row.bridge_port ?? "")
|
||||||
|
)}</td><td>${escapeHtml(String(row.vlan ?? ""))}</td><td>${escapeHtml(
|
||||||
|
row.mac || "—"
|
||||||
|
)}</td><td>${escapeHtml(row.client_ip || "—")}</td><td>${escapeHtml(row.client_name || "—")}</td></tr>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
setStatus(`Поиск MAC: найдено ${items.length} строк (${canon}).`);
|
||||||
|
} catch (e) {
|
||||||
|
if (macSearchTableBody) {
|
||||||
|
macSearchTableBody.innerHTML = `<tr><td colspan="8" style="padding:8px;color:#b91c1c;">${escapeHtml(
|
||||||
|
e.message
|
||||||
|
)}</td></tr>`;
|
||||||
|
}
|
||||||
|
setStatus(`Ошибка поиска MAC: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadBtn.addEventListener("click", async () => {
|
loadBtn.addEventListener("click", async () => {
|
||||||
await loadTopology();
|
await loadTopology();
|
||||||
await loadDeviceTable(scanIdInput.value.trim());
|
await loadDeviceTable(scanIdInput.value.trim());
|
||||||
});
|
});
|
||||||
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
||||||
|
macSearchBtn?.addEventListener("click", runMacSearch);
|
||||||
|
macSearchInput?.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.key === "Enter") runMacSearch();
|
||||||
|
});
|
||||||
createScanBtn.addEventListener("click", createScan);
|
createScanBtn.addEventListener("click", createScan);
|
||||||
document.getElementById("purgeDbBtn")?.addEventListener("click", async () => {
|
document.getElementById("purgeDbBtn")?.addEventListener("click", async () => {
|
||||||
const inp = document.getElementById("purgeSecretInput");
|
const inp = document.getElementById("purgeSecretInput");
|
||||||
|
|||||||
Reference in New Issue
Block a user