Skip to content
Merged
3 changes: 2 additions & 1 deletion agent/probe/probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions agent/probe/probe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions app/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,7 @@
"links": {
"colLink": "Link",
"colSpeed": "Speed",
"colPing": "Ping",
"congestedChannel": "Congested channel",
"fiberWan": "WAN fiber",
"hypervisorCable": "Hypervisor cable",
Expand Down
1 change: 1 addition & 0 deletions app/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,7 @@
"links": {
"colLink": "Enlace",
"colSpeed": "Velocidad",
"colPing": "Ping",
"congestedChannel": "Canal congestionado",
"fiberWan": "Fibra WAN",
"hypervisorCable": "Cable de hipervisor",
Expand Down
19 changes: 11 additions & 8 deletions app/src/components/topology/LegendCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -126,7 +126,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
}
let i = 0
return (
<div className={cn('space-y-4', compact && 'space-y-3')}>
<div className={cn(horizontal ? 'grid grid-cols-4 gap-x-[50px] gap-y-4' : 'space-y-4', compact && 'space-y-3')}>
<div>
<div className="mb-2 text-label uppercase text-text-muted">{t('topology.legend.bandConnection')}</div>
<ul className="grid grid-cols-2 gap-x-4 gap-y-2">
Expand All @@ -142,9 +142,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
</motion.li>
</ul>
</div>
<div>
<div className={cn(horizontal && 'col-span-2')}>
<div className="mb-2 text-label uppercase text-text-muted">{t('topology.links.title')}</div>
<ul className="grid grid-cols-1 gap-x-4 gap-y-2">
<ul className={cn('grid gap-x-4 gap-y-2', horizontal ? 'grid-cols-3' : 'grid-cols-1')}>
<motion.li className="flex items-center gap-2 text-sm text-text-secondary" {...row(i++)}>
<LineSample color={COLOR.ok} />
{t('topology.legend.wiredLink')}
Expand Down Expand Up @@ -173,7 +173,7 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
</div>
<div>
<div className="mb-2 text-label uppercase text-text-muted">{t('routers.colStatus')}</div>
<ul className="grid grid-cols-3 gap-x-4 gap-y-2">
<ul className={cn('grid gap-x-4 gap-y-2', horizontal ? 'grid-cols-1' : 'grid-cols-3')}>
{STATUS_ROWS.map((r) => (
<motion.li key={r.labelKey} className="flex items-center gap-2 text-sm text-text-secondary" {...row(i++)}>
<RingSample color={r.color} />
Expand All @@ -182,7 +182,10 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
))}
</ul>
</div>
<motion.p className="border-t border-border pt-3 text-caption text-text-muted" {...row(i++)}>
<motion.p
className={cn('border-t border-border pt-3 text-caption text-text-muted', horizontal && 'col-span-full')}
{...row(i++)}
>
{t('topology.legend.summary', { online: deviceTotals.online, peers: activePeerCount, links: activeLinkCount })}
</motion.p>
</div>
Expand All @@ -196,9 +199,9 @@ export function LegendContent({ model, compact = false }: { model: TopologyModel
export function LegendCard({ model }: { model: TopologyModel }) {
const { t } = useTranslation()
return (
<section className="rounded-2xl border border-border bg-surface p-5">
<section className="rounded-2xl border border-border bg-surface px-[50px] py-5">
<h2 className="mb-4 font-display text-h2 text-text-primary">{t('topology.legend.title')}</h2>
<LegendContent model={model} />
<LegendContent model={model} horizontal />
</section>
)
}
Expand Down
4 changes: 3 additions & 1 deletion app/src/components/topology/LinksTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colLink')}</th>
<th scope="col" className="pb-2 pr-4 font-medium">{t('devices.colType')}</th>
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colSpeed')}</th>
<th scope="col" className="pb-2 pr-4 font-medium">{t('topology.links.colPing')}</th>
<th scope="col" className="pb-2 pr-4 font-medium">{t('devices.colSignal')}</th>
<th scope="col" className="pb-2 pr-4 font-medium">{t('routers.colStatus')}</th>
<th scope="col" className="relative pb-2 font-medium">
Expand All @@ -152,6 +153,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
</td>
<td className="py-3 pr-4 whitespace-nowrap text-text-secondary">{t(row.type)}</td>
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-primary">{row.speed}</td>
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-secondary">{row.ping}</td>
<td className="py-3 pr-4 whitespace-nowrap font-mono text-mono-sm text-text-secondary">{row.signal}</td>
<td className="py-3 pr-4 whitespace-nowrap">
<StatusPill tone={row.tone} label={t(row.statusLabel)} pulse={row.tone !== 'ok'} />
Expand Down Expand Up @@ -186,7 +188,7 @@ export function LinksTable({ model, hoverLink, onHoverLink }: LinksTableProps) {
<div className="space-y-0.5 text-caption text-text-secondary">
<div>
{t(row.type)} · <span className="font-mono text-text-primary">{row.speed}</span> ·{' '}
<span className="font-mono">{row.signal}</span>
<span className="font-mono">{row.ping}</span> · <span className="font-mono">{row.signal}</span>
</div>
{row.note && <div className="text-warn"><LinkNote note={row.note} /></div>}
</div>
Expand Down
38 changes: 32 additions & 6 deletions app/src/components/topology/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, TopoSemLink>()
// #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<string, number>()
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 => {
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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,
})
Expand All @@ -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,
})
Expand All @@ -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,
})
Expand All @@ -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,
})
Expand Down
6 changes: 6 additions & 0 deletions app/src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions app/src/pages/Topology.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,14 +357,14 @@ export default function Topology() {



{/* ③ + ④ Leyenda y enlaces (desktop: 5/7 cols; móvil: solo enlaces en lista) */}
<div className="grid grid-cols-1 gap-4 md:gap-5 lg:grid-cols-12">
<div className="hidden lg:col-span-5 lg:block">
{/* ③ + ④ 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). */}
<div className="grid grid-cols-1 gap-4 md:gap-5">
<div className="hidden lg:block">
<LegendCard model={model} />
</div>
<div className="lg:col-span-7">
<LinksTable model={model} hoverLink={hoverLink} onHoverLink={setHoverLink} />
</div>
<LinksTable model={model} hoverLink={hoverLink} onHoverLink={setHoverLink} />
</div>

{/* Etiquetado manual de hardware (issue #142 Fase B): clic en un chip
Expand Down
2 changes: 2 additions & 0 deletions server-go/internal/adapters/live.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 54 additions & 11 deletions server-go/internal/adapters/topology.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 == "" {
Expand All @@ -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
}
Expand Down
Loading
Loading