Skip to content

Commit a4d7585

Browse files
authored
feat(topology): full-width legend and links table with ping column (#1387)
Legend and links table go full width under the map: legend sections in one row with three-column link samples and a full-width summary; links table gains a ping column (per-router latency now exposed in the overview), lists managed-switch uplinks, and hypervisor cables get their parent-measured speed. Ping parser no longer rounds to integer.\n\nCloses #1384
1 parent ea2bbda commit a4d7585

13 files changed

Lines changed: 173 additions & 37 deletions

File tree

‎agent/probe/probe.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,8 @@ var pingRTTRe = regexp.MustCompile(`= [\d.]+/([\d.]+)(?:/[\d.]+){1,2} ms`)
526526
func ParsePingSummary(out string) (latency, loss *float64) {
527527
if m := pingRTTRe.FindStringSubmatch(out); m != nil {
528528
v, _ := strconv.ParseFloat(m[1], 64)
529-
v = float64(int(v + 0.5))
529+
// #1384: sin redondeo a entero - en LAN el ping es <1 ms y el
530+
// redondeo lo dejaba en 0 (ruido). El formateo es cosa de la UI.
530531
latency = &v
531532
}
532533
if m := pingLossRe.FindStringSubmatch(out); m != nil {

‎agent/probe/probe_test.go‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,16 +188,20 @@ func TestBuildEthPortsSkipsDsaConduit(t *testing.T) {
188188
func TestParsePingSummary(t *testing.T) {
189189
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"
190190
lat, loss := ParsePingSummary(out)
191-
if lat == nil || *lat != 9 || loss == nil || *loss != 0 {
191+
if lat == nil || *lat != 9.456 || loss == nil || *loss != 0 {
192192
t.Fatalf("ping: %v %v", lat, loss)
193193
}
194194
// #1281: busybox (OpenWrt) imprime min/avg/max SIN mdev.
195-
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 {
196-
t.Errorf("busybox rtt: lat = %v, want 6", lat)
195+
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 {
196+
t.Errorf("busybox rtt: lat = %v, want 6.326", lat)
197197
}
198-
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 {
198+
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 {
199199
t.Errorf("busybox rtt+loss: lat=%v loss=%v", lat, loss)
200200
}
201+
// #1384: sub-ms LAN ping must survive (0.4 ms, antes redondeado a 0).
202+
if lat, _ := ParsePingSummary("round-trip min/avg/max = 0.3/0.4/0.6 ms"); lat == nil || *lat != 0.4 {
203+
t.Errorf("sub-ms rtt: lat = %v, want 0.4", lat)
204+
}
201205
if lat, _ := ParsePingSummary("2 packets transmitted, 0 received, 100% packet loss"); lat != nil {
202206
t.Fatalf("sin rtt → nil: %v", lat)
203207
}

‎app/public/locales/en/translation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,7 @@
12041204
"links": {
12051205
"colLink": "Link",
12061206
"colSpeed": "Speed",
1207+
"colPing": "Ping",
12071208
"congestedChannel": "Congested channel",
12081209
"fiberWan": "WAN fiber",
12091210
"hypervisorCable": "Hypervisor cable",

‎app/public/locales/es/translation.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,7 @@
12041204
"links": {
12051205
"colLink": "Enlace",
12061206
"colSpeed": "Velocidad",
1207+
"colPing": "Ping",
12071208
"congestedChannel": "Canal congestionado",
12081209
"fiberWan": "Fibra WAN",
12091210
"hypervisorCable": "Cable de hipervisor",

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ function WgSample() {
108108
)
109109
}
110110

111-
export function LegendContent({ model, compact = false }: { model: TopologyModel; compact?: boolean }) {
111+
export function LegendContent({ model, compact = false, horizontal = false }: { model: TopologyModel; compact?: boolean; horizontal?: boolean }) {
112112
const { t } = useTranslation()
113113
const reduce = useReducedMotion()
114114
const { deviceTotals, routers, devices } = useNetPulse()
@@ -126,7 +126,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
126126
}
127127
let i = 0
128128
return (
129-
<div className={cn('space-y-4', compact && 'space-y-3')}>
129+
<div className={cn(horizontal ? 'grid grid-cols-4 gap-x-[50px] gap-y-4' : 'space-y-4', compact && 'space-y-3')}>
130130
<div>
131131
<div className="mb-2 text-label uppercase text-text-muted">{t('topology.legend.bandConnection')}</div>
132132
<ul className="grid grid-cols-2 gap-x-4 gap-y-2">
@@ -142,9 +142,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
142142
</motion.li>
143143
</ul>
144144
</div>
145-
<div>
145+
<div className={cn(horizontal && 'col-span-2')}>
146146
<div className="mb-2 text-label uppercase text-text-muted">{t('topology.links.title')}</div>
147-
<ul className="grid grid-cols-1 gap-x-4 gap-y-2">
147+
<ul className={cn('grid gap-x-4 gap-y-2', horizontal ? 'grid-cols-3' : 'grid-cols-1')}>
148148
<motion.li className="flex items-center gap-2 text-sm text-text-secondary" {...row(i++)}>
149149
<LineSample color={COLOR.ok} />
150150
{t('topology.legend.wiredLink')}
@@ -173,7 +173,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
173173
</div>
174174
<div>
175175
<div className="mb-2 text-label uppercase text-text-muted">{t('routers.colStatus')}</div>
176-
<ul className="grid grid-cols-3 gap-x-4 gap-y-2">
176+
<ul className={cn('grid gap-x-4 gap-y-2', horizontal ? 'grid-cols-1' : 'grid-cols-3')}>
177177
{STATUS_ROWS.map((r) => (
178178
<motion.li key={r.labelKey} className="flex items-center gap-2 text-sm text-text-secondary" {...row(i++)}>
179179
<RingSample color={r.color} />
@@ -182,7 +182,10 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
182182
))}
183183
</ul>
184184
</div>
185-
<motion.p className="border-t border-border pt-3 text-caption text-text-muted" {...row(i++)}>
185+
<motion.p
186+
className={cn('border-t border-border pt-3 text-caption text-text-muted', horizontal && 'col-span-full')}
187+
{...row(i++)}
188+
>
186189
{t('topology.legend.summary', { online: deviceTotals.online, peers: activePeerCount, links: activeLinkCount })}
187190
</motion.p>
188191
</div>
@@ -196,9 +199,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
196199
export function LegendCard({ model }: { model: TopologyModel }) {
197200
const { t } = useTranslation()
198201
return (
199-
<section className="rounded-2xl border border-border bg-surface p-5">
202+
<section className="rounded-2xl border border-border bg-surface px-[50px] py-5">
200203
<h2 className="mb-4 font-display text-h2 text-text-primary">{t('topology.legend.title')}</h2>
201-
<LegendContent model={model} />
204+
<LegendContent model={model} horizontal />
202205
</section>
203206
)
204207
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
126126
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colLink')}</th>
127127
<th scope="col" className="pb-2 pr-4 font-medium">{t('devices.colType')}</th>
128128
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colSpeed')}</th>
129+
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colPing')}</th>
129130
<th scope="col" className="pb-2 pr-4 font-medium">{t('devices.colSignal')}</th>
130131
<th scope="col" className="pb-2 pr-4 font-medium">{t('routers.colStatus')}</th>
131132
<th scope="col" className="relative pb-2 font-medium">
@@ -152,6 +153,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
152153
</td>
153154
<td className="py-3 pr-4 whitespace-nowrap text-text-secondary">{t(row.type)}</td>
154155
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-primary">{row.speed}</td>
156+
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-secondary">{row.ping}</td>
155157
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-secondary">{row.signal}</td>
156158
<td className="py-3 pr-4 whitespace-nowrap">
157159
<StatusPill tone={row.tone} label={t(row.statusLabel)} pulse={row.tone !== 'ok'} />
@@ -186,7 +188,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
186188
<div className="space-y-0.5 text-caption text-text-secondary">
187189
<div>
188190
{t(row.type)} · <span className="font-mono text-text-primary">{row.speed}</span> ·{' '}
189-
<span className="font-mono">{row.signal}</span>
191+
<span className="font-mono">{row.ping}</span> · <span className="font-mono">{row.signal}</span>
190192
</div>
191193
{row.note && <div className="text-warn"><LinkNote note={row.note} /></div>}
192194
</div>

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export interface BackhaulRow {
103103
kind: TopoLinkKind
104104
type: string
105105
speed: string
106+
/** Latencia del extremo destino en ms, formateada (#1384); "—" sin dato. */
107+
ping: string
106108
signal: string
107109
tone: 'ok' | 'warn' | 'tunnel'
108110
statusLabel: string
@@ -1616,9 +1618,15 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16161618
// gateway. Sin semántica o sin match → gateway (comportamiento de siempre,
16171619
// demo incluido).
16181620
const uplinkSemByNodeId = new Map<string, TopoSemLink>()
1621+
// #1384: velocidad del cable de un dispositivo cuando el padre la midió
1622+
// en esa boca (server, fillWiredSpeeds: p. ej. los hipervisores en las
1623+
// bocas a 2,5 G del switch gestionado, aunque el device no traiga
1624+
// speedMbps de ningún controlador).
1625+
const wiredSemSpeedByTo = new Map<string, number>()
16191626
if (sem) {
16201627
for (const sl of sem.links) {
16211628
if (sl.kind === 'uplink') uplinkSemByNodeId.set(sl.to, sl)
1629+
if (sl.kind === 'wired' && sl.speedMbps && sl.speedMbps > 0) wiredSemSpeedByTo.set(sl.to, sl.speedMbps)
16221630
}
16231631
}
16241632
const uplinkParentName = (nodeId: string): string => {
@@ -1639,15 +1647,29 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16391647
}
16401648
const uplinkSpeed = (nodeId: string): string =>
16411649
linkSpeed(uplinkSemByNodeId.get(nodeId)?.speedMbps)
1650+
// #1384: ping del enlace = latencia sondeada del equipo destino (ms, un
1651+
// decimal bajo 10 ms para que se aprecien las LAN); "—" sin sonda.
1652+
const fmtPing = (ms?: number): string => {
1653+
if (ms == null || ms < 0) return '—'
1654+
if (ms === 0) return '<1 ms'
1655+
return ms < 10 ? `${ms.toFixed(1)} ms` : `${Math.round(ms)} ms`
1656+
}
16421657
if (gatewayNode) {
16431658
backhauls.push({
16441659
id: 'wan', a: gatewayNode.router.name, b: 'Internet', kind: 'wan',
1645-
type: 'topology.links.fiberWan', speed: wan.plan, signal: `${wan.latencyMs} ms`,
1660+
type: 'topology.links.fiberWan', speed: wan.plan,
1661+
ping: fmtPing(wan.latencyMs), signal: '—',
16461662
tone: 'ok', statusLabel: 'common.status.online',
16471663
spark: gatewayNode.router.sparkline, sparkColor: COLOR.accent,
16481664
})
16491665
}
1650-
for (const node of apNodes) {
1666+
for (const node of [...apNodes, ...switchNodes]) {
1667+
// #1384: el uplink lo lista TODA unidad de flota no-gateway, no solo los
1668+
// APs: el switch gestionado (roleBadge SW) también cuelga del gateway y
1669+
// su enlace (p. ej. Flint2 <-> KP-9000 a 2,5 Gbps) estaba en el mapa y
1670+
// en la semántica pero no en la tabla. No hay duplicado con el bucle
1671+
// 'dist' de abajo: la sombra distnode del switch se absorbe server-side
1672+
// (#1374/#1376) y no llega en distributionNodes.
16511673
const isWifi = node.router.backhaul === 'wifi'
16521674
backhauls.push({
16531675
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
16571679
// boca (#1321); "—" si nadie la midió. El tono sí es información
16581680
// real: un enlace por wifi merece mirarse más que uno por cable.
16591681
speed: uplinkSpeed(node.id),
1682+
ping: fmtPing(node.router.latencyMs),
16601683
signal: '—',
16611684
tone: isWifi ? 'warn' : 'ok',
16621685
statusLabel: isWifi ? 'common.status.warn' : 'common.status.online',
@@ -1680,7 +1703,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16801703
id: `dist-${dv.id}`, a,
16811704
b: [dv.node.name, dv.node.ip, via, dv.node.portLabel ?? dv.node.port].filter(Boolean).join(' · '),
16821705
kind: 'dist', type: dv.node.role === 'ap' ? 'topology.links.managedAp' : 'topology.links.managedSwitch',
1683-
speed: linkSpeed(dv.node.speedMbps), signal: '—',
1706+
speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
16841707
tone: 'ok', statusLabel: 'common.status.online',
16851708
spark, sparkColor: COLOR.accent,
16861709
})
@@ -1689,7 +1712,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16891712
id: `dist-${dv.id}`, a,
16901713
b: '', bKey: 'topology.links.inferredSwitch', bVars: { port: dv.node.portLabel ?? dv.node.port },
16911714
kind: 'dist', type: 'common.cable',
1692-
speed: linkSpeed(dv.node.speedMbps), signal: '—',
1715+
speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
16931716
tone: 'ok', statusLabel: 'common.status.online',
16941717
spark, sparkColor: COLOR.ok,
16951718
})
@@ -1707,7 +1730,10 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
17071730
id: `wired-${host.id}`, a: hubNameOf(host),
17081731
b: [host.name, port, `${ctCountByHost.get(host.id) ?? 0} CT`].filter(Boolean).join(' · '),
17091732
kind: 'wired', type: 'topology.links.hypervisorCable',
1710-
speed: linkSpeed(host.speedMbps), signal: '—',
1733+
// #1384: primero la boca medida por el padre en este sondeo (sem
1734+
// wired, fillWiredSpeeds); si no, el sello del controlador en el
1735+
// device; si ninguno, "—" honesta.
1736+
speed: linkSpeed(wiredSemSpeedByTo.get(host.id) ?? host.speedMbps), ping: '—', signal: '—',
17111737
tone: 'ok', statusLabel: 'common.status.online',
17121738
spark: host.sparkline, sparkColor: COLOR.ok,
17131739
})
@@ -1717,7 +1743,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
17171743
const device = devices.find((d) => d.id === node.peer.id)
17181744
backhauls.push({
17191745
id: `wg-${node.peer.id}`, a: 'Internet', b: node.peer.name, kind: 'wg',
1720-
type: 'WireGuard', speed: '—', signal: '—',
1746+
type: 'WireGuard', speed: '—', ping: '—', signal: '—',
17211747
tone: 'tunnel', statusLabel: 'common.active',
17221748
spark: device?.sparkline ?? [0, 0], sparkColor: COLOR.tunnel,
17231749
})

‎app/src/data/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export interface Router {
8585
lldp?: LldpInfo | null
8686
/** Sparkline de tráfico 24h (Mbps, 24 puntos) */
8787
sparkline: number[]
88+
/**
89+
* Latencia sondeada de la unidad en ms (#1384): ping unidad→gateway en
90+
* no-gateway, latencia WAN en el gateway. Ausente si la fuente no la
91+
* sondea (agent-only/external/SNMP): la tabla de enlaces pinta "—".
92+
*/
93+
latencyMs?: number
8894
}
8995

9096
export interface WanInfo {

‎app/src/pages/Topology.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,14 @@ export default function Topology() {
357357

358358

359359

360-
{/* ③ + ④ Leyenda y enlaces (desktop: 5/7 cols; móvil: solo enlaces en lista) */}
361-
<div className="grid grid-cols-1 gap-4 md:gap-5 lg:grid-cols-12">
362-
<div className="hidden lg:col-span-5 lg:block">
360+
{/* ③ + ④ Leyenda y enlaces (#1384): ambas a todo lo ancho, apiladas;
361+
la leyenda va en una sola fila de columnas (desktop; en móvil solo
362+
la lista de enlaces, la leyenda vive en el sheet del mapa). */}
363+
<div className="grid grid-cols-1 gap-4 md:gap-5">
364+
<div className="hidden lg:block">
363365
<LegendCard model={model} />
364366
</div>
365-
<div className="lg:col-span-7">
366-
<LinksTable model={model} hoverLink={hoverLink} onHoverLink={setHoverLink} />
367-
</div>
367+
<LinksTable model={model} hoverLink={hoverLink} onHoverLink={setHoverLink} />
368368
</div>
369369

370370
{/* Etiquetado manual de hardware (issue #142 Fase B): clic en un chip

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,7 @@ func (l *Live) buildRouter(p *routerPolled, history []histPoint) Router {
13391339
CPU: iptr(p.cpu), RAM: iptr(p.ram), Temp: iptr(p.temp),
13401340
Uptime: fmtUptime(p.uptimeSec), Clients: len(p.leases),
13411341
Sparkline: sparkline, TempThreshold: thr,
1342+
LatencyMs: p.latencyMs,
13421343
}
13431344
if noVitals {
13441345
r.VitalsAvailable = bptr(false)
@@ -3442,6 +3443,7 @@ func (l *Live) buildOverview(ctx context.Context) (*Overview, error) {
34423443
distNodes = filtered
34433444
}
34443445
fillUplinkSpeeds(polled, distNodes, topo.Links)
3446+
fillWiredSpeeds(polled, distNodes, topo.Links) // #1384
34453447
return &Overview{
34463448
Health: computeHealth(routerList, adguard),
34473449
WAN: wan,

0 commit comments

Comments
 (0)