Skip to content

Commit b215b23

Browse files
authored
feat(snmp): group virtual interfaces instead of hiding them (#1142)
Follow-up to #1115, requested in #1125. Dropping LAG/VLAN/bridge/tunnel interfaces from the ports card hid information and broke the mental model of the switch: FDB and LLDP entries referencing their ifIndex resolved to generated names, and an AP-only view of a switch with LAGs was incomplete. The ifTable walk now keeps every interface and tags the non-physical ones with their family (lag, vlan, bridge, tunnel, virtual). The ports card still renders only physical jacks in the chassis, with a collapsed-by-default "Virtual interfaces (N)" group below (state persisted), each row labeled by family. FDB/LLDP resolution now names virtual ports properly instead of falling back to port-N. The exclusion-based defense for failed walks is preserved: an unreported ifType stays in the physical group. Verified against the real Linksys LGS310C: 10 physical jacks plus po1-8 (LAG) and vlan1 (VLAN) listed in the collapsed group. Full suite 48/48. Closes #1125
1 parent 5275f83 commit b215b23

8 files changed

Lines changed: 141 additions & 53 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,13 @@
874874
"detailRouterLinkLldp": "link between routers · LLDP",
875875
"detailApWifi": "WiFi client behind",
876876
"detailMac": "MAC {{mac}}",
877-
"inferredSwitch": "Switch"
877+
"inferredSwitch": "Switch",
878+
"virtualGroup": "Virtual interfaces ({{count}})",
879+
"fam_lag": "LAG",
880+
"fam_vlan": "VLAN",
881+
"fam_bridge": "Bridge",
882+
"fam_tunnel": "Tunnel",
883+
"fam_virtual": "Virtual"
878884
},
879885
"radios": {
880886
"channel": "Channel",

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,13 @@
874874
"detailRouterLinkLldp": "enlace entre routers · LLDP",
875875
"detailApWifi": "cliente WiFi detrás",
876876
"detailMac": "MAC {{mac}}",
877-
"inferredSwitch": "Switch"
877+
"inferredSwitch": "Switch",
878+
"virtualGroup": "Interfaces virtuales ({{count}})",
879+
"fam_lag": "LAG",
880+
"fam_vlan": "VLAN",
881+
"fam_bridge": "Bridge",
882+
"fam_tunnel": "Túnel",
883+
"fam_virtual": "Virtual"
878884
},
879885
"radios": {
880886
"channel": "Canal",

‎app/src/components/routers/PortPanel.tsx‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { motion, useReducedMotion } from 'framer-motion'
2+
import { useState } from 'react'
23
import { useTranslation } from 'react-i18next'
34
import { Link } from 'react-router'
45
import type { Router } from '@/data/mock'
@@ -7,7 +8,7 @@ import { SectionHeader } from '@/components/SectionHeader'
78
import { getRouterExtras, portPeerDetail, portPeerLabel } from '@/components/routers/routerExtras'
89
import type { EthPort, RouterExtras, SfpInfo } from '@/components/routers/routerExtras'
910
import { EMPTY_EXTRAS, useNetPulse } from '@/data/DataProvider'
10-
import { Activity, Network } from 'lucide-react'
11+
import { Activity, ChevronDown, ChevronRight, Network } from 'lucide-react'
1112
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
1213
import { fmtTemp, useTempUnit } from '@/lib/temperature'
1314
import { cn } from '@/lib/utils'
@@ -287,6 +288,17 @@ function MiniStat({ label, value }: { label: string; value: string }) {
287288
)
288289
}
289290

