diff --git a/agent/probe/probe.go b/agent/probe/probe.go index 0d479e47..77fc94a1 100644 --- a/agent/probe/probe.go +++ b/agent/probe/probe.go @@ -526,7 +526,8 @@ var pingRTTRe = regexp.MustCompile(`= [\d.]+/([\d.]+)(?:/[\d.]+){1,2} ms`) func ParsePingSummary(out string) (latency, loss *float64) { if m := pingRTTRe.FindStringSubmatch(out); m != nil { v, _ := strconv.ParseFloat(m[1], 64) - v = float64(int(v + 0.5)) + // #1384: sin redondeo a entero - en LAN el ping es <1 ms y el + // redondeo lo dejaba en 0 (ruido). El formateo es cosa de la UI. latency = &v } if m := pingLossRe.FindStringSubmatch(out); m != nil { diff --git a/agent/probe/probe_test.go b/agent/probe/probe_test.go index 2d6113ab..0d7c03f5 100644 --- a/agent/probe/probe_test.go +++ b/agent/probe/probe_test.go @@ -188,16 +188,20 @@ func TestBuildEthPortsSkipsDsaConduit(t *testing.T) { func TestParsePingSummary(t *testing.T) { out := "3 packets transmitted, 3 received, 0% packet loss, time 2003ms\nrtt min/avg/max/mdev = 8.123/9.456/10.999/0.5 ms" lat, loss := ParsePingSummary(out) - if lat == nil || *lat != 9 || loss == nil || *loss != 0 { + if lat == nil || *lat != 9.456 || loss == nil || *loss != 0 { t.Fatalf("ping: %v %v", lat, loss) } // #1281: busybox (OpenWrt) imprime min/avg/max SIN mdev. - if lat, _ := ParsePingSummary("3 packets transmitted, 3 packets received, 0% packet loss\nround-trip min/avg/max = 6.196/6.326/6.527 ms"); lat == nil || *lat != 6 { - t.Errorf("busybox rtt: lat = %v, want 6", lat) + if lat, _ := ParsePingSummary("3 packets transmitted, 3 packets received, 0% packet loss\nround-trip min/avg/max = 6.196/6.326/6.527 ms"); lat == nil || *lat != 6.326 { + t.Errorf("busybox rtt: lat = %v, want 6.326", lat) } - if lat, loss := ParsePingSummary("round-trip min/avg/max = 10.5/20.4/30.6 ms\n3 packets transmitted, 3 received, 50% packet loss"); lat == nil || *lat != 20 || loss == nil || *loss != 50 { + if lat, loss := ParsePingSummary("round-trip min/avg/max = 10.5/20.4/30.6 ms\n3 packets transmitted, 3 received, 50% packet loss"); lat == nil || *lat != 20.4 || loss == nil || *loss != 50 { t.Errorf("busybox rtt+loss: lat=%v loss=%v", lat, loss) } + // #1384: sub-ms LAN ping must survive (0.4 ms, antes redondeado a 0). + if lat, _ := ParsePingSummary("round-trip min/avg/max = 0.3/0.4/0.6 ms"); lat == nil || *lat != 0.4 { + t.Errorf("sub-ms rtt: lat = %v, want 0.4", lat) + } if lat, _ := ParsePingSummary("2 packets transmitted, 0 received, 100% packet loss"); lat != nil { t.Fatalf("sin rtt → nil: %v", lat) } diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json index c21828c4..a8d2fa55 100644 --- a/app/public/locales/en/translation.json +++ b/app/public/locales/en/translation.json @@ -1195,6 +1195,7 @@ "links": { "colLink": "Link", "colSpeed": "Speed", + "colPing": "Ping", "congestedChannel": "Congested channel", "fiberWan": "WAN fiber", "hypervisorCable": "Hypervisor cable", diff --git a/app/public/locales/es/translation.json b/app/public/locales/es/translation.json index bb3920e0..70c85893 100644 --- a/app/public/locales/es/translation.json +++ b/app/public/locales/es/translation.json @@ -1195,6 +1195,7 @@ "links": { "colLink": "Enlace", "colSpeed": "Velocidad", + "colPing": "Ping", "congestedChannel": "Canal congestionado", "fiberWan": "Fibra WAN", "hypervisorCable": "Cable de hipervisor", diff --git a/app/src/components/topology/LegendCard.tsx b/app/src/components/topology/LegendCard.tsx index f0d1877c..05ccaabc 100644 --- a/app/src/components/topology/LegendCard.tsx +++ b/app/src/components/topology/LegendCard.tsx @@ -108,7 +108,7 @@ function WgSample() { ) } -export function LegendContent({ model, compact = false }: { model: TopologyModel; compact?: boolean }) { +export function LegendContent({ model, compact = false, horizontal = false }: { model: TopologyModel; compact?: boolean; horizontal?: boolean }) { const { t } = useTranslation() const reduce = useReducedMotion() const { deviceTotals, routers, devices } = useNetPulse() @@ -126,7 +126,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel } let i = 0 return ( -
+
{t('topology.legend.bandConnection')}
    @@ -142,9 +142,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
-
+
{t('topology.links.title')}
-
    +
      {t('topology.legend.wiredLink')} @@ -173,7 +173,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
{t('routers.colStatus')}
-
    +
      {STATUS_ROWS.map((r) => ( @@ -182,7 +182,10 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel ))}
- + {t('topology.legend.summary', { online: deviceTotals.online, peers: activePeerCount, links: activeLinkCount })}
@@ -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).