Skip to content

Commit ab5e32a

Browse files
committed
feat(topology): full-width legend and links table with ping column (#1384)
The legend and the links table under the map sat side by side (5/7 cols). Both are now full width and stacked: - Legend: its sections (bands, link styles, status, summary) lay out as one horizontal row of columns at lg; the mobile bottom sheet keeps the stacked layout. - Links table: new ping column showing the destination unit's probed latency. The server already polls router->gateway latency on non-gateway units (and WAN latency on the gateway) but only persisted it to metrics; the overview now exposes it per router as latencyMs. Units without a probe (agent-only/external/SNMP) show -. The WAN row moves its latency from the signal column to the ping column. Closes #1384
1 parent 88e1e68 commit ab5e32a

11 files changed

Lines changed: 57 additions & 23 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
@@ -1195,6 +1195,7 @@
11951195
"links": {
11961196
"colLink": "Link",
11971197
"colSpeed": "Speed",
1198+
"colPing": "Ping",
11981199
"congestedChannel": "Congested channel",
11991200
"fiberWan": "WAN fiber",
12001201
"hypervisorCable": "Hypervisor cable",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,7 @@
11951195
"links": {
11961196
"colLink": "Enlace",
11971197
"colSpeed": "Velocidad",
1198+
"colPing": "Ping",
11981199
"congestedChannel": "Canal congestionado",
11991200
"fiberWan": "Fibra WAN",
12001201
"hypervisorCable": "Cable de hipervisor",

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

Lines changed: 9 additions & 6 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-2 gap-x-6 gap-y-4 xl:grid-cols-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">
@@ -144,7 +144,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
144144
</div>
145145
<div>
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-2' : '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('text-caption text-text-muted', horizontal ? 'self-center xl:pl-6 xl:border-l xl:border-border' : 'border-t border-border pt-3')}
187+
{...row(i++)}
188+
>
186189
{t('topology.legend.summary', { online: deviceTotals.online, peers: activePeerCount, links: activeLinkCount })}
187190
</motion.p>
188191
</div>
@@ -198,7 +201,7 @@ export function LegendCard({ model }: { model: TopologyModel }) {
198201
return (
199202
<section className="rounded-2xl border border-border bg-surface p-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: 16 additions & 5 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
@@ -1639,10 +1641,18 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16391641
}
16401642
const uplinkSpeed = (nodeId: string): string =>
16411643
linkSpeed(uplinkSemByNodeId.get(nodeId)?.speedMbps)
1644+
// #1384: ping del enlace = latencia sondeada del equipo destino (ms, un
1645+
// decimal bajo 10 ms para que se aprecien las LAN); "—" sin sonda.
1646+
const fmtPing = (ms?: number): string => {
1647+
if (ms == null || ms < 0) return '—'
1648+
if (ms === 0) return '<1 ms'
1649+
return ms < 10 ? `${ms.toFixed(1)} ms` : `${Math.round(ms)} ms`
1650+
}
16421651
if (gatewayNode) {
16431652
backhauls.push({
16441653
id: 'wan', a: gatewayNode.router.name, b: 'Internet', kind: 'wan',
1645-
type: 'topology.links.fiberWan', speed: wan.plan, signal: `${wan.latencyMs} ms`,
1654+
type: 'topology.links.fiberWan', speed: wan.plan,
1655+
ping: fmtPing(wan.latencyMs), signal: '—',
16461656
tone: 'ok', statusLabel: 'common.status.online',
16471657
spark: gatewayNode.router.sparkline, sparkColor: COLOR.accent,
16481658
})
@@ -1657,6 +1667,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16571667
// boca (#1321); "—" si nadie la midió. El tono sí es información
16581668
// real: un enlace por wifi merece mirarse más que uno por cable.
16591669
speed: uplinkSpeed(node.id),
1670+
ping: fmtPing(node.router.latencyMs),
16601671
signal: '—',
16611672
tone: isWifi ? 'warn' : 'ok',
16621673
statusLabel: isWifi ? 'common.status.warn' : 'common.status.online',
@@ -1680,7 +1691,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16801691
id: `dist-${dv.id}`, a,
16811692
b: [dv.node.name, dv.node.ip, via, dv.node.portLabel ?? dv.node.port].filter(Boolean).join(' · '),
16821693
kind: 'dist', type: dv.node.role === 'ap' ? 'topology.links.managedAp' : 'topology.links.managedSwitch',
1683-
speed: linkSpeed(dv.node.speedMbps), signal: '—',
1694+
speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
16841695
tone: 'ok', statusLabel: 'common.status.online',
16851696
spark, sparkColor: COLOR.accent,
16861697
})
@@ -1689,7 +1700,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
16891700
id: `dist-${dv.id}`, a,
16901701
b: '', bKey: 'topology.links.inferredSwitch', bVars: { port: dv.node.portLabel ?? dv.node.port },
16911702
kind: 'dist', type: 'common.cable',
1692-
speed: linkSpeed(dv.node.speedMbps), signal: '—',
1703+
speed: linkSpeed(dv.node.speedMbps), ping: fmtPing(rn?.router.latencyMs), signal: '—',
16931704
tone: 'ok', statusLabel: 'common.status.online',
16941705
spark, sparkColor: COLOR.ok,
16951706
})
@@ -1707,7 +1718,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
17071718
id: `wired-${host.id}`, a: hubNameOf(host),
17081719
b: [host.name, port, `${ctCountByHost.get(host.id) ?? 0} CT`].filter(Boolean).join(' · '),
17091720
kind: 'wired', type: 'topology.links.hypervisorCable',
1710-
speed: linkSpeed(host.speedMbps), signal: '—',
1721+
speed: linkSpeed(host.speedMbps), ping: '—', signal: '—',
17111722
tone: 'ok', statusLabel: 'common.status.online',
17121723
spark: host.sparkline, sparkColor: COLOR.ok,
17131724
})
@@ -1717,7 +1728,7 @@ export function buildTopologyModel({ routers, devices, wan, wireguard, distribut
17171728
const device = devices.find((d) => d.id === node.peer.id)
17181729
backhauls.push({
17191730
id: `wg-${node.peer.id}`, a: 'Internet', b: node.peer.name, kind: 'wg',
1720-
type: 'WireGuard', speed: '—', signal: '—',
1731+
type: 'WireGuard', speed: '—', ping: '—', signal: '—',
17211732
tone: 'tunnel', statusLabel: 'common.active',
17221733
spark: device?.sparkline ?? [0, 0], sparkColor: COLOR.tunnel,
17231734
})

‎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: 1 addition & 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)

0 commit comments

Comments
 (0)