"use client" import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer, EthernetPort, ArrowDown, ArrowUp, Box, ChevronRight } from 'lucide-react' import useSWR from "swr" import { NetworkTrafficChart } from "./network-traffic-chart" import { NetworkFlow, type NetworkFlowData } from "./network-flow" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { LatencyDetailModal } from "./latency-detail-modal" import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts" import { useT } from "../lib/i18n/provider" type TFunction = (key: string, params?: Record) => string interface NetworkData { interfaces: NetworkInterface[] physical_interfaces?: NetworkInterface[] bridge_interfaces?: NetworkInterface[] // Bond masters. Also present inside `interfaces` for backward // compatibility; this list is what the topology diagram consumes. bond_interfaces?: NetworkInterface[] vm_lxc_interfaces?: NetworkInterface[] traffic: { bytes_sent: number bytes_recv: number packets_sent?: number packets_recv?: number packet_loss_in?: number packet_loss_out?: number dropin?: number dropout?: number errin?: number errout?: number } active_count?: number total_count?: number physical_active_count?: number physical_total_count?: number bridge_active_count?: number bridge_total_count?: number vm_lxc_active_count?: number vm_lxc_total_count?: number hostname?: string domain?: string dns_servers?: string[] } interface NetworkInterface { name: string type: string status: string speed: number duplex: string mtu: number mac_address: string | null addresses: Array<{ ip: string netmask: string }> bytes_sent?: number bytes_recv?: number packets_sent?: number packets_recv?: number errors_in?: number errors_out?: number drops_in?: number drops_out?: number // Live rate (bytes/sec) computed by the backend as the delta // between this poll and the previous one. Present from the second // /api/network response onward; absent on the first call after the // service starts or after a long pause. rx_Bps?: number tx_Bps?: number // Hardware ceiling parsed from ethtool's "Supported link modes". // The card shows "(max N Gbps)" next to the negotiated speed when // the link is auto-negotiated below the NIC's max. max_speed?: number // Bridges that have this physical NIC as their underlying interface // (directly, or as a bond slave). Surfaced in the card so the // operator can see "this NIC → vmbr0" at a glance. used_by_bridges?: string[] bond_mode?: string // Kernel's human-readable mode, e.g. "fault-tolerance (active-backup)". // bond_mode holds the short form ("active-backup") that matches // /etc/network/interfaces and the Proxmox UI. bond_mode_detail?: string | null bond_slaves?: string[] bond_active_slave?: string | null // True only for modes where a slave really sits idle (active-backup). bond_supports_failover?: boolean bond_slave_status?: Record // Set on a physical NIC that is enslaved to a bond. bond_master?: string bond_role?: "active" | "standby" | "member" bond_link?: string // Master device resolved from /sys/class/net//master — the // bridge for a guest tap, the bond for a slave NIC. bridge_owner?: string bridge_members?: string[] bridge_physical_interface?: string bridge_bond_slaves?: string[] bridge_vlan_interface?: string | null packet_loss_in?: number packet_loss_out?: number vmid?: number vm_name?: string vm_type?: string vm_status?: string } // Same dot-prefix tone the Storage cards use, so a "no errors" / // "errors present" cue reads identically across pages. const NetStatusDot = ({ tone }: { tone: "ok" | "warn" | "fail" }) => { const cls = tone === "ok" ? "bg-green-500" : tone === "warn" ? "bg-yellow-500" : "bg-red-500" return } const netCounterTone = (n: number | null | undefined): "ok" | "warn" | "fail" => { if (!n || n <= 0) return "ok" if (n < 10) return "warn" return "fail" } // Icon picker — defaults to the actual port type rather than a Wi-Fi // glyph for everything. Wireless interfaces (wl*/wifi*) keep the Wi-Fi // glyph; wired NICs use EthernetPort; bonds/bridges/vlans get more // specific icons so the operator can tell them apart at a glance. function getInterfaceIcon(iface: NetworkInterface): React.ComponentType<{ className?: string }> { const name = (iface.name || "").toLowerCase() const type = (iface.type || "").toLowerCase() if (name.startsWith("wl") || name.startsWith("wifi")) return Wifi if (type === "bridge") return Network if (type === "bond") return Router if (type === "vlan") return Activity if (type === "vm_lxc" || type === "virtual") return Box // Physical wired NIC (eth0, enp*, ens*, eno*, nic0, …) → ethernet port. return EthernetPort } // Match the dark blue badge tone the Storage card uses for the disk // type chip, but mapped to the actual interface class. function getInterfaceTypeLabel(type: string, t: TFunction) { switch ((type || "").toLowerCase()) { case "physical": return t("network.interfaceTypes.physical") case "bridge": return t("network.interfaceTypes.bridge") case "bond": return t("network.interfaceTypes.bond") case "vlan": return t("network.interfaceTypes.vlan") case "vm_lxc": case "virtual": return t("network.interfaceTypes.virtual") default: return type || t("common.unknown") } } function getInterfaceTypeChip(type: string, t: TFunction) { switch ((type || "").toLowerCase()) { case "physical": return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: getInterfaceTypeLabel(type, t) } case "bridge": return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: getInterfaceTypeLabel(type, t) } case "bond": return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: getInterfaceTypeLabel(type, t) } case "vlan": return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: getInterfaceTypeLabel(type, t) } case "vm_lxc": case "virtual": return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: getInterfaceTypeLabel(type, t) } default: return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || t("common.unknown") } } } const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => { const normalized = (status || "").toLowerCase() if (normalized === "up") return t("network.status.up") if (normalized === "down") return t("network.status.down") return status || t("common.unknown") } const formatDuplex = (duplex: string | undefined, t: TFunction): string => { const normalized = (duplex || "").toLowerCase() if (normalized === "full") return t("network.duplex.full") if (normalized === "half") return t("network.duplex.half") if (!duplex || normalized === "unknown") return t("common.unknown") return duplex } // Per-interface card matching the Storage page's "Physical Disks" // pattern: 2-line header (identity / live state), horizontal divider, // vertical key→value stat block, footer with serial + arrow CTA. // Replaces the row-style block that was unchanged since 1.0.0. function renderPhysicalInterfaceCardV2( iface: NetworkInterface, onOpen: (iface: NetworkInterface) => void, t: TFunction, ) { const Icon = getInterfaceIcon(iface) const chip = getInterfaceTypeChip(iface.type, t) const isUp = (iface.status || "").toLowerCase() === "up" const firstAddr = iface.addresses?.[0]?.ip || "" const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1) const speedStr = formatSpeed(iface.speed, t("common.notAvailable")) // Hardware max in Mbps from ethtool. Show only when it's different // from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise). const maxSpeedStr = iface.max_speed && iface.max_speed !== iface.speed ? formatSpeed(iface.max_speed, t("common.notAvailable")) : "" const bridgesUsing = iface.used_by_bridges || [] const errIn = iface.errors_in ?? 0 const errOut = iface.errors_out ?? 0 const dropIn = iface.drops_in ?? 0 const dropOut = iface.drops_out ?? 0 const totalErrors = errIn + errOut const totalDrops = dropIn + dropOut return (
onOpen(iface)} > {/* Header L1: identity (icon + name + type) | status. */}

