Files
nettopo-go/internal/api/handler.go
T
PTah 088be32ec5 feat: отмена скана на сервере, стабильнее scan_id, упрощённый UI
- Runner: context, POST /api/scans/{id}/cancel, частичный canceled; Start до ответа createScan

- API: scanPathID (PathUnescape), лог при 404; Postgres GetScan логирует ошибки чтения

- newScanID: суффикс 8 hex; тест обновлён

- Web UI: убраны строки scan_id/from-to; скрытое поле scanId; cancel при таймауте/новом скане

Made-with: Cursor
2026-04-13 15:26:23 +10:00

794 lines
21 KiB
Go

package api
import (
"encoding/json"
"log"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"nettopo-go/internal/scans"
"nettopo-go/internal/webui"
)
type Handler struct {
store scans.Store
run *scans.Runner
resetDBSecret string
}
type LinkView struct {
SourceIP string `json:"source_ip"`
SourcePort string `json:"source_port"`
TargetIP string `json:"target_ip,omitempty"`
TargetSysName string `json:"target_sys_name,omitempty"`
TargetPort string `json:"target_port,omitempty"`
RemoteChassis string `json:"remote_chassis_id,omitempty"`
ResolvedBy string `json:"resolved_by"`
}
type TopologyNode struct {
ID string `json:"id"`
IP string `json:"ip"`
Name string `json:"name,omitempty"`
}
type TopologyEdge struct {
SourceIP string `json:"source_ip"`
SourcePort string `json:"source_port,omitempty"`
TargetIP string `json:"target_ip,omitempty"`
TargetName string `json:"target_name,omitempty"`
TargetPort string `json:"target_port,omitempty"`
}
type ScanDiffResponse struct {
FromScanID string `json:"from_scan_id"`
ToScanID string `json:"to_scan_id"`
NewHosts []string `json:"new_hosts"`
MissingHosts []string `json:"missing_hosts"`
NewLinks []string `json:"new_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"`
MacsOnPort int `json:"macs_on_port"`
PortKind string `json:"port_kind"` // access | ambiguous | strong_transit
CheckedAt time.Time `json:"checked_at"`
}
func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
}
func scanPathID(r *http.Request) string {
raw := r.PathValue("id")
if raw == "" {
return ""
}
if dec, err := url.PathUnescape(raw); err == nil {
return strings.TrimSpace(dec)
}
return strings.TrimSpace(raw)
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
webFS := http.FileServer(http.FS(webui.FS))
mux.Handle("GET /", webFS)
mux.Handle("GET /web/", http.StripPrefix("/web/", webFS))
mux.HandleFunc("GET /health", h.health)
mux.HandleFunc("POST /api/scans", h.createScan)
mux.HandleFunc("GET /api/scans", h.listScans)
mux.HandleFunc("GET /api/scans/diff", h.getScansDiff)
mux.HandleFunc("GET /api/scans/{id}", h.getScan)
mux.HandleFunc("POST /api/scans/{id}/cancel", h.postCancelScan)
mux.HandleFunc("GET /api/scans/{id}/hosts", h.getScanHosts)
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}/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}/topology", h.getScanTopology)
mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
return withJSON(mux)
}
func (h *Handler) postAdminPurgeScans(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "use POST")
return
}
if h.resetDBSecret == "" {
writeError(w, http.StatusForbidden, "purge disabled: set NETTOPO_RESET_DB_SECRET in environment")
return
}
key := strings.TrimSpace(r.Header.Get("X-Nettopo-Reset-Key"))
if key == "" || key != h.resetDBSecret {
writeError(w, http.StatusUnauthorized, "invalid or missing X-Nettopo-Reset-Key header")
return
}
if err := h.store.PurgeAllScanData(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "all scan data removed (scan_jobs and related rows)",
})
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"time": time.Now().UTC(),
})
}
func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) {
var req scans.CreateScanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
req.Name = "manual-scan"
}
job, err := h.store.CreateScan(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Start до ответа клиенту: иначе возможен POST /cancel до регистрации cancel в Runner (задача останется queued/running).
if h.run != nil {
h.run.Start(job)
}
writeJSON(w, http.StatusCreated, map[string]any{
"scan_id": job.ID,
"status": job.Status,
})
}
func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) {
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 500 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 500")
return
}
limit = n
}
writeJSON(w, http.StatusOK, map[string]any{
"items": h.store.ListScans(limit),
})
}
func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) {
fromID := strings.TrimSpace(r.URL.Query().Get("from"))
toID := strings.TrimSpace(r.URL.Query().Get("to"))
if fromID == "" || toID == "" {
writeError(w, http.StatusBadRequest, "both from and to are required")
return
}
if _, ok := h.store.GetScan(fromID); !ok {
writeError(w, http.StatusNotFound, "from scan not found")
return
}
if _, ok := h.store.GetScan(toID); !ok {
writeError(w, http.StatusNotFound, "to scan not found")
return
}
fromHosts := hostUpSet(h.store.ListHostResults(fromID, 200000))
toHosts := hostUpSet(h.store.ListHostResults(toID, 200000))
fromLinks := linkSet(h.store.ListLLDPResults(fromID, 200000))
toLinks := linkSet(h.store.ListLLDPResults(toID, 200000))
resp := ScanDiffResponse{
FromScanID: fromID,
ToScanID: toID,
NewHosts: setDiff(toHosts, fromHosts),
MissingHosts: setDiff(fromHosts, toHosts),
NewLinks: setDiff(toLinks, fromLinks),
MissingLinks: setDiff(fromLinks, toLinks),
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required")
return
}
job, ok := h.store.GetScan(id)
if !ok {
log.Printf("getScan: not found id=%q (len=%d)", id, len(id))
writeError(w, http.StatusNotFound, "scan not found")
return
}
writeJSON(w, http.StatusOK, job)
}
func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "use POST")
return
}
id := scanPathID(r)
if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required")
return
}
if _, ok := h.store.GetScan(id); !ok {
log.Printf("postCancelScan: not found id=%q (len=%d)", id, len(id))
writeError(w, http.StatusNotFound, "scan not found")
return
}
runnerSignaled := false
if h.run != nil {
runnerSignaled = h.run.CancelScan(id)
}
if runnerSignaled {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": true})
return
}
cur, _ := h.store.GetScan(id)
if cur.Status == "queued" {
fin := time.Now().UTC()
_, _ = h.store.UpdateScan(id, scans.ScanUpdate{Status: "canceled", FinishedAt: &fin})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false, "status": "canceled"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false})
}
func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 200000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 500000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 500000")
return
}
limit = n
}
writeJSON(w, http.StatusOK, map[string]any{
"items": h.store.ListHostResults(id, limit),
})
}
func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 1000
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.ListOpenPortResults(id, limit),
})
}
func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 200000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 500000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 500000")
return
}
limit = n
}
writeJSON(w, http.StatusOK, map[string]any{
"items": h.store.ListSNMPResults(id, limit),
})
}
func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 2000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 20000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 20000")
return
}
limit = n
}
writeJSON(w, http.StatusOK, map[string]any{
"items": h.store.ListLLDPResults(id, limit),
})
}
func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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) getScanPortDevices(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
ifIndex := 0
if raw := strings.TrimSpace(r.URL.Query().Get("if_index")); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "if_index must be >= 0")
return
}
ifIndex = n
}
limit := 20000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 100000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 100000")
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.ListPortDeviceResults(id, ip, ifIndex, limit)
for i := range items {
if lip := strings.TrimSpace(items[i].LearnedIP); lip != "" {
if nm, ok := ipToName[lip]; ok {
items[i].Hostname = nm
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
})
}
func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
accessMax := 8
trunkMin := 32
if v := strings.TrimSpace(r.URL.Query().Get("access_max_macs")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 4096 {
writeError(w, http.StatusBadRequest, "access_max_macs must be between 1 and 4096")
return
}
accessMax = n
}
if v := strings.TrimSpace(r.URL.Query().Get("trunk_min_macs")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 2 || n > 65536 {
writeError(w, http.StatusBadRequest, "trunk_min_macs must be between 2 and 65536")
return
}
trunkMin = n
}
if accessMax >= trunkMin {
writeError(w, http.StatusBadRequest, "access_max_macs must be less than trunk_min_macs")
return
}
counts, err := h.store.CountDistinctMACsPerSwitchPort(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
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 {
key := scans.PortFDBLocationKey(r.IP, r.IfIndex, r.BridgePort, r.Vlan)
nOn := counts[key]
if nOn < 1 {
nOn = 1
}
row := MacLocationRow{
SwitchIP: r.IP,
IfIndex: r.IfIndex,
BridgePort: r.BridgePort,
Vlan: r.Vlan,
MAC: r.MAC,
ClientIP: strings.TrimSpace(r.LearnedIP),
MacsOnPort: nOn,
PortKind: scans.ClassifyPortKind(nOn, accessMax, trunkMin),
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)
}
sort.Slice(out, func(i, j int) bool {
ri, rj := scans.PortKindRank(out[i].PortKind), scans.PortKindRank(out[j].PortKind)
if ri != rj {
return ri < rj
}
if out[i].MacsOnPort != out[j].MacsOnPort {
return out[i].MacsOnPort < out[j].MacsOnPort
}
if out[i].SwitchIP != out[j].SwitchIP {
return out[i].SwitchIP < out[j].SwitchIP
}
if out[i].IfIndex != out[j].IfIndex {
return out[i].IfIndex < out[j].IfIndex
}
if out[i].Vlan != out[j].Vlan {
return out[i].Vlan < out[j].Vlan
}
return out[i].ClientIP < out[j].ClientIP
})
likely := make([]MacLocationRow, 0)
for _, row := range out {
if row.PortKind == "access" {
likely = append(likely, row)
}
}
sort.Slice(likely, func(i, j int) bool {
if likely[i].MacsOnPort != likely[j].MacsOnPort {
return likely[i].MacsOnPort < likely[j].MacsOnPort
}
if likely[i].SwitchIP != likely[j].SwitchIP {
return likely[i].SwitchIP < likely[j].SwitchIP
}
if likely[i].IfIndex != likely[j].IfIndex {
return likely[i].IfIndex < likely[j].IfIndex
}
return likely[i].Vlan < likely[j].Vlan
})
writeJSON(w, http.StatusOK, map[string]any{
"mac": norm,
"access_max_macs": accessMax,
"trunk_min_macs": trunkMin,
"likely_attachment": likely,
"items": out,
})
}
func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 2000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 20000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 20000")
return
}
limit = n
}
snmpItems := h.store.ListSNMPResults(id, 200000)
lldpItems := h.store.ListLLDPResults(id, limit)
sysNameToIP := make(map[string]string, len(snmpItems))
for _, s := range snmpItems {
name := strings.TrimSpace(strings.ToLower(s.SysName))
if name == "" {
continue
}
if _, exists := sysNameToIP[name]; !exists {
sysNameToIP[name] = s.IP
}
}
links := make([]LinkView, 0, len(lldpItems))
for _, l := range lldpItems {
targetNameNorm := strings.TrimSpace(strings.ToLower(l.RemoteSysName))
targetIP := ""
resolvedBy := "unresolved"
if targetNameNorm != "" {
if ip, ok := sysNameToIP[targetNameNorm]; ok {
targetIP = ip
resolvedBy = "remote_sys_name"
}
}
links = append(links, LinkView{
SourceIP: l.IP,
SourcePort: l.LocalPortNum,
TargetIP: targetIP,
TargetSysName: l.RemoteSysName,
TargetPort: l.RemotePortID,
RemoteChassis: l.RemoteChassisID,
ResolvedBy: resolvedBy,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"items": links,
})
}
func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) {
id := scanPathID(r)
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
}
limit := 5000
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 || n > 50000 {
writeError(w, http.StatusBadRequest, "limit must be between 1 and 50000")
return
}
limit = n
}
snmpItems := h.store.ListSNMPResults(id, 200000)
lldpItems := h.store.ListLLDPResults(id, limit)
sysNameToIP := make(map[string]string, len(snmpItems))
nodesByIP := make(map[string]TopologyNode, len(snmpItems))
for _, s := range snmpItems {
nameNorm := strings.TrimSpace(strings.ToLower(s.SysName))
if nameNorm != "" {
if _, exists := sysNameToIP[nameNorm]; !exists {
sysNameToIP[nameNorm] = s.IP
}
}
nodesByIP[s.IP] = TopologyNode{
ID: s.IP,
IP: s.IP,
Name: s.SysName,
}
}
edges := make([]TopologyEdge, 0, len(lldpItems))
for _, l := range lldpItems {
targetIP := ""
targetNameNorm := strings.TrimSpace(strings.ToLower(l.RemoteSysName))
if targetNameNorm != "" {
targetIP = sysNameToIP[targetNameNorm]
}
if _, exists := nodesByIP[l.IP]; !exists {
nodesByIP[l.IP] = TopologyNode{ID: l.IP, IP: l.IP}
}
if targetIP != "" {
if _, exists := nodesByIP[targetIP]; !exists {
nodesByIP[targetIP] = TopologyNode{ID: targetIP, IP: targetIP, Name: l.RemoteSysName}
}
}
edges = append(edges, TopologyEdge{
SourceIP: l.IP,
SourcePort: l.LocalPortNum,
TargetIP: targetIP,
TargetName: l.RemoteSysName,
TargetPort: l.RemotePortID,
})
}
nodes := make([]TopologyNode, 0, len(nodesByIP))
for _, n := range nodesByIP {
nodes = append(nodes, n)
}
writeJSON(w, http.StatusOK, map[string]any{
"nodes": nodes,
"edges": edges,
})
}
func withJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/health" {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
next.ServeHTTP(w, r)
})
}
func hostUpSet(items []scans.HostResult) map[string]struct{} {
out := make(map[string]struct{}, len(items))
for _, h := range items {
if h.IsUp {
out[h.IP] = struct{}{}
}
}
return out
}
func linkSet(items []scans.LLDPResult) map[string]struct{} {
out := make(map[string]struct{}, len(items))
for _, l := range items {
key := l.IP + "|" + l.LocalPortNum + "|" + l.RemoteSysName + "|" + l.RemotePortID + "|" + l.RemoteChassisID
out[key] = struct{}{}
}
return out
}
func setDiff(a, b map[string]struct{}) []string {
out := make([]string, 0)
for k := range a {
if _, ok := b[k]; !ok {
out = append(out, k)
}
}
return out
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}