291+
// FAMILY_LABEL_KEY (#1125): familia ifType (IANA) -> clave i18n de la
292+
// etiqueta del grupo de interfaces virtuales. Cerrado: el server solo emite
293+
// estas cinco ("lag"|"vlan"|"bridge"|"tunnel"|"virtual").
294+
const FAMILY_LABEL_KEY: Record<string, string> = {
295+
lag: 'routerDetail.ports.fam_lag',
296+
vlan: 'routerDetail.ports.fam_vlan',
297+
bridge: 'routerDetail.ports.fam_bridge',
298+
tunnel: 'routerDetail.ports.fam_tunnel',
299+
virtual: 'routerDetail.ports.fam_virtual',
300+
}
301+
290302
export function PortPanel({ router, extras, className, snmpStats }: {
291303
router: Router
292304
extras?: RouterExtras
@@ -296,7 +308,18 @@ export function PortPanel({ router, extras, className, snmpStats }: {
296308
const { t } = useTranslation()
297309
const { isDemo, wan } = useNetPulse()
298310
const ex = extras ?? (isDemo ? getRouterExtras(router.id) : EMPTY_EXTRAS)
299-
const ports = ex.ethPorts
311+
// #1125: las interfaces no físicas (LAG/VLAN/bridge/túnel/virtual) ya no se
312+
// descartan (#1115): se listan en la sección colapsada de abajo. El chasis
313+
// y el contador usados/total solo cuentan bocas físicas.
314+
const ports = ex.ethPorts.filter((p) => !p.family)
315+
const virtualPorts = ex.ethPorts.filter((p) => p.family)
316+
const [virtualOpen, setVirtualOpen] = useState(() => localStorage.getItem('np-ports-virtual-open') === '1')
317+
const toggleVirtual = () =>
318+
setVirtualOpen((v) => {
319+
const next = !v
320+
localStorage.setItem('np-ports-virtual-open', next ? '1' : '0')
321+
return next
322+
})
300323
const used = ports.filter((p) => p.up).length
301324
const wirelessUplink = ex.backhaul?.kind === 'wireless'
302325

@@ -358,6 +381,47 @@ export function PortPanel({ router, extras, className, snmpStats }: {
358381
</span>
359382
)}
360383
</div>
384+
385+
{/* #1125: las interfaces no físicas (LAG/VLAN/bridge/túnel) se listan
386+
aquí, agrupadas y colapsadas por defecto, en vez de desaparecer de
387+
la tarjeta (#1115): nada se oculta y el chasis queda limpio. El
388+
estado plegado se recuerda. */}
389+
{virtualPorts.length > 0 && (
390+
<div className="mt-4 rounded-xl border border-border/70 bg-elevated/40">
391+
<button
392+
type="button"
393+
onClick={toggleVirtual}
394+
aria-expanded={virtualOpen}
395+
className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-caption font-medium text-text-secondary transition-colors hover:text-text-primary"
396+
>
397+
{virtualOpen ? (
398+
<ChevronDown className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
399+
) : (
400+
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
401+
)}
402+
{t('routerDetail.ports.virtualGroup', { count: virtualPorts.length })}
403+
</button>
404+
{virtualOpen && (
405+
<ul className="border-t border-border/60 px-4 py-2">
406+
{virtualPorts.map((p) => (
407+
<li key={p.id} className="flex items-center gap-3 py-1.5 text-caption">
408+
<span className="inline-flex w-16 shrink-0 justify-center rounded border border-border px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wide text-text-muted">
409+
{t(FAMILY_LABEL_KEY[p.family ?? ''] ?? 'routerDetail.ports.fam_virtual', { defaultValue: p.family })}
410+
</span>
411+
<span className="truncate font-mono text-mono-sm text-text-primary">{p.label}</span>
412+
<span className="ml-auto inline-flex shrink-0 items-center gap-2">
413+
{p.speed && <span className="text-text-muted">{p.speed}</span>}
414+
<span
415+
className={p.up ? 'h-1.5 w-1.5 rounded-full bg-ok' : 'h-1.5 w-1.5 rounded-full bg-border-strong/50'}
416+
title={p.up ? t('routerDetail.ports.inUse') : t('routerDetail.ports.free')}
417+
/>
418+
</span>
419+
</li>
420+
))}
421+
</ul>
422+
)}
423+
</div>
424+
)}
361425
</section>
362426
)
363427
}

‎app/src/components/routers/routerExtras.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export interface EthPort {
7070
up: boolean
7171
speed?: string // "2.5 Gbps" | "1 Gbps"
7272
iface?: string // interfaz física (/proc/net/dev), si difiere del id
73+
/** #1125: familia para interfaces NO físicas ("lag"|"vlan"|"bridge"|
74+
* "tunnel"|"virtual"); ausente = boca física. La tarjeta las agrupa en
75+
* la sección colapsada en vez de ocultarlas (#1115). */
76+
family?: string
7377
rxBytes?: number // contadores acumulados de la boca (#305)
7478
txBytes?: number
7579
rxErrors?: number

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func (l *Live) pollRouterSNMP(cfg RouterConfig) (*routerPolled, error) {
123123
Up: p.OperUp,
124124
Speed: p.SpeedString(),
125125
Iface: name,
126+
Family: p.Family,
126127
RxBytes: p.RxBytes,
127128
TxBytes: p.TxBytes,
128129
RxErrs: p.RxErrors,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,10 @@ type EthPort struct {
561561
Up bool `json:"up"`
562562
Speed string `json:"speed,omitempty"` // solo si up ("1 Gbps"|"100 Mbps")
563563
Iface string `json:"iface,omitempty"` // iface física (/proc/net/dev)
564+
// #1125: familia para interfaces NO físicas ("lag"|"vlan"|"bridge"|
565+
// "tunnel"|"virtual"); vacío/ausente = boca física. El frontend las
566+
// agrupa en la sección colapsada de la tarjeta de puertos.
567+
Family string `json:"family,omitempty"`
564568
RxBytes uint64 `json:"rxBytes,omitempty"`
565569
TxBytes uint64 `json:"txBytes,omitempty"`
566570
RxErrs uint64 `json:"rxErrors,omitempty"`

‎server-go/internal/snmp/ifTable.go‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ type PortStats struct {
1414
Descr string
1515
Alias string
1616
Type int
17+
// Familia de la interfaz para las NO físicas (#1125): "lag", "vlan",
18+
// "bridge", "tunnel" o "virtual". Vacío = boca física (o ifType no
19+
// reportado por walk fallido: se conserva en el grupo físico, la misma
20+
// defensa de la #1115). El frontend las agrupa colapsadas al pie de la
21+
// tarjeta de puertos en vez de ocultarlas.
22+
Family string
1723
SpeedBps uint64
1824
HighSpeedMbps uint32
1925
OperUp bool
@@ -88,39 +94,34 @@ func PollIfTable(s *gosnmp.GoSNMP) ([]PortStats, error) {
8894
}
8995
out := make([]PortStats, 0, len(byIdx))
9096
for _, ps := range byIdx {
97+
ps.Family = ifFamily(ps.Type)
9198
out = append(out, *ps)
9299
}
93100
sortByIndex(out)
94-
return physicalIfPorts(out), nil
101+
return out, nil
95102
}
96103

97-
// nonPhysicalIfTypes: ifTypes (IANA) que NO son una boca física del switch
98-
// y no deben aparecer en la tarjeta de puertos (#1115): interfaces LAG,
99-
// VLANs, loopbacks, bridges y túneles. Verificado en un Linksys LGS310C
104+
// nonPhysicalIfTypeFamilies: ifTypes (IANA) que NO son una boca física del
105+
// switch, con su familia para el grupo colapsado de la tarjeta (#1125,
106+
// alternativa a ocultarlas de la #1115: nada se pierde y las referencias
107+
// FDB/LLDP por ifIndex siguen resolviendo). Verificado en un Linksys LGS310C
100108
// real: po1-8 llegan como ieee8023adLag(161) y vlan1 como l3ipvlan(136).
101-
var nonPhysicalIfTypes = map[int]bool{
102-
24: true, // softwareLoopback
103-
53: true, // propVirtual
104-
131: true, // tunnel
105-
135: true, // l2vlan
106-
136: true, // l3ipvlan
107-
161: true, // ieee8023adLag
108-
209: true, // bridge
109+
// Todo ifType que NO esté en el mapa (incluido Type=0 por walk fallido) se
110+
// trata como físico: la defensa por exclusión de la #1115 se conserva.
111+
var nonPhysicalIfTypeFamilies = map[int]string{
112+
24: "virtual", // softwareLoopback
113+
53: "virtual", // propVirtual
114+
131: "tunnel", // tunnel
115+
135: "vlan", // l2vlan
116+
136: "vlan", // l3ipvlan
117+
161: "lag", // ieee8023adLag
118+
209: "bridge", // bridge
109119
}
110120

111-
// physicalIfPorts descarta las interfaces no físicas del ifTable (#1115).
112-
// Filtra por EXCLUSIÓN y no por "solo ethernetCsmacd": un switch que no
113-
// reporte ifType (Type=0 por walk fallido) conserva todos sus puertos en
114-
// lugar de quedarse a cero.
115-
func physicalIfPorts(ports []PortStats) []PortStats {
116-
out := ports[:0]
117-
for _, p := range ports {
118-
if nonPhysicalIfTypes[p.Type] {
119-
continue
120-
}
121-
out = append(out, p)
122-
}
123-
return out
121+
// ifFamily clasifica una interfaz por su ifType: "" = física (o desconocida),
122+
// en otro caso la familia del grupo virtual (#1125).
123+
func ifFamily(t int) string {
124+
return nonPhysicalIfTypeFamilies[t]
124125
}
125126

126127
func applyPortField(ps *PortStats, oid string, pdu gosnmp.SnmpPDU) {

‎server-go/internal/snmp/snmp_test.go‎

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -217,30 +217,32 @@ func TestSortByIndex(t *testing.T) {
217217
}
218218
}
219219

220-
// #1115: la tarjeta de puertos solo lista bocas físicas. Verificado en un
221-
// LGS310C real: po1-8 (ieee8023adLag) y vlan1 (l3ipvlan) fuera; las 10
222-
// ethernetCsmacd dentro. El filtro es por exclusión: un switch que no
223-
// reporta ifType (Type=0) conserva sus puertos.
224-
func TestPhysicalIfPorts(t *testing.T) {
225-
in := []PortStats{
226-
{Index: 1, Name: "Slot0/1", Type: 6},
227-
{Index: 10, Name: "Slot0/10", Type: 6},
228-
{Index: 53, Name: "po1", Type: 161},
229-
{Index: 61, Name: "vlan1", Type: 136},
230-
{Index: 70, Name: "br0", Type: 209},
231-
{Index: 71, Name: "lo", Type: 24},
232-
{Index: 72, Name: "tun0", Type: 131},
233-
{Index: 73, Name: "vlan2", Type: 135},
234-
{Index: 74, Name: "mgmt", Type: 53},
235-
{Index: 80, Name: "weird0", Type: 0}, // sin ifType reportado: se conserva
236-
}
237-
got := physicalIfPorts(in)
238-
if len(got) != 3 {
239-
t.Fatalf("esperaba 3 puertos físicos, obtuve %d: %+v", len(got), got)
240-
}
241-
for i, want := range []int{1, 10, 80} {
242-
if got[i].Index != want {
243-
t.Fatalf("puerto %d: Index %d, esperaba %d", i, got[i].Index, want)
220+
// #1125 (alternativa a la exclusión de la #1115): el ifTable conserva TODAS
221+
// las interfaces y cada una se clasifica por familia; las no físicas (LAG,
222+
// VLAN, bridge, túnel, virtual) van al grupo colapsado de la tarjeta en el
223+
// frontend en vez de desaparecer. Verificado en un LGS310C real: po1-8
224+
// (ieee8023adLag) y vlan1 (l3ipvlan) clasificados; un ifType no listado o no
225+
// reportado (Type=0 por walk fallido) sigue siendo físico (defensa #1115).
226+
func TestIfFamilyClassification(t *testing.T) {
227+
cases := []struct {
228+
typ int
229+
family string
230+
}{
231+
{6, ""}, // ethernetCsmacd: física
232+
{117, ""}, // gigabitEthernet: física
233+
{161, "lag"}, // ieee8023adLag (LGS310C poN)
234+
{136, "vlan"}, // l3ipvlan (LGS310C vlan1)
235+
{135, "vlan"}, // l2vlan
236+
{209, "bridge"}, // bridge
237+
{131, "tunnel"}, // tunnel
238+
{24, "virtual"}, // softwareLoopback
239+
{53, "virtual"}, // propVirtual
240+
{0, ""}, // sin ifType: físico (walk fallido conserva todo)
241+
{9999, ""}, // desconocido: se trata como físico por exclusión
242+
}
243+
for _, c := range cases {
244+
if got := ifFamily(c.typ); got != c.family {
245+
t.Errorf("ifFamily(%d) = %q, esperaba %q", c.typ, got, c.family)
244246
}
245247
}
246248
}

0 commit comments

Comments
 (0)