@@ -196,9 +199,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
export function LegendCard({ model }: { model: TopologyModel }) {
const { t } = useTranslation()
return (
-
+
{t('topology.legend.title')}
-
+
)
}
diff --git a/app/src/components/topology/LinksTable.tsx b/app/src/components/topology/LinksTable.tsx
index 14d3b1b2..e26136d5 100644
--- a/app/src/components/topology/LinksTable.tsx
+++ b/app/src/components/topology/LinksTable.tsx
@@ -126,6 +126,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
{t('topology.links.colLink')} |
{t('devices.colType')} |
{t('topology.links.colSpeed')} |
+ {t('topology.links.colPing')} |
{t('devices.colSignal')} |
{t('routers.colStatus')} |
@@ -152,6 +153,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
| {t(row.type)} |
{row.speed} |
+ {row.ping} |
{row.signal} |
@@ -186,7 +188,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
{t(row.type)} · {row.speed} ·{' '}
- {row.signal}
+ {row.ping} · {row.signal}
{row.note &&
}
diff --git a/app/src/components/topology/model.ts b/app/src/components/topology/model.ts
index f1cb7c5f..693d991a 100644
--- a/app/src/components/topology/model.ts
+++ b/app/src/components/topology/model.ts
@@ -103,6 +103,8 @@ export interface BackhaulRow {
kind: TopoLinkKind
type: string
speed: string
+ /** Latencia del extremo destino en ms, formateada (#1384); "—" sin dato. */
+ ping: string
signal: string
tone: 'ok' | 'warn' | 'tunnel'
statusLabel: string
@@ -1616,9 +1618,15 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
// gateway. Sin semántica o sin match → gateway (comportamiento de siempre,
// demo incluido).
const uplinkSemByNodeId = new Map()
+ // #1384: velocidad del cable de un dispositivo cuando el padre la midió
+ // en esa boca (server, fillWiredSpeeds: p. ej. los hipervisores en las
+ // bocas a 2,5 G del switch gestionado, aunque el device no traiga
+ // speedMbps de ningún controlador).
+ const wiredSemSpeedByTo = new Map()
if (sem) {
for (const sl of sem.links) {
if (sl.kind === 'uplink') uplinkSemByNodeId.set(sl.to, sl)
+ if (sl.kind === 'wired' && sl.speedMbps && sl.speedMbps > 0) wiredSemSpeedByTo.set(sl.to, sl.speedMbps)
}
}
const uplinkParentName = (nodeId: string): string => {
@@ -1639,15 +1647,29 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
}
const uplinkSpeed = (nodeId: string): string =>
linkSpeed(uplinkSemByNodeId.get(nodeId)?.speedMbps)
+ // #1384: ping del enlace = latencia sondeada del equipo destino (ms, un
+ // decimal bajo 10 ms para que se aprecien las LAN); "—" sin sonda.
+ const fmtPing = (ms?: number): string => {
+ if (ms == null || ms < 0) return '—'
+ if (ms === 0) return '<1 ms'
+ return ms < 10 ? `${ms.toFixed(1)} ms` : `${Math.round(ms)} ms`
+ }
if (gatewayNode) {
backhauls.push({
id: 'wan', a: gatewayNode.router.name, b: 'Internet', kind: 'wan',
- type: 'topology.links.fiberWan', speed: wan.plan, signal: `${wan.latencyMs} ms`,
+ type: 'topology.links.fiberWan', speed: wan.plan,
+ ping: fmtPing(wan.latencyMs), signal: '—',
tone: 'ok', statusLabel: 'common.status.online',
spark: gatewayNode.router.sparkline, sparkColor: COLOR.accent,
})
}
- for (const node of apNodes) {
+ for (const node of [...apNodes, ...switchNodes]) {
+ // #1384: el uplink lo lista TODA unidad de flota no-gateway, no solo los
+ // APs: el switch gestionado (roleBadge SW) también cuelga del gateway y
+ // su enlace (p. ej. Flint2 <-> KP-9000 a 2,5 Gbps) estaba en el mapa y
+ // en la semántica pero no en la tabla. No hay duplicado con el bucle
+ // 'dist' de abajo: la sombra distnode del switch se absorbe server-side
+ // (#1374/#1376) y no llega en distributionNodes.
const isWifi = node.router.backhaul === 'wifi'
backhauls.push({
id: `uplink-${node.id}`, a: `${uplinkParentName(node.id)}${uplinkPortSuffix(node.id)}`, b: node.router.name, kind: 'uplink',
@@ -1657,6 +1679,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
// boca (#1321); "—" si nadie la midió. El tono sí es información
// real: un enlace por wifi merece mirarse más que uno por cable.
speed: uplinkSpeed(node.id),
+ ping: fmtPing(node.router.latencyMs),
signal: '—',
tone: isWifi ? 'warn' : 'ok',
statusLabel: isWifi ? 'common.status.warn' : 'common.status.online',
@@ -1680,7 +1703,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
id: `dist-${dv.id}`, a,
b: [dv.node.name, dv.node.ip, via, dv.node.portLabel ?? dv.node.port].filter(Boolean).join(' · '),
kind: 'dist', type: dv.node.role === 'ap' ? 'topology.links.managedAp' : 'topology.links.managedSwitch',
- speed: linkSpeed(dv.node.speedMbps), signal: '—',
+ speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
tone: 'ok', statusLabel: 'common.status.online',
spark, sparkColor: COLOR.accent,
})
@@ -1689,7 +1712,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
id: `dist-${dv.id}`, a,
b: '', bKey: 'topology.links.inferredSwitch', bVars: { port: dv.node.portLabel ?? dv.node.port },
kind: 'dist', type: 'common.cable',
- speed: linkSpeed(dv.node.speedMbps), signal: '—',
+ speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
tone: 'ok', statusLabel: 'common.status.online',
spark, sparkColor: COLOR.ok,
})
@@ -1707,7 +1730,10 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
id: `wired-${host.id}`, a: hubNameOf(host),
b: [host.name, port, `${ctCountByHost.get(host.id) ?? 0} CT`].filter(Boolean).join(' · '),
kind: 'wired', type: 'topology.links.hypervisorCable',
- speed: linkSpeed(host.speedMbps), signal: '—',
+ // #1384: primero la boca medida por el padre en este sondeo (sem
+ // wired, fillWiredSpeeds); si no, el sello del controlador en el
+ // device; si ninguno, "—" honesta.
+ speed: linkSpeed(wiredSemSpeedByTo.get(host.id) ?? host.speedMbps), ping: '—', signal: '—',
tone: 'ok', statusLabel: 'common.status.online',
spark: host.sparkline, sparkColor: COLOR.ok,
})
@@ -1717,7 +1743,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
const device = devices.find((d) => d.id === node.peer.id)
backhauls.push({
id: `wg-${node.peer.id}`, a: 'Internet', b: node.peer.name, kind: 'wg',
- type: 'WireGuard', speed: '—', signal: '—',
+ type: 'WireGuard', speed: '—', ping: '—', signal: '—',
tone: 'tunnel', statusLabel: 'common.active',
spark: device?.sparkline ?? [0, 0], sparkColor: COLOR.tunnel,
})
diff --git a/app/src/data/types.ts b/app/src/data/types.ts
index 4c864598..fa2ccde3 100644
--- a/app/src/data/types.ts
+++ b/app/src/data/types.ts
@@ -85,6 +85,12 @@ export interface Router {
lldp?: LldpInfo | null
/** Sparkline de tráfico 24h (Mbps, 24 puntos) */
sparkline: number[]
+ /**
+ * Latencia sondeada de la unidad en ms (#1384): ping unidad→gateway en
+ * no-gateway, latencia WAN en el gateway. Ausente si la fuente no la
+ * sondea (agent-only/external/SNMP): la tabla de enlaces pinta "—".
+ */
+ latencyMs?: number
}
export interface WanInfo {
diff --git a/app/src/pages/Topology.tsx b/app/src/pages/Topology.tsx
index 3f09392b..f9839b05 100644
--- a/app/src/pages/Topology.tsx
+++ b/app/src/pages/Topology.tsx
@@ -357,14 +357,14 @@ export default function Topology() {
- {/* ③ + ④ Leyenda y enlaces (desktop: 5/7 cols; móvil: solo enlaces en lista) */}
-
-
+ {/* ③ + ④ Leyenda y enlaces (#1384): ambas a todo lo ancho, apiladas;
+ la leyenda va en una sola fila de columnas (desktop; en móvil solo
+ la lista de enlaces, la leyenda vive en el sheet del mapa). */}
+
{/* Etiquetado manual de hardware (issue #142 Fase B): clic en un chip
diff --git a/server-go/internal/adapters/live.go b/server-go/internal/adapters/live.go
index 38ac31f3..ec27c218 100644
--- a/server-go/internal/adapters/live.go
+++ b/server-go/internal/adapters/live.go
@@ -1339,6 +1339,7 @@ func (l *Live) buildRouter(p *routerPolled, history []histPoint) Router {
CPU: iptr(p.cpu), RAM: iptr(p.ram), Temp: iptr(p.temp),
Uptime: fmtUptime(p.uptimeSec), Clients: len(p.leases),
Sparkline: sparkline, TempThreshold: thr,
+ LatencyMs: p.latencyMs,
}
if noVitals {
r.VitalsAvailable = bptr(false)
@@ -3442,6 +3443,7 @@ func (l *Live) buildOverview(ctx context.Context) (*Overview, error) {
distNodes = filtered
}
fillUplinkSpeeds(polled, distNodes, topo.Links)
+ fillWiredSpeeds(polled, distNodes, topo.Links) // #1384
return &Overview{
Health: computeHealth(routerList, adguard),
WAN: wan,
diff --git a/server-go/internal/adapters/topology.go b/server-go/internal/adapters/topology.go
index 4138491d..e401b25e 100644
--- a/server-go/internal/adapters/topology.go
+++ b/server-go/internal/adapters/topology.go
@@ -625,6 +625,17 @@ func fillNodeSpeeds(polled map[string]*routerPolled, dists []DistributionNode) {
}
}
+// #1380: los enlaces con evidencia LLDP llevan la boca física del switch
+// ("3", del PortDesc LLDP) mientras la tabla de puertos del switch la
+// nombra "lan3". Se normaliza el prefijo "lan" (solo si va seguido de un
+// dígito) para casar ambos lados sin falsos positivos (lan10 != "1").
+func stripLANPrefix(s string) string {
+ if len(s) > 3 && s[:3] == "lan" && s[3] >= '0' && s[3] <= '9' {
+ return s[3:]
+ }
+ return s
+}
+
// fillUplinkSpeeds (#1321): velocidad del uplink de una unidad cuando el
// padre la reporta en esa boca (EthPort up + parseSpeedMbps, el mismo
// criterio conservador de fillNodeSpeeds). El From del enlace puede ser el
@@ -641,16 +652,6 @@ func fillUplinkSpeeds(polled map[string]*routerPolled, dists []DistributionNode,
routerOfDist[d.ID] = d.RouterID
portOfDist[d.ID] = d.Port
}
- // #1380: los enlaces con evidencia LLDP llevan la boca física del switch
- // ("3", del PortDesc LLDP) mientras la tabla de puertos del switch la
- // nombra "lan3". Se normaliza el prefijo "lan" (solo si va seguido de un
- // dígito) para casar ambos lados sin falsos positivos (lan10 != "1").
- stripLAN := func(s string) string {
- if len(s) > 3 && s[:3] == "lan" && s[3] >= '0' && s[3] <= '9' {
- return s[3:]
- }
- return s
- }
for i := range links {
l := &links[i]
if l.Kind != "uplink" || l.SpeedMbps > 0 || l.Port == "" {
@@ -670,7 +671,49 @@ func fillUplinkSpeeds(polled map[string]*routerPolled, dists []DistributionNode,
continue
}
for _, ep := range p.ports {
- if ep.Up && (ep.ID == l.Port || stripLAN(ep.ID) == stripLAN(l.Port)) {
+ if ep.Up && (ep.ID == l.Port || stripLANPrefix(ep.ID) == stripLANPrefix(l.Port)) {
+ if mbps := parseSpeedMbps(ep.Speed); mbps > 0 {
+ l.SpeedMbps = mbps
+ }
+ break
+ }
+ }
+ }
+}
+
+// fillWiredSpeeds (#1384): velocidad de un enlace cableado cuando el padre
+// es un router de la flota sondeado directamente (gateway o switch
+// gestionado vía beacon, p. ej. el KP-9000) y midió esa boca (EthPort up +
+// parseSpeedMbps, el mismo criterio conservador de fillUplinkSpeeds). Es lo
+// que permite a la tabla de enlaces dar la velocidad del cable de un
+// hipervisor (citadel-01 en lan5 a 2,5 Gbps) aunque el device no traiga
+// speedMbps de ningún controlador.
+//
+// Solo enlaces que cuelgan DIRECTO del router: detrás de un distnode
+// (switch inferido/gestionado) o de otro device (los CTs de un hipervisor)
+// nadie midió la boca del dispositivo y SpeedMbps queda a 0 ("—" honesta).
+func fillWiredSpeeds(polled map[string]*routerPolled, dists []DistributionNode, links []TopoLink) {
+ if len(links) == 0 {
+ return
+ }
+ isDist := map[string]bool{}
+ for _, d := range dists {
+ isDist[d.ID] = true
+ }
+ for i := range links {
+ l := &links[i]
+ if l.Kind != "wired" || l.SpeedMbps > 0 || l.Port == "" {
+ continue
+ }
+ if isDist[l.From] {
+ continue
+ }
+ p := polled[l.From]
+ if p == nil {
+ continue
+ }
+ for _, ep := range p.ports {
+ if ep.Up && (ep.ID == l.Port || stripLANPrefix(ep.ID) == stripLANPrefix(l.Port)) {
if mbps := parseSpeedMbps(ep.Speed); mbps > 0 {
l.SpeedMbps = mbps
}
diff --git a/server-go/internal/adapters/topology_override_test.go b/server-go/internal/adapters/topology_override_test.go
index 5b4fa9b0..e54d240a 100644
--- a/server-go/internal/adapters/topology_override_test.go
+++ b/server-go/internal/adapters/topology_override_test.go
@@ -310,3 +310,46 @@ func TestFillUplinkSpeedsLldpPortFisico(t *testing.T) {
t.Fatalf("uplink rt4 (boca \"6\" = lan6): %d, want 1000", links[1].SpeedMbps)
}
}
+
+// #1384: velocidad del cable de un dispositivo cuando el padre es un router
+// sondeado que midió esa boca (hipervisor en el switch gestionado); detrás
+// de un distnode o de otro device nadie la midió y queda a 0.
+func TestFillWiredSpeeds(t *testing.T) {
+ polled := map[string]*routerPolled{
+ "sw1": {ports: []EthPort{
+ {ID: "lan5", Up: true, Speed: "2.5 Gbps"},
+ {ID: "lan8", Up: false, Speed: "2.5 Gbps"},
+ }},
+ "gw": {ports: []EthPort{
+ {ID: "lan2", Up: true, Speed: "1 Gbps"},
+ }},
+ }
+ dists := []DistributionNode{{ID: "dist-gw-lan3", RouterID: "gw", Port: "lan3"}}
+ links := []TopoLink{
+ {From: "sw1", To: "pve-01", Kind: "wired", Port: "lan5"}, // hipervisor directo al switch: 2,5 G
+ {From: "sw1", To: "pve-02", Kind: "wired", Port: "8"}, // normalización "8" = lan8, pero caída: 0
+ {From: "gw", To: "nas", Kind: "wired", Port: "lan2"}, // directo al gateway: 1 G
+ {From: "dist-gw-lan3", To: "pc", Kind: "wired", Port: "lan3"}, // detrás de distnode: nadie midió, 0
+ {From: "pve-01", To: "ct-1", Kind: "wired", Port: "lan5"}, // CT del hipervisor: 0
+ {From: "gw", To: "sin-puerto", Kind: "wired"}, // sin puerto: 0
+ }
+ fillWiredSpeeds(polled, dists, links)
+ if links[0].SpeedMbps != 2500 {
+ t.Fatalf("wired pve-01 (lan5 up 2.5G): %d, want 2500", links[0].SpeedMbps)
+ }
+ if links[1].SpeedMbps != 0 {
+ t.Fatalf("wired pve-02 (boca caída): %d, want 0", links[1].SpeedMbps)
+ }
+ if links[2].SpeedMbps != 1000 {
+ t.Fatalf("wired nas (lan2 gateway): %d, want 1000", links[2].SpeedMbps)
+ }
+ if links[3].SpeedMbps != 0 {
+ t.Fatalf("wired pc (detrás de distnode): %d, want 0", links[3].SpeedMbps)
+ }
+ if links[4].SpeedMbps != 0 {
+ t.Fatalf("wired ct-1 (device-hub no sondeado): %d, want 0", links[4].SpeedMbps)
+ }
+ if links[5].SpeedMbps != 0 {
+ t.Fatalf("wired sin puerto: %d, want 0", links[5].SpeedMbps)
+ }
+}
diff --git a/server-go/internal/adapters/types.go b/server-go/internal/adapters/types.go
index 8b3f028d..9f54bcd9 100644
--- a/server-go/internal/adapters/types.go
+++ b/server-go/internal/adapters/types.go
@@ -175,6 +175,10 @@ type Router struct {
Type string `json:"type,omitempty"`
Status string `json:"status"` // "online"|"warn"|"offline"|"unreachable"
Health int `json:"health"`
+ // LatencyMs (#1384): latencia sondeada de la unidad (ping unidad→gateway
+ // en no-gateway; latencia WAN en el gateway). Null cuando la fuente no la
+ // sondea (agent-only/external/SNMP) - la tabla de enlaces pinta "—".
+ LatencyMs *float64 `json:"latencyMs,omitempty"`
// AccessMissing: el router responde pero el acceso SSH/ubus no está
// configurado (clave del servidor no autorizada) — issue #257. La UI lo
// pinta como "sin acceso" en vez de "offline" (config issue, no power).
|