Skip to content

Commit b6ea1f8

Browse files
authored
feat(topology): real uplink evidence, inferred leaf switches, hover card dedupe (#1325)
Refs #1325. TopoLink.Evidence (device/fdb/lldp/override) drives the uplink label; managed switches infer leaf nodes behind non-uplink ports with 2+ wired clients; switch hover cards dedupe the same-peer line. Items 1-2 of the issue stay open pending the fleet retest.
1 parent 65e2a8c commit b6ea1f8

8 files changed

Lines changed: 275 additions & 15 deletions

File tree

‎app/src/components/topology/TopologyMap.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,10 @@ function TooltipCard({
291291
<span className="font-display text-sm font-semibold text-text-primary">
292292
{tip.node.name ?? t('topology.dist.managed')}
293293
</span>
294-
<StatusPill tone="accent" label="LLDP" />
294+
{/* #1325: la procedencia se alinea con la evidencia real del nodo
295+
(LLDP si el router lo vio anunciarse; si no, el integrador).
296+
Antes era "LLDP" en todas las cajas gestionadas. */}
297+
<StatusPill tone="accent" label={distBadge(tip.node) || t('topology.dist.managed')} />
295298
</div>
296299
<div className="mt-0.5 text-caption text-text-muted">
297300
{[tip.node.ip, portName(tip.node.port, tip.node.portLabel)].filter(Boolean).join(' · ')}
@@ -300,7 +303,12 @@ function TooltipCard({
300303
<MiniStat label={t('topology.dist.port', { owner: portOwnerName(tip.node) })} value={portName(tip.node.port, tip.node.portLabel)} />
301304
<MiniStat label={t('topology.dist.macs')} value={String(tip.node.macCount)} />
302305
</div>
303-
{tip.node.lldp && (
306+
{/* #1325 (item 1): work-around defensivo. La label del distnode
307+
(name/ip) y la del puerto (lldp.chassis/mgmt) resuelven al MISMO
308+
vecino por vías distintas; si el chasis LLDP repite el nombre del
309+
nodo, se pinta UNA sola línea (el nombre ya identifica al peer).
310+
Mientras el server no unifique las dos vías. */}
311+
{tip.node.lldp && tip.node.lldp.chassis !== tip.node.name && (
304312
<div className="mt-2 text-caption font-semibold text-accent">
305313
{t('topology.lldpIdentified', {
306314
chassis: tip.node.lldp.chassis ?? '—',

‎app/src/components/topology/model.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,27 @@ function flowFor(mbps: number, alive = false): { packets: number; packetDur: num
679679
return { packets, packetDur: Math.max(1.6, 5 - mbps / 12) }
680680
}
681681

682+
/**
683+
* Tag de evidencia real del uplink (#1325). El server manda `evidence` por
684+
* enlace; el front ya no decide el "· LLDP" por node.router.lldp (heurístico
685+
* del router), que podía etiquetar "LLDP" un enlace resuelto por FDB/device.
686+
* Vacía = fallback al gateway (sin evidencia, sin tag).
687+
*/
688+
function evidenceTag(evidence?: string): string {
689+
switch (evidence) {
690+
case 'lldp':
691+
return ' · LLDP'
692+
case 'fdb':
693+
return ' · FDB'
694+
case 'device':
695+
return ' · device'
696+
case 'override':
697+
return ' · manual'
698+
default:
699+
return ''
700+
}
701+
}
702+
682703
// ---------------------------------------------------------------------------
683704
// Builder
684705
// ---------------------------------------------------------------------------
@@ -1436,7 +1457,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
14361457
: `${sl.speedMbps} Mbps`)
14371458
: '1G'
14381459
const portSuffix = sl.port ? ` · ${sl.port}` : ''
1439-
const label = isWifi ? 'WiFi uplink' : `Cable ${speedText}${node.router.lldp ? ' · LLDP' : ''}${portSuffix}`
1460+
const label = isWifi ? 'WiFi uplink' : `Cable ${speedText}${evidenceTag(sl.evidence)}${portSuffix}`
14401461
// #1047: el padre lo decide la semántica (FDB del switch cuando no
14411462
// hay LLDP); solo se cae al gateway si el server no lo resolvió.
14421463
// #1060: el padre puede ser un distnode (círculo inferido/gestionado):

‎app/src/data/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,12 @@ export interface TopoSemLink {
433433
port?: string
434434
/** velocidad negociada en la boca del padre (Mbps), 0/ausente = sin dato (#1321) */
435435
speedMbps?: number
436+
/**
437+
* Evidencia real que resolvió el uplink del hijo (#1325): "override" |
438+
* "lldp" | "device" | "fdb". Ausente/vacía = fallback al gateway (sin
439+
* evidencia: el front no pinta tag). Solo en enlaces kind='uplink'.
440+
*/
441+
evidence?: string
436442
}
437443

438444
/**

‎server-go/internal/adapters/topo_semantics.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,12 +383,18 @@ func BuildTopoSemantics(routers []Router, devices []Device, wg WireGuardStats, d
383383
}
384384
}
385385
uplinkEvidence := map[string]topoParent{}
386+
// uplinkSource (#1325): qué capa fijó el padre de cada unidad de flota,
387+
// para exponer la evidencia real por enlace (el front ya no decide el tag
388+
// "LLDP" por node.router.lldp). Valores canónicos: "device"/"fdb"/"lldp"/
389+
// "override". Vacío = fallback al gateway (sin evidencia, sin tag).
390+
uplinkSource := map[string]string{}
386391
filtered := online[:0]
387392
for _, d := range online {
388393
if rid, ok := routerMacs[strings.ToUpper(d.MAC)]; ok {
389394
if rid != d.RouterID {
390395
if _, seen := uplinkEvidence[rid]; !seen {
391396
uplinkEvidence[rid] = topoParent{parent: d.RouterID, port: d.Port}
397+
uplinkSource[rid] = "device"
392398
}
393399
}
394400
continue
@@ -402,6 +408,7 @@ func BuildTopoSemantics(routers []Router, devices []Device, wg WireGuardStats, d
402408
for rid, ev := range fdbEvidence {
403409
if _, ok := uplinkEvidence[rid]; !ok {
404410
uplinkEvidence[rid] = ev
411+
uplinkSource[rid] = "fdb"
405412
}
406413
}
407414
// #1279: el LLDP es ground truth (dos equipos que se anuncian por el
@@ -411,6 +418,7 @@ func BuildTopoSemantics(routers []Router, devices []Device, wg WireGuardStats, d
411418
for rid, ev := range lldpEvidence {
412419
if ev.parent != rid {
413420
uplinkEvidence[rid] = ev
421+
uplinkSource[rid] = "lldp"
414422
}
415423
}
416424
// #1321: el attach manual (#142) sobre la MAC bridge de una unidad de
@@ -426,6 +434,7 @@ func BuildTopoSemantics(routers []Router, devices []Device, wg WireGuardStats, d
426434
}
427435
}
428436
uplinkEvidence[rid] = ev
437+
uplinkSource[rid] = "override"
429438
}
430439
// #1042: el equipo aguas arriba del gateway (módem/ONT del ISP) se
431440
// descubre por ARP como un cliente más, pero vive en el lado WAN: si su
@@ -538,7 +547,7 @@ func BuildTopoSemantics(routers []Router, devices []Device, wg WireGuardStats, d
538547
}
539548
}
540549
}
541-
sem.Links = append(sem.Links, TopoLink{From: from, To: ap.ID, Kind: "uplink", Port: port})
550+
sem.Links = append(sem.Links, TopoLink{From: from, To: ap.ID, Kind: "uplink", Port: port, Evidence: uplinkSource[ap.ID]})
542551
}
543552
// router → distnode (solo inferred|managed son hubs propios en el mapa).
544553
// En una cadena LLDP switch→switch (issue #300) el distnode cuelga de su

‎server-go/internal/adapters/topo_semantics_test.go‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -251,11 +251,11 @@ func TestTopoSemanticsFdbUplinkParent(t *testing.T) {
251251
links[l.To] = l
252252
}
253253
}
254-
if got := links["sw1"]; got.From != "gw" || got.Port != "1" {
255-
t.Fatalf("uplink sw1: %+v, want from gw puerto 1", got)
254+
if got := links["sw1"]; got.From != "gw" || got.Port != "1" || got.Evidence != "device" {
255+
t.Fatalf("uplink sw1: %+v, want from gw puerto 1 evidencia device", got)
256256
}
257-
if got := links["ap1"]; got.From != "sw1" || got.Port != "5" {
258-
t.Fatalf("uplink ap1: %+v, want from sw1 puerto 5", got)
257+
if got := links["ap1"]; got.From != "sw1" || got.Port != "5" || got.Evidence != "device" {
258+
t.Fatalf("uplink ap1: %+v, want from sw1 puerto 5 evidencia device", got)
259259
}
260260
// Las unidades de flota no duplican como chips de cliente.
261261
for _, ring := range sem.Rings {
@@ -289,11 +289,11 @@ func TestTopoSemanticsFdbEvidenceSinDevices(t *testing.T) {
289289
links[l.To] = l
290290
}
291291
}
292-
if got := links["ap1"]; got.From != "sw1" || got.Port != "5" {
293-
t.Fatalf("uplink ap1: %+v, want from sw1 puerto 5", got)
292+
if got := links["ap1"]; got.From != "sw1" || got.Port != "5" || got.Evidence != "fdb" {
293+
t.Fatalf("uplink ap1: %+v, want from sw1 puerto 5 evidencia fdb", got)
294294
}
295-
if got := links["sw1"]; got.From != "gw" || got.Port != "1" {
296-
t.Fatalf("uplink sw1: %+v, want from gw puerto 1", got)
295+
if got := links["sw1"]; got.From != "gw" || got.Port != "1" || got.Evidence != "fdb" {
296+
t.Fatalf("uplink sw1: %+v, want from gw puerto 1 evidencia fdb", got)
297297
}
298298
}
299299

@@ -311,7 +311,7 @@ func TestTopoSemanticsDeviceEvidenceManda(t *testing.T) {
311311
sem := BuildTopoSemantics(routers, devices, WireGuardStats{}, nil, "", ev, nil, nil)
312312
for _, l := range sem.Links {
313313
if l.Kind == "uplink" && l.To == "ap1" {
314-
if l.From != "gw" || l.Port != "4" {
314+
if l.From != "gw" || l.Port != "4" || l.Evidence != "device" {
315315
t.Fatalf("la evidencia de device debe mandar: %+v", l)
316316
}
317317
return
@@ -562,7 +562,7 @@ func TestTopoSemanticsLldpEvidenceWins(t *testing.T) {
562562
sem := BuildTopoSemantics(routers, nil, WireGuardStats{}, nil, "", fdb, lldp, nil)
563563
for _, l := range sem.Links {
564564
if l.Kind == "uplink" && l.To == "ap1" {
565-
if l.From != "sw1" || l.Port != "two-gigabitEthernet 1/0/21" {
565+
if l.From != "sw1" || l.Port != "two-gigabitEthernet 1/0/21" || l.Evidence != "lldp" {
566566
t.Fatalf("LLDP debe mandar sobre la FDB: %+v", l)
567567
}
568568
return
@@ -584,11 +584,30 @@ func TestTopoSemanticsOverrideEvidenceWins(t *testing.T) {
584584
sem := BuildTopoSemantics(routers, nil, WireGuardStats{}, nil, "", nil, lldp, ovr)
585585
for _, l := range sem.Links {
586586
if l.Kind == "uplink" && l.To == "ap1" {
587-
if l.From != "sw1" || l.Port != "lan4" {
587+
if l.From != "sw1" || l.Port != "lan4" || l.Evidence != "override" {
588588
t.Fatalf("el override debe mandar conservando el puerto inferido: %+v", l)
589589
}
590590
return
591591
}
592592
}
593593
t.Fatal("falta el uplink de ap1")
594594
}
595+
596+
// #1325: sin evidencia de ninguna capa el uplink cae al gateway y no lleva
597+
// tag de evidencia (Evidence vacía). El fallback al gateway no es evidencia.
598+
func TestTopoSemanticsFallbackSinEvidencia(t *testing.T) {
599+
routers := []Router{
600+
{ID: "gw", Name: "gateway", RoleBadge: "Principal", MAC: "AA:BB:CC:00:00:01"},
601+
{ID: "ap1", Name: "ap1", MAC: "AA:BB:CC:00:00:03"},
602+
}
603+
sem := BuildTopoSemantics(routers, nil, WireGuardStats{}, nil, "", nil, nil, nil)
604+
for _, l := range sem.Links {
605+
if l.Kind == "uplink" && l.To == "ap1" {
606+
if l.From != "gw" || l.Evidence != "" {
607+
t.Fatalf("uplink ap1 sin evidencia: %+v, want from gw y Evidence vacía", l)
608+
}
609+
return
610+
}
611+
}
612+
t.Fatal("falta el uplink de ap1")
613+
}

‎server-go/internal/adapters/topology.go‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,28 @@ func keptMacsForPort(portMACs []string, routerID string, byMAC map[string]int, d
9393
return kept
9494
}
9595

96+
// leafSwitchClients (#1325): MACs de un puerto del FDB de un SWITCH gestionado
97+
// que cuentan como clientes para el umbral de switch hoja (>= 2). Excluye las
98+
// bridge-MACs de la flota y los clientes wifi conocidos (los de un AP que
99+
// transitan por el switch). A diferencia de keptMacsForPort, NO filtra por
100+
// RouterID: los clientes cableados de un switch se atribuyen al DHCP server
101+
// (casi siempre el gateway), no al switch, y aquí importa el enlace físico.
102+
func leafSwitchClients(portMACs []string, byMAC map[string]int, devices []Device, routerMACs map[string]bool) []string {
103+
kept := make([]string, 0, len(portMACs))
104+
for _, mac := range portMACs {
105+
if routerMACs[mac] {
106+
continue
107+
}
108+
if idx, ok := byMAC[mac]; ok {
109+
if devices[idx].Band != "cable" && devices[idx].Band != "—" {
110+
continue
111+
}
112+
}
113+
kept = append(kept, mac)
114+
}
115+
return kept
116+
}
117+
96118
// hypervisorHostMAC: MAC del host de un hipervisor claro en `kept` (MACs OUI
97119
// de hipervisor + exactamente un host normal, siendo ese host un Device del
98120
// router). Vacío+false si el puerto no es un hipervisor claro.
@@ -383,6 +405,81 @@ func inferTopology(polled map[string]*routerPolled, devices []Device) ([]Device,
383405
}
384406
}
385407

408+
// #1325: switches hoja inferidos tras switches gestionados. Un switch
409+
// gestionado (distnode kind="managed") aprende en SU FDB los clientes
410+
// cableados de un switch hoja no gestionado: cada puerto que NO sea su
411+
// uplink (root port, donde aprende la bridge-MAC del gateway) y que
412+
// aprenda >= 2 clientes cableados -> nodo inferido hoja anclado al switch
413+
// (Parent) para que cuelgue de él en el mapa, no del router. Los APs
414+
// (no-switch) siguen excluidos: su puerto multi-MAC es su propio uplink.
415+
// La fase FDB no los veía porque keptMacsForPort filtra por RouterID y
416+
// los clientes de un switch se atribuyen al DHCP server (el gateway).
417+
if gatewayID != "" {
418+
switchIDByMac := map[string]string{}
419+
for id, p := range polled {
420+
if p != nil && p.brMac != "" && p.cfg.Type == "managed-switch" {
421+
switchIDByMac[strings.ToUpper(p.brMac)] = id
422+
}
423+
}
424+
gwMac := ""
425+
if gw := polled[gatewayID]; gw != nil {
426+
gwMac = strings.ToUpper(gw.brMac)
427+
}
428+
var managed []DistributionNode
429+
for _, dn := range dists {
430+
if dn.Kind == "managed" && dn.Mac != "" {
431+
managed = append(managed, dn)
432+
}
433+
}
434+
for _, dn := range managed {
435+
switchID, ok := switchIDByMac[strings.ToUpper(dn.Mac)]
436+
if !ok {
437+
continue
438+
}
439+
p := polled[switchID]
440+
if p == nil {
441+
continue
442+
}
443+
rootPort := ""
444+
if gwMac != "" {
445+
rootPort = p.fdb[gwMac]
446+
}
447+
byPort := fdbByPort(p.fdb)
448+
ports := make([]string, 0, len(byPort))
449+
for port := range byPort {
450+
ports = append(ports, port)
451+
}
452+
sort.Strings(ports)
453+
for _, port := range ports {
454+
if port == rootPort {
455+
continue // uplink del propio switch (bucle)
456+
}
457+
if lldpHandled[switchID][port] {
458+
continue
459+
}
460+
occupied := false
461+
for _, d2 := range dists {
462+
if d2.RouterID == switchID && d2.Port == port && (d2.Kind == "managed" || d2.Kind == "inferred") {
463+
occupied = true
464+
break
465+
}
466+
}
467+
if occupied {
468+
continue // el puerto ya tiene distnode managed/inferred
469+
}
470+
kept := leafSwitchClients(byPort[port], byMAC, devices, routerMACs)
471+
if len(kept) < 2 {
472+
continue
473+
}
474+
id := fmt.Sprintf("dist-%s-%s", switchID, port)
475+
dists = append(dists, DistributionNode{
476+
ID: id, Kind: "inferred", RouterID: switchID, Port: port,
477+
PortLabel: portLabel(p, port), MacCount: len(kept), Parent: dn.ID,
478+
})
479+
}
480+
}
481+
}
482+
386483
// Post-paso LLDP: un vecino cuya chassis-MAC es un Device conocido lo
387484
// identifica (Device.Lldp), en el puerto que sea y aunque no multiplexe.
388485
for _, routerID := range routerIDs {

0 commit comments

Comments
 (0)