{iface.name}

{chip.label}
{formatInterfaceStatus(iface.status, t)}
{/* Header L2: speed + max (when negotiated < hw) | duplex. */}
{speedStr} {maxSpeedStr && ( · {t("network.labels.maxSpeed", { speed: maxSpeedStr })} )} {formatDuplex(iface.duplex, t)}
{/* Separator. */}
{/* Stats: key uppercase left · value right. */}
{firstAddr && (
IP {firstAddr}{extraAddrs > 0 ? ` (+${extraAddrs})` : ""}
)}
MTU {iface.mtu || "—"}
{bridgesUsing.length > 0 && (
{t("network.interfaceTypes.bridge")} {bridgesUsing.map((b) => `→ ${b}`).join(" ")}
)} {/* Live RX/TX rate. Same wording the Network Traffic chart uses ("Received" / "Sent") and the same canonical colours (green for Received, blue for Sent). Falls back to "—" until the backend has a delta — first poll after start has no previous sample to compute against. */}
{t("network.labels.received")} {iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
{t("network.labels.sent")} {iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
{(totalErrors > 0 || totalDrops > 0) && ( <> {totalErrors > 0 && (
{t("network.labels.errors")} {totalErrors.toLocaleString()}
)} {totalDrops > 0 && (
{t("network.labels.drops")} {totalDrops.toLocaleString()}
)} )}
{/* Footer: MAC (left, mono) + arrow CTA (right). */}
{iface.mac_address ? ( MAC: {iface.mac_address} ) : ( )}
) } const getInterfaceTypeBadge = (type: string, t: TFunction) => { switch (type) { case "physical": return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") } case "bridge": return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") } case "bond": return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") } case "vlan": return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") } case "vm_lxc": return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") } case "virtual": return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") } default: return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") } } } const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => { if (vmType === "lxc") { return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" } } else if (vmType === "vm") { return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" } } return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") } } // Format bytes/sec into the canonical network unit ladder. // Matches the convention used by the Network Traffic chart so the // rates on the per-interface cards and the chart read the same way. const formatRate = (bps: number | undefined): string => { if (bps === undefined || bps === null || !Number.isFinite(bps)) return "—" if (bps < 1) return "0 B/s" const k = 1024 const sizes = ["B/s", "KB/s", "MB/s", "GB/s"] const i = Math.min(sizes.length - 1, Math.floor(Math.log(bps) / Math.log(k))) const v = bps / Math.pow(k, i) return `${v >= 100 ? v.toFixed(0) : v.toFixed(v >= 10 ? 1 : 2)} ${sizes[i]}` } const formatBytes = (bytes: number | undefined): string => { if (!bytes || bytes === 0) return "0 B" const k = 1024 const sizes = ["B", "KB", "MB", "GB", "TB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` } const formatStorage = (bytes: number): string => { if (bytes === 0) return "0 B" const k = 1024 const sizes = ["B", "KB", "MB", "GB", "TB", "PB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) const value = bytes / Math.pow(k, i) // Use 1 decimal place for values >= 10, 2 decimal places for values < 10 const decimals = value >= 10 ? 1 : 2 return `${value.toFixed(decimals)} ${sizes[i]}` } const formatSpeed = (speed: number, unavailable = "N/A"): string => { if (speed === 0) return unavailable if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps` return `${speed} Mbps` } const fetcher = async (url: string): Promise => { return fetchApi(url) } export function NetworkMetrics() { const t = useT() const { data: networkData, error, isLoading, } = useSWR("/api/network", fetcher, { // Was 15 s — too long for the Network Flow's pulse animation // which needs near-live rates. 3 s gives the dashboard responsive // updates without hammering the backend. refreshInterval: 3000, revalidateOnFocus: true, revalidateOnReconnect: true, }) const [selectedInterface, setSelectedInterface] = useState(null) const [timeframe, setTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day") const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day") const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [latencyModalOpen, setLatencyModalOpen] = useState(false) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit()) // Latency history for sparkline (last hour) const { data: latencyData } = useSWR<{ data: Array<{ timestamp: number; value: number }> stats: { min: number; max: number; avg: number; current: number } target: string }>("/api/network/latency/history?target=gateway&timeframe=hour", (url: string) => fetchApi(url), { refreshInterval: 60000, revalidateOnFocus: false } ) useEffect(() => { setNetworkUnit(getNetworkUnit()) const handleUnitChange = (e: CustomEvent) => { setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes") } window.addEventListener("networkUnitChanged" as any, handleUnitChange) return () => window.removeEventListener("networkUnitChanged" as any, handleUnitChange) }, []) const { data: modalNetworkData } = useSWR(selectedInterface ? "/api/network" : null, fetcher, { refreshInterval: 17000, revalidateOnFocus: false, revalidateOnReconnect: true, }) const { data: interfaceHistoricalData } = useSWR(`/api/node/metrics?timeframe=${timeframe}`, fetcher, { refreshInterval: 29000, revalidateOnFocus: false, }) if (isLoading) { return (
{t("network.loading.title")}

