Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ const realApi = {
body: JSON.stringify({ ip, hostname, remove }),
}),
ethports: () => request<{ ports: import("./types").EthPort[] }>("/api/ethports"),
l2clients: () => request<import("./types").L2Probe>("/api/l2clients"),
usteer: () => request<{ aps: import("./types").UsteerAP[] }>("/api/usteer"),
guestwifi: () => request<import("./types").GuestProbe>("/api/guestwifi"),
setGuestwifi: (cfg: { enabled: boolean; ssid?: string; key?: string; band?: string; subnet?: string; isolate?: boolean }) =>
Expand Down
17 changes: 8 additions & 9 deletions app/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Overview } from "../pages/Overview";
import { TrafficPage } from "../pages/Traffic";
import { CoveragePage } from "../pages/Coverage";
import { ClientsPage } from "../pages/Clients";
import { L2ClientsPage } from "../pages/L2Clients";
import { WifiPage } from "../pages/Wifi";
import { InternetPage } from "../pages/Internet";
import { Services } from "../pages/Services";
Expand Down Expand Up @@ -233,14 +234,12 @@ function ShellInner({ onLogout }: { onLogout: () => void }) {
// #500: Servicios solo gateway (en AP solo quedaba AdGuard/mDNS;
// el nombre local se movió a Ajustes).
if (id === "services" && !isRouter) return "gatewayOnly";
// #518: Clientes y Tráfico no tienen fuente de datos en un switch L2
// dedicado: sin dnsmasq no hay leases ni reservas, y el conteo por
// cliente/app (nlbwmon) no aplica al forwarding por hardware. Ambas
// páginas quedaban vacías (Tráfico con un CTA muerto hacia Servicios,
// que en switch tampoco se ofrece), así que la entrada no se ofrece.
// El FDB del bridge sí se lee en switches; un inventario por boca sería
// una feature aparte (#518 comenta la evidencia).
if ((id === "clients" || id === "traffic") && isSwitch) return "switchL2";
// #518/#520: en un switch L2, "Clients" muestra el inventario de la FDB
// (L2ClientsPage) en vez de la vista de router. "Traffic" sí queda
// oculto: el conteo por cliente/app (nlbwmon) no aplica al forwarding
// por hardware y la página quedaría vacía (su CTA apunta a Servicios,
// que en switch tampoco se ofrece).
if ((id === "traffic") && isSwitch) return "switchL2";
return null;
};
const visible = (id: Page) => gateOf(id) === null;
Expand Down Expand Up @@ -438,7 +437,7 @@ function ShellInner({ onLogout }: { onLogout: () => void }) {
/>
)}
{activePage === "traffic" && <TrafficPage onNavigate={navigate} />}
{activePage === "clients" && <ClientsPage />}
{activePage === "clients" && (isSwitch ? <L2ClientsPage /> : <ClientsPage />)}
{activePage === "internet" && (
<InternetPage mwan={mwan} onMwanChange={setMwan} fwd={fwd} onFwdChange={setFwd} />
)}
Expand Down
15 changes: 15 additions & 0 deletions app/src/demo/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,21 @@ export const demoStorage: T.StorageProbe = {
],
};

export const demoL2Clients: T.L2Probe = {
applicable: true,
bridge: "br-lan",
ports: ["lan1", "lan2", "lan3", "lan4"],
clients: [
{ mac: "04:cf:8c:11:22:33", vendor: "Xiaomi", port: "lan1", vlans: [10, 20], age: 42 },
{ mac: "4c:eb:d6:aa:bb:cc", vendor: "Espressif", port: "lan1", vlans: [20], age: 3 },
{ mac: "02:1a:2b:3c:4d:5e", random: true, port: "lan2", vlans: [0], age: 120 },
{ mac: "00:0a:eb:12:34:56", vendor: "TP-Link Technologies", port: "lan2", vlans: [], age: 5 },
{ mac: "00:15:6d:de:ad:00", vendor: "Ubiquiti", port: "lan3", vlans: [10], age: 90 },
{ mac: "00:04:3c:70:80:90", vendor: "Sonos", port: "lan4", vlans: [10, 30], age: 3600, static: true },
{ mac: "b8:27:eb:aa:11:22", vendor: "Raspberry Pi Foundation", port: "lan4", vlans: [30], age: 15 },
],
};

export const demoMacAcl: T.MACACLProbe = { applicable: false, ports: [] };