{t("network.loading.description")}

) } if (error || !networkData) { return (
{t("network.errors.serverUnavailableTitle")}
{error?.message || t("network.errors.serverUnavailableDescription")}
) } const trafficInFormatted = formatNetworkTraffic( networkTotals.received * 1024 ** 3, networkUnit, 2 ) const trafficOutFormatted = formatNetworkTraffic( networkTotals.sent * 1024 ** 3, networkUnit, 2 ) const packetsRecvK = networkData.traffic.packets_recv ? (networkData.traffic.packets_recv / 1000).toFixed(0) : "0" const totalErrors = (networkData.traffic.errin || 0) + (networkData.traffic.errout || 0) const packetLossIn = networkData.traffic.packet_loss_in || 0 const packetLossOut = networkData.traffic.packet_loss_out || 0 const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2) // Determine health status let healthStatusKey = "network.status.healthy" let healthColor = "bg-green-500/10 text-green-500 border-green-500/20" if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) { healthStatusKey = "network.status.critical" healthColor = "bg-red-500/10 text-red-500 border-red-500/20" } else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) { healthStatusKey = "network.status.warning" healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } const allInterfaces = [ ...(networkData.physical_interfaces || []), ...(networkData.bond_interfaces || []), ...(networkData.bridge_interfaces || []), ...(networkData.vm_lxc_interfaces || []), ] const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => { const vmidA = a.vmid ?? Number.MAX_SAFE_INTEGER const vmidB = b.vmid ?? Number.MAX_SAFE_INTEGER return vmidA - vmidB }) const topInterface = vmLxcInterfaces.length > 0 ? vmLxcInterfaces.reduce((top, iface) => { const ifaceTraffic = (iface.bytes_recv || 0) + (iface.bytes_sent || 0) const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0) return ifaceTraffic > topTraffic ? iface : top }, vmLxcInterfaces[0]) : { name: t("network.empty.noVmLxc"), type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: t("common.notAvailable") } const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0) const getTimeframeLabel = () => { switch (timeframe) { case "hour": return t("network.timeframes.hour") case "day": return t("network.timeframes.day") case "week": return t("network.timeframes.week") case "month": return t("network.timeframes.month") case "year": return t("network.timeframes.year") default: return t("network.timeframes.day") } } // Compact form for inline header use. The full "24 Hours" gets noisy // next to the title; "Past 24 h" keeps the same meaning in less space. const getTimeframeShortLabel = () => { switch (timeframe) { case "hour": return t("network.timeframes.short.hour") case "day": return t("network.timeframes.short.day") case "week": return t("network.timeframes.short.week") case "month": return t("network.timeframes.short.month") case "year": return t("network.timeframes.short.year") default: return t("network.timeframes.short.day") } } const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => { switch (value) { case "hour": return t("network.timeframes.last.hour") case "day": return t("network.timeframes.last.day") case "week": return t("network.timeframes.last.week") case "month": return t("network.timeframes.last.month") case "year": return t("network.timeframes.last.year") default: return t("network.timeframes.last.day") } } const hostname = networkData.hostname || t("common.notAvailable") const domain = networkData.domain || t("common.notAvailable") const dnsServers = networkData.dns_servers || [] const primaryDNS = dnsServers[0] || t("common.notAvailable") const secondaryDNS = dnsServers[1] || t("common.notAvailable") return (
{/* Network Overview Cards */}
{/* ── Network Traffic (preview restyle: Down/Up dual headline + stacked bar) ── */} {(() => { const downBytes = networkData.traffic.bytes_recv || 0 const upBytes = networkData.traffic.bytes_sent || 0 const totalBytes = downBytes + upBytes const downPct = totalBytes > 0 ? (downBytes / totalBytes) * 100 : 50 const upPct = totalBytes > 0 ? (upBytes / totalBytes) * 100 : 50 return (
{t("network.cards.traffic")} {getTimeframeShortLabel()}
{t("network.labels.down")}
{trafficInFormatted}
{t("network.labels.up")}
{trafficOutFormatted}
{t("network.labels.down")} {Math.round(downPct)}% {t("network.labels.up")} {Math.round(upPct)}%
) })()} {/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */} {t("network.cards.activeInterfaces")}
{(networkData.physical_active_count ?? 0) + (networkData.bridge_active_count ?? 0)}
{t("network.interfaceTypes.physical")}: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0} {t("network.interfaceTypes.bridges")}: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}

{t("network.summary.totalInterfaces", { count: (networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0), })}

{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */} {t("network.cards.status")} {healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)} {(() => { const lossPct = Number.parseFloat(avgPacketLoss) || 0 const lossColor = lossPct >= 5 ? 'text-red-500' : lossPct >= 1 ? 'text-orange-500' : lossPct > 0 ? 'text-yellow-500' : 'text-blue-500' return (
{avgPacketLoss}% {t("network.labels.packetLoss")}
) })()}
{t("network.labels.hostname")}:
{hostname}
DNS:
{primaryDNS}
{t("network.labels.errors")}:
{totalErrors}
{t("network.labels.domain")}:
{domain}
{/* Latency Card with Sparkline */} setLatencyModalOpen(true)} > {t("network.cards.latency")}
{latencyData?.stats?.current ?? 0} ms
{(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") : (latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") : (latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
{/* Sparkline */} {latencyData?.data && latencyData.data.length > 0 && (
)}

{t("network.labels.avg")}: {latencyData?.stats?.avg ?? 0}ms | {t("network.labels.max")}: {latencyData?.stats?.max ?? 0}ms

{/* Timeframe Selector */}
{/* Network Traffic Card with Chart */} {t("network.cards.traffic")} {/* Network Flow — proof of concept. Lives next to Network Traffic while the design is validated on a real host. Once approved, this card replaces (or pairs with) the chart above. */} {(() => { const toMBps = (bps?: number) => (bps || 0) / (1024 * 1024) const allIfaces = [ ...(networkData.physical_interfaces || []), ...(networkData.bond_interfaces || []), ...(networkData.bridge_interfaces || []), ...(networkData.vm_lxc_interfaces || []), ] const flowData: NetworkFlowData = { nics: (networkData.physical_interfaces || []).map((p) => ({ id: p.name, link: formatSpeed(p.speed), rx: toMBps(p.rx_Bps), tx: toMBps(p.tx_Bps), // A slave whose MII status is down has no carrier even though // the interface itself stays administratively up — the bond // driver's view is the accurate one here. status: p.bond_link === "down" ? "down" : (p.status || "").toLowerCase() === "up" ? "up" : "down", bond: p.bond_master, role: p.bond_role, })), bonds: (networkData.bond_interfaces || []).map((b) => ({ id: b.name, mode: b.bond_mode && b.bond_mode !== "unknown" ? b.bond_mode : undefined, rx: toMBps(b.rx_Bps), tx: toMBps(b.tx_Bps), status: (b.status || "").toLowerCase() === "up" ? "up" : "down", })), bridges: (networkData.bridge_interfaces || []).map((b) => ({ id: b.name, parent: b.bridge_physical_interface, })), consumers: [ (() => { // PROXMOX node = sum of every running guest's rate. // This stays consistent with each bridge's own label // (which sums the same guest rates), and with the // total trunk flow — no discrepancy between the host's // displayed rate and the sum of its bridges. const runningGuests = (networkData.vm_lxc_interfaces || []).filter( (v) => v.vm_status !== "stopped" ) return { id: "host", label: "host", kind: "host" as const, bridge: (networkData.bridge_interfaces?.[0]?.name) || "", rx: runningGuests.reduce((a, v) => a + toMBps(v.rx_Bps), 0), tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0), } })(), ...(networkData.vm_lxc_interfaces || []).map((v) => { // Authoritative bridge from the kernel (read by the // backend from /sys/class/net//master). Fallback // to bridge_members scan, then first bridge as last // resort so we never silently drop a guest. const ownerName = (v as any).bridge_owner || (networkData.bridge_interfaces || []).find((b) => (b.bridge_members || []).includes(v.name) )?.name || (networkData.bridge_interfaces?.[0]?.name || "") return { id: v.name, label: v.vm_name || v.name, kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc", bridge: ownerName, rx: toMBps(v.rx_Bps), tx: toMBps(v.tx_Bps), offline: v.vm_status === "stopped", } }), ], } return ( { // Map the clicked node back to a NetworkInterface and // open the same details modal the cards below use. The // virtual "host" id never matches a real interface, so // it's a no-op — tapping the PROXMOX circle does nothing // (there's no host-level modal in this view). if (name === "host") return const match = allIfaces.find((iface) => iface.name === name) if (match) setSelectedInterface(match) }} /> ) })()} {/* Physical Interfaces section */} {t("network.sections.physicalInterfaces")} {t("network.summary.activeCount", { active: networkData.physical_active_count ?? 0, total: networkData.physical_total_count ?? 0, })} {/* Same responsive grid as the Storage page: 3 cols desktop, 2 cols tablet, 1 col mobile. Cards self-size so a row of long interface names won't push others off-screen. */}
{networkData.physical_interfaces.map((iface) => renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t), )}
{networkData.bridge_interfaces && networkData.bridge_interfaces.length > 0 && ( {t("network.sections.bridgeInterfaces")} {t("network.summary.activeCount", { active: networkData.bridge_active_count ?? 0, total: networkData.bridge_total_count ?? 0, })}
{networkData.bridge_interfaces.map((interface_, index) => { const typeBadge = getInterfaceTypeBadge(interface_.type, t) return (
setSelectedInterface(interface_)} > {/* First row: Icon, Name, Type Badge, Physical Interface (responsive), Status */}
{interface_.name}
{typeBadge.label} {interface_.bridge_physical_interface && (
→ {interface_.bridge_physical_interface} {interface_.bridge_bond_slaves && interface_.bridge_bond_slaves.length > 0 && ( ({interface_.bridge_bond_slaves.join(", ")}) )}
)}
{formatInterfaceStatus(interface_.status, t)}
{/* Second row: Details - Responsive layout */}
{t("network.labels.ipAddress")}
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : t("common.notAvailable")}
{t("network.labels.speed")}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
{t("network.labels.duplex")}
{formatDuplex(interface_.duplex, t)}
MTU
{interface_.mtu}
{interface_.mac_address && (
MAC
{interface_.mac_address}
)}
) })}
)} {/* VM & LXC Network Interfaces section */} {networkData.vm_lxc_interfaces && networkData.vm_lxc_interfaces.length > 0 && ( {t("network.sections.vmLxcInterfaces")} {t("network.summary.activeCount", { active: networkData.vm_lxc_active_count ?? 0, total: networkData.vm_lxc_total_count ?? 0, })}
{vmLxcInterfaces.map((interface_, index) => { const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t) return (
setSelectedInterface(interface_)} > {/* First row: Icon, Name, VM/LXC Badge, VM Name, Status */}
{interface_.name}
{vmTypeBadge.label} {interface_.vm_name && (
→ {interface_.vm_name}
)}
{formatInterfaceStatus(interface_.status, t)}
{/* Second row: Details - Responsive layout */}
VMID
{interface_.vmid ?? t("common.notAvailable")}
{t("network.labels.speed")}
{formatSpeed(interface_.speed, t("common.notAvailable"))}
{t("network.labels.duplex")}
{formatDuplex(interface_.duplex, t)}
MTU
{interface_.mtu}
{interface_.mac_address && (
MAC
{interface_.mac_address}
)}
) })}
)} {/* Interface Details Modal */} setSelectedInterface(null)}> {selectedInterface?.name} - {t("network.interfaceDetails.title")} {t("network.interfaceDetails.description")} {selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
)}
{selectedInterface && (
{(() => { // Find the current interface data from modalNetworkData if available const currentInterfaceData = modalNetworkData ? [ ...(modalNetworkData.physical_interfaces || []), ...(modalNetworkData.bond_interfaces || []), ...(modalNetworkData.bridge_interfaces || []), ...(modalNetworkData.vm_lxc_interfaces || []), ].find((iface) => iface.name === selectedInterface.name) : selectedInterface const displayInterface = currentInterfaceData || selectedInterface return ( <> {/* Basic Information */}

{t("network.interfaceDetails.basicInformation")}

{t("network.labels.interfaceName")}
{displayInterface.name}
{t("network.labels.type")}
{getInterfaceTypeBadge(displayInterface.type, t).label}
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
{t("network.labels.physicalInterface")}
{displayInterface.bridge_physical_interface}
{/* Slaves come from the bridge's own payload (bridge_bond_slaves); the bond master is not part of physical_interfaces, so looking it up there never matched. */} {displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
{t("network.labels.bondMembers")}
{displayInterface.bridge_bond_slaves.map((slave, idx) => ( {slave} ))}
)}
)} {displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
{t("network.labels.vmLxcName")}
{displayInterface.vm_name} {displayInterface.vm_type && ( {getVMTypeBadge(displayInterface.vm_type, t).label} )}
)}
{t("network.labels.status")}
{formatInterfaceStatus(displayInterface.status, t)}
{t("network.labels.speed")}
{formatSpeed(displayInterface.speed, t("common.notAvailable"))}
{t("network.labels.duplex")}
{formatDuplex(displayInterface.duplex, t)}
MTU
{displayInterface.mtu}
{displayInterface.mac_address && (
{t("network.labels.macAddress")}
{displayInterface.mac_address}
)}
{/* IP Addresses */} {displayInterface.addresses.length > 0 && (

{t("network.interfaceDetails.ipAddresses")}

{displayInterface.addresses.map((addr, idx) => (
{addr.ip}
{t("network.labels.netmask")}: {addr.netmask}
))}
)} {/* Network Traffic Statistics - Only show if interface is UP and NOT a VM interface */} {displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (

{t("network.interfaceDetails.trafficStatistics", { timeframe: getLastTimeframeLabel(modalTimeframe), })}

{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
{formatNetworkTraffic( interfaceTotals.received * 1024 ** 3, networkUnit, 2 )}
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
{formatNetworkTraffic( interfaceTotals.sent * 1024 ** 3, networkUnit, 2 )}
{t("network.labels.packetsReceived")}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
{t("network.labels.packetsSent")}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
{t("network.labels.errorsIn")}
{displayInterface.errors_in || 0}
{t("network.labels.errorsOut")}
{displayInterface.errors_out || 0}
{t("network.labels.dropsIn")}
{displayInterface.drops_in || 0}
{t("network.labels.dropsOut")}
{displayInterface.drops_out || 0}
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (

{t("network.interfaceDetails.trafficSinceBoot")}

{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
{t("network.labels.packetsReceived")}
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
{t("network.labels.packetsSent")}
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
{t("network.labels.errorsIn")}
{displayInterface.errors_in || 0}
{t("network.labels.errorsOut")}
{displayInterface.errors_out || 0}
{t("network.labels.dropsIn")}
{displayInterface.drops_in || 0}
{t("network.labels.dropsOut")}
{displayInterface.drops_out || 0}
) : (

{t("network.interfaceDetails.inactiveTitle")}

{t("network.interfaceDetails.inactiveDescription")}

)} {/* Bond Information */} {displayInterface.type === "bond" && displayInterface.bond_slaves && (

{t("network.interfaceDetails.bondConfiguration")}

{t("network.labels.bondingMode")}
{displayInterface.bond_mode || t("common.unknown")} {displayInterface.bond_mode_detail && displayInterface.bond_mode_detail !== displayInterface.bond_mode && ( {" "} ({displayInterface.bond_mode_detail}) )}
{displayInterface.bond_active_slave && (
{displayInterface.bond_supports_failover ? t("network.labels.activeSlave") : t("network.labels.primarySlave")}
{displayInterface.bond_active_slave}
)}
{t("network.labels.slaveInterfaces")}
{displayInterface.bond_slaves.map((slave, idx) => { // Only active-backup has a real standby. In every // other mode all slaves transmit, so we just show // the link state. const link = displayInterface.bond_slave_status?.[slave] const isDown = link === "down" const role = isDown ? "down" : displayInterface.bond_supports_failover ? slave === displayInterface.bond_active_slave ? "active" : "standby" : null const tone = isDown ? "bg-red-500/10 text-red-500 border-red-500/20" : role === "standby" ? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" : "bg-purple-500/10 text-purple-500 border-purple-500/20" return ( {slave} {role && · {t(`network.roles.${role}`)}} ) })}
)} {/* Bridge Information */} {displayInterface.type === "bridge" && displayInterface.bridge_members && (

{t("network.interfaceDetails.bridgeConfiguration")}

{t("network.labels.virtualMemberInterfaces")}
{displayInterface.bridge_members.length > 0 ? ( displayInterface.bridge_members .filter( (member) => !member.startsWith("enp") && !member.startsWith("eth") && !member.startsWith("eno") && !member.startsWith("ens") && !member.startsWith("wlan") && !member.startsWith("wlp"), ) .map((member, idx) => ( {member} )) ) : (
{t("network.empty.noVirtualMembers")}
)}
)} ) })()}
)}
{/* Latency Detail Modal */}
) }