export const demoSelfUpdate: T.SelfUpdateCheck = {
Expand Down
1 change: 1 addition & 0 deletions app/src/demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export const demoApi: typeof api = {

// puertos
ethports: () => get({ ports: D.demoEthPorts }),
l2clients: () => get(D.demoL2Clients),
multiwan: () => get(state.mwan),
setMultiwan: async (req) => {
const m = state.mwan;
Expand Down
25 changes: 23 additions & 2 deletions app/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default {
desc: {
overview: "How your router is doing",
internet: "Your line to the outside: WAN, forwards, per-app traffic and attack blocking",
clients: "Who is connected to your network right now",
clients: "Devices connected to your network",
coverage: "How Wi-Fi is spread across your devices",
traffic: "Which devices and apps use the most",
wifi: "Networks, passwords and who's connected",
Expand All @@ -86,7 +86,7 @@ export default {
portsAp: "Its port detail lives in Overview",
noPorts: "No manageable built-in switch",
noStorage: "No USB storage to manage",
switchL2: "A switch does not route: the router sees the clients and their usage",
switchL2: "A switch does not route: the router sees per-client usage",
},
},
theme: {
Expand Down Expand Up @@ -1283,6 +1283,27 @@ export default {
doneOn: "Done. The guest welcome page is on.",
doneOff: "Done. The guest welcome page is off.",
},
l2clients: {
title: "Connected devices",
subtitle: "Layer-2 inventory: every MAC the bridge has learned, with its port and VLAN",
col: {
mac: "MAC address",
vendor: "Vendor",
port: "Port",
vlans: "VLANs",
age: "Last seen",
static: "Static",
},
empty: "No clients learned on the bridge yet",
notApplicable: "This device does not expose a bridge FDB to inventory",
searchPlaceholder: "Search by MAC, port or vendor",
randomMac: "Random",
staticEntry: "Static",
unfilteredVlan: "Unfiltered",
ageSeconds: "{{seconds}} s",
ageMinutes: "{{minutes}} min",
ageUnknown: "unknown",
},
traffic: {
title: "Traffic",
measuring: "Measuring...",
Expand Down
25 changes: 23 additions & 2 deletions app/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default {
desc: {
overview: "Cómo está tu router ahora",
internet: "Tu línea con el exterior: WAN, reenvíos, tráfico por app y bloqueo de ataques",
clients: "Quién está conectado a tu red ahora mismo",
clients: "Equipos conectados a la red",
coverage: "Cómo se reparte el Wi-Fi entre tus equipos",
traffic: "Qué dispositivos y apps gastan más",
wifi: "Redes, claves y quién está conectado",
Expand All @@ -86,7 +86,7 @@ export default {
portsAp: "El detalle de sus bocas vive en Resumen",
noPorts: "Sin switch integrado gestionable",
noStorage: "No hay almacenamiento USB que gestionar",
switchL2: "Un switch no enruta: los clientes y su consumo los ve el router",
switchL2: "Un switch no enruta: el consumo por cliente lo ve el router",
},
},
theme: {
Expand Down Expand Up @@ -1283,6 +1283,27 @@ export default {
doneOn: "Listo. La página de bienvenida de invitados está activa.",
doneOff: "Listo. La página de bienvenida de invitados está apagada.",
},
l2clients: {
title: "Equipos conectados",
subtitle: "Inventario de nivel 2: cada MAC aprendida por el bridge con su puerto y VLAN",
col: {
mac: "Dirección MAC",
vendor: "Fabricante",
port: "Puerto",
vlans: "VLANs",
age: "Visto hace",
static: "Estática",
},
empty: "No hay clientes aprendidos en el bridge todavía",
notApplicable: "Este equipo no expone una FDB de bridge que inventariar",
searchPlaceholder: "Buscar por MAC, puerto o fabricante",
randomMac: "Aleatoria",
staticEntry: "Estática",
unfilteredVlan: "Sin filtrado",
ageSeconds: "{{seconds}} s",
ageMinutes: "{{minutes}} min",
ageUnknown: "desconocida",
},
traffic: {
title: "Tráfico",
measuring: "Midiendo...",
Expand Down
152 changes: 152 additions & 0 deletions app/src/pages/L2Clients.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Network, Search } from "lucide-react";
import { api } from "../api";
import type { L2Client, L2Probe } from "../types";
import { Card, EmptyState, Input, Pill, Skeleton } from "../components/ui";
import { IlluDevices } from "../components/ui/illustrations";

/**
* Inventario de clientes de nivel 2 (#520): en un switch, la vista "Clients"
* no se alimenta de leases/ARP sino de la FDB del bridge (MAC + puerto +
* VLAN). Sin semántica DHCP: aquí no hay reservas, bloqueos, cuotas ni
* parental. Se muestra cuando el rol es switch; el router/AP sigue usando
* ClientsPage.
*/

/** Edad legible desde segundos (0 = desconocida). */
function fmtAge(age: number | undefined, t: (k: string, o?: Record<string, string | number>) => string): string {
if (!age) return t("l2clients.ageUnknown");
if (age < 60) return t("l2clients.ageSeconds", { seconds: age });
return t("l2clients.ageMinutes", { minutes: Math.floor(age / 60) });
}

/** Una lista de VLANs vacía o con solo [0] significa "sin filtrado". */
function unfiltered(vlans: number[]): boolean {
return vlans.length === 0 || (vlans.length === 1 && vlans[0] === 0);
}

function vlanText(vlans: number[], t: (k: string, o?: Record<string, string | number>) => string): string {
if (unfiltered(vlans)) return t("l2clients.unfilteredVlan");
return vlans.join(", ");
}

export function L2ClientsPage() {
const { t } = useTranslation();
const [probe, setProbe] = useState<L2Probe>();
const [query, setQuery] = useState("");

const load = useCallback(async () => {
try {
setProbe(await api.l2clients());
} catch {
// Error de red: se conserva el último inventario y el poller reintenta.
}
}, []);

// Mismo refresco que la vista de router: la FDB aprende y caduca sola.
useEffect(() => { load(); const id = setInterval(load, 5000); return () => clearInterval(id); }, [load]);

const clients = useMemo(() => {
const list = probe?.clients ?? [];
const q = query.trim().toLowerCase();
if (!q) return list;
return list.filter((c) =>
c.mac.toLowerCase().includes(q) ||
c.port.toLowerCase().includes(q) ||
(c.vendor ?? "").toLowerCase().includes(q),
);
}, [probe, query]);

// Página entera no aplica (sin bridge resoluble) y se entra por URL.
if (probe && !probe.applicable) {
return (
<Card index={0}>
<EmptyState illustration={<IlluDevices size={120} />} title={t("l2clients.notApplicable")} />
</Card>
);
}

const showSearch = (probe?.clients.length ?? 0) > 10;

return (
<div className="flex flex-col gap-[var(--card-gap)]">
<Card
index={0}
title={t("l2clients.title")}
icon={Network}
action={probe && (
<Pill tone="muted">{t("l2clients.subtitle")}</Pill>
)}
>
{!probe ? (
<div className="grid gap-3">
<Skeleton className="h-10" />
<Skeleton className="h-64" />
</div>
) : clients.length === 0 ? (
<EmptyState illustration={<IlluDevices size={120} />} title={t("l2clients.empty")} />
) : (
<>
{showSearch && (
<div className="mb-3 max-w-sm">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("l2clients.searchPlaceholder")}
icon={Search}
aria-label={t("l2clients.searchPlaceholder")}
/>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-small">
<thead>
<tr className="text-faint text-caption border-b border-border">
<th className="py-2 pr-3 font-medium">{t("l2clients.col.mac")}</th>
<th className="py-2 pr-3 font-medium">{t("l2clients.col.vendor")}</th>
<th className="py-2 pr-3 font-medium">{t("l2clients.col.port")}</th>
<th className="py-2 pr-3 font-medium">{t("l2clients.col.vlans")}</th>
<th className="py-2 pr-3 font-medium">{t("l2clients.col.age")}</th>
</tr>
</thead>
<tbody>
{clients.map((c) => (
<L2Row key={`${c.mac}\u0000${c.port}`} c={c} t={t} />
))}
</tbody>
</table>
</div>
</>
)}
</Card>
</div>
);
}

function L2Row({ c, t }: { c: L2Client; t: (k: string, o?: Record<string, string | number>) => string }) {
return (
<tr className="border-b border-border/60 last:border-0">
<td className="py-2 pr-3 font-mono">{c.mac}</td>
<td className="py-2 pr-3">
{c.random ? (
<Pill tone="muted">{t("l2clients.randomMac")}</Pill>
) : c.vendor ? (
c.vendor
) : (
<span className="text-faint">-</span>
)}
</td>
<td className="py-2 pr-3">
<a href="#/ports" className="text-accent hover:underline">{c.port}</a>
</td>
<td className="py-2 pr-3 font-mono tabular-nums">{vlanText(c.vlans, t)}</td>
<td className="py-2 pr-3">
<span className="inline-flex items-center gap-1.5">
{fmtAge(c.age, t)}
{c.static && <Pill tone="accent">{t("l2clients.staticEntry")}</Pill>}
</span>
</td>
</tr>
);
}
19 changes: 19 additions & 0 deletions app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,25 @@ export interface VLANProbe {
ports: string[];
}

/** One learned MAC in a switch's bridge FDB (#520). */
export interface L2Client {
mac: string;
vendor?: string;
random?: boolean;
port: string;
vlans: number[];
age?: number;
static?: boolean;
}

/** GET /api/l2clients payload (#520). */
export interface L2Probe {
applicable: boolean;
bridge?: string;
ports?: string[];
clients: L2Client[];
}

export interface VLANEdit {
vid: number;
ports: VLANPort[];
Expand Down
Loading
Loading