diff --git a/AppImage/components/network-flow.tsx b/AppImage/components/network-flow.tsx new file mode 100644 index 00000000..8f6e4807 --- /dev/null +++ b/AppImage/components/network-flow.tsx @@ -0,0 +1,961 @@ +"use client" + +// Network flow diagram — proof of concept. +// Shows NICs → host → bridges → guests with animated rx/tx pulses. +// SVG-based so it scales cleanly and animates with CSS keyframes. + +import { useEffect, useMemo, useRef, useState } from "react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Activity } from "lucide-react" + +// One animated comet-trail pulse. Returned as DATA from the layout +// renderers instead of an SVG string so the parent component can +// render them as JSX s and preserve DOM identity across +// re-renders — otherwise replacing the SVG via innerHTML restarts +// every CSS animation, producing the visible "rebound" effect. +type PulseData = { + d: string + type: "rx" | "tx" + strokeWidth: number + animDur?: number + key: string + // Combined rx+tx rate of the originating guest, in MB/s. Used to + // escalate the head's glow when a guest is a heavy consumer + // (1 MB/s → warm, 30 MB/s → hot). + rate?: number +} + +// ─── Public types — match the /api/network shape ──────────── +type NIC = { + id: string + link: string + rx: number // MB/s + tx: number + status?: "up" | "down" // present → drives the active state instead of the rate +} +type Bridge = { + id: string + parent?: string +} +type Guest = { + id: string + label: string + kind: "lxc" | "vm" | "host" + bridge: string + rx: number + tx: number + offline?: boolean +} +export type NetworkFlowData = { + nics: NIC[] + bridges: Bridge[] + consumers: Guest[] // includes the host pseudo-entry +} + +// ─── Lucide icon paths inlined (we render them as raw ) ── +const ICONS: Record = { + nic: ``, + bridge: ``, + host: ``, + lxc: ``, + vm: ``, +} + +const COLORS = { + host: "var(--amber-500, #f59e0b)", + lxc: "var(--cyan-500, #06b6d4)", + vm: "var(--purple-500, #a855f7)", + nic: "var(--amber-500, #f59e0b)", + bridge: "var(--cyan-500, #06b6d4)", + gray: "#525252", +} + +function fmt(v: number): string { + if (!v) return "0 B/s" + // Below 1 KB/s show B/s — the previous "—" hid real-but-low traffic. + if (v < 0.001) return `${Math.round(v * 1024 * 1024)} B/s` + if (v < 1) return `${(v * 1024).toFixed(0)} KB/s` + return `${v.toFixed(1)} MB/s` +} +// "Active" = running guest, regardless of current rate. Lab hosts can +// have idle guests we still want to see in the topology. The previous +// 50 KB/s threshold hid everything on a quiet host like .1.10. +function activeConsumers(consumers: Guest[]): Guest[] { + return consumers + .filter((c) => !c.offline && c.kind !== "host") + .sort((a, b) => a.id.localeCompare(b.id)) +} +// Bridges without ANY active guest are hidden — the diagram stays +// focused on the parts of the topology that actually carry traffic. +function visibleBridges(bridges: Bridge[], guests: Guest[]): Bridge[] { + const used = new Set(guests.map((c) => c.bridge)) + return bridges.filter((b) => used.has(b.id)) +} + +function svgIcon(kind: string, cx: number, cy: number, size: number, color: string): string { + const half = size / 2 + const path = ICONS[kind] || "" + return ` + ${path} + ` +} + +function orthLink(x1: number, y1: number, x2: number, y2: number, r = 12): string { + if (Math.abs(y2 - y1) < 2) return `M ${x1} ${y1} L ${x2} ${y2}` + const midX = (x1 + x2) / 2 + const dy = y2 > y1 ? 1 : -1 + return [ + `M ${x1} ${y1}`, + `L ${midX - r} ${y1}`, + `Q ${midX} ${y1} ${midX} ${y1 + dy * r}`, + `L ${midX} ${y2 - dy * r}`, + `Q ${midX} ${y2} ${midX + r} ${y2}`, + `L ${x2} ${y2}`, + ].join(" ") +} +function orthLinkV(x1: number, y1: number, x2: number, y2: number, r = 10): string { + if (Math.abs(x2 - x1) < 2) return `M ${x1} ${y1} L ${x2} ${y2}` + const midY = (y1 + y2) / 2 + const dx = x2 > x1 ? 1 : -1 + return [ + `M ${x1} ${y1}`, + `L ${x1} ${midY - r}`, + `Q ${x1} ${midY} ${x1 + dx * r} ${midY}`, + `L ${x2 - dx * r} ${midY}`, + `Q ${x2} ${midY} ${x2} ${midY + r}`, + `L ${x2} ${y2}`, + ].join(" ") +} + +// Build the COMPLETE flow path for one guest: host → bridge → bus → +// tap → guest. One SVG path concatenating each segment so a single +// pulse can travel end-to-end with that guest's own speed. +function buildFullFlowPath( + hostX: number, hostY: number, radHost: number, + bridgesX: number, bridgeY: number, radBridge: number, + busX0: number, busY: number, + cx: number, targetY: number, tapR: number, +): string { + const hbPath = orthLink(hostX + radHost, hostY, bridgesX - radBridge, bridgeY, 14) + const bbPath = orthLink(bridgesX + radBridge, bridgeY, busX0, busY, 12) + const dy = targetY > busY ? 1 : -1 + const tail = `L ${cx - tapR} ${busY} Q ${cx} ${busY} ${cx} ${busY + dy * tapR} L ${cx} ${targetY}` + return `${hbPath} ${bbPath} ${tail}` +} +function curvedTap(cx: number, busY: number, targetY: number, r = 14): string { + const dy = targetY > busY ? 1 : -1 + return `M ${cx - r} ${busY} Q ${cx} ${busY} ${cx} ${busY + dy * r} L ${cx} ${targetY}` +} + +// ─── Renderer: returns full SVG markup string for a given width ── +function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; pulses: PulseData[]; height: number } { + const top = activeConsumers(data.consumers) + const bridges = visibleBridges(data.bridges, top) + const host = data.consumers.find((c) => c.kind === "host") + + const tight = W < 1100 + const nicX = tight ? 70 : 90 + const hostX = tight ? 280 : 340 + const bridgesX = tight ? 470 : 540 + const busX0 = tight ? 580 : 660 + const busXEnd = W - 40 + const busAvail = busXEnd - busX0 + + const radNic = tight ? 26 : 30 + const radHost = tight ? 38 : 44 + const radBridge = tight ? 22 : 26 + const radGuest = 22 + + // More vertical breathing room around each guest. The previous 110 + // crammed circle+label+sub close to the bus; bumping to 135 lets + // the text halo fully clear the trunk pulses behind it. + const topCellH = 135 + // Bot was 135; bumped to 160 because we moved the bot guest 30 px + // further from the bus so the tap beam has room to flow. + const botCellH = 160 + const minCellW = 140 + const maxGuestsPerRow = Math.max(2, Math.floor(busAvail / minCellW)) + + // Sort bridges so the ones WITH guests come first. Idle bridges + // sit compactly at the top (just below the host) and don't push + // anything below them down the canvas. + const bridgesSorted = [...bridges].sort((a, b) => { + const aN = top.filter((c) => c.bridge === a.id).length + const bN = top.filter((c) => c.bridge === b.id).length + return bN - aN + }) + + let cursorY = 40 + const sections = bridgesSorted.map((b) => { + const guests = top.filter((c) => c.bridge === b.id) + const pairsPerBus = maxGuestsPerRow + const totalPairs = Math.ceil(guests.length / 2) + const busRowCount = totalPairs > 0 ? Math.ceil(totalPairs / pairsPerBus) : 0 + const buses: Array<{ + pairs: Array<{ top: Guest | null; bot: Guest | null }> + busY: number + cellW: number + }> = [] + for (let r = 0; r < busRowCount; r++) { + const sliceStart = r * pairsPerBus * 2 + const slice = guests.slice(sliceStart, sliceStart + pairsPerBus * 2) + const pairs: Array<{ top: Guest | null; bot: Guest | null }> = [] + for (let p = 0; p < Math.ceil(slice.length / 2); p++) { + pairs.push({ top: slice[p * 2] || null, bot: slice[p * 2 + 1] || null }) + } + const cellCount = pairs.length + const cellW = Math.max(minCellW, Math.min(220, busAvail / Math.max(1, cellCount))) + const busY = cursorY + topCellH + buses.push({ pairs, busY, cellW }) + cursorY += topCellH + botCellH + } + let sectionTop: number, sectionBot: number, bridgeY: number + if (buses.length > 0) { + sectionTop = buses[0].busY - topCellH + sectionBot = cursorY + } else { + // Compact slot for an idle bridge — just enough for the circle + // + label + sub. Previous 100 px was wasted space and pushed + // sibling bridges far below. + sectionTop = cursorY + sectionBot = cursorY + 60 + cursorY = sectionBot + } + bridgeY = (sectionTop + sectionBot) / 2 + cursorY += 8 + return { b, guests, buses, sectionTop, sectionBot, bridgeY } + }) + + const nicCount = data.nics.length + // Vertical pitch per NIC = circle + label + sub + breathing + // room. Was 118; bumped to 132 because the new 9-px-wide trunk + // line passing near sibling NICs ate into the label/sub area + // visually (the line and the text were "stuck" together). + const nicPitchMin = 132 + const nicMinH = 80 + nicCount * nicPitchMin + const H = Math.max(220, nicMinH, cursorY + 20) + const sectionsTop = sections.length ? sections[0].sectionTop : 40 + const sectionsBot = sections.length ? sections[sections.length - 1].sectionBot : H - 40 + const hostY = sections.length ? (sectionsTop + sectionsBot) / 2 : H / 2 + // Always at least nicPitchMin between NICs so labels/subs never collide. + const nicSpacing = Math.max(nicPitchMin, Math.min(140, H / Math.max(1, nicCount + 1))) + // Center the NIC stack VERTICALLY in the canvas — anchoring it to + // hostY (which itself anchors to the centre of the bridge grid) + // produced an asymmetric column on hosts where the bridge grid + // sits high (e.g. .1.10): first NIC stuck to the top, big empty + // space below the last NIC. + const nicStackH = (nicCount - 1) * nicSpacing + const nicY0 = Math.max(radNic + 8, (H - nicStackH) / 2) + + // Split into two layers so EVERY static line is drawn before EVERY + // pulse. Otherwise a later-drawn static path (e.g. ens4f2's grey + // trunk) covers the pulses of earlier paths sharing its column, + // making them "fade to grey". + const linksStatic: string[] = [] + const linksPulse: PulseData[] = [] + const nodes: string[] = [] + + data.nics.forEach((n, i) => { + const y = nicY0 + i * nicSpacing + // NIC is "active" when the kernel reports the link up — independent + // of current rate (a NIC with 100 B/s of background traffic should + // not render as gray). + const active = n.status ? n.status === "up" : n.rx + n.tx > 0 + const stroke = active ? COLORS.nic : COLORS.gray + nodes.push(` + + ${svgIcon("nic", nicX, y, 18, stroke)} + ${n.id} + ${n.link} + `) + }) + + nodes.push(` + + ${svgIcon("host", hostX, hostY, 24, COLORS.host)} + PROXMOX + ${fmt((host?.rx || 0) + (host?.tx || 0))} + `) + + // Logarithmic mapping rate (MB/s) → pulse animation duration (s), + // then SNAPPED to 7 discrete buckets. Reason: CSS restarts the + // keyframe animation whenever animation-duration changes, so a + // continuously varying duration produces a visible "rebound / + // restart" on every poll. With snapping, duration only changes + // when the rate crosses a threshold — smooth otherwise. + // + // ≤ 250 KB/s → 2.5 s + // ~300 KB/s → 1.8 s + // ~1 MB/s → 1.4 s + // ~5 MB/s → 1.0 s + // ~20 MB/s → 0.75 s + // ~80 MB/s → 0.5 s + // ≥ 500 MB/s → 0.3 s + const SPEED_BUCKETS = [0.3, 0.5, 0.75, 1.0, 1.4, 1.8, 2.5] + const durFor = (rate: number) => { + const raw = 1.8 / Math.log10(1 + Math.max(0, rate) * 30) + if (!isFinite(raw) || raw >= 2.5) return 2.5 + return SPEED_BUCKETS.find((b) => b >= raw) ?? 2.5 + } + + // All structural lines (trunk + taps) share the SAME thickness so + // the diagram reads as one consistent network. The only thing that + // varies per guest is pulse SPEED — faster animation = heavier + // consumer at a glance. + // Single-lane in beam mode (rx and tx share the centre line, passing + // each other in opposite directions). dashes/gradient still apply a + // small perpendicular offset, but 5 px is enough for those too — + // 9 px was leftover from a previous two-lane experiment. + const TRUNK_WIDTH = 5 + const TRUNK_PULSE_WIDTH = 3 + const TAP_WIDTH = TRUNK_WIDTH + const TAP_PULSE_WIDTH = TRUNK_PULSE_WIDTH + sections.forEach((sec) => { + const bridgeRate = sec.guests.reduce((a, c) => a + c.rx + c.tx, 0) + // Trunk activates per-bridge only when at least ONE guest of + // that bridge has > 1.1 KB/s — not the sum, which adds up + // background noise to a meaningless total. + const sumRx = sec.guests.some((c) => c.rx > 0.00108) ? 1 : 0 + const sumTx = sec.guests.some((c) => c.tx > 0.00108) ? 1 : 0 + + nodes.push(` + + ${svgIcon("bridge", bridgesX, sec.bridgeY, 16, COLORS.bridge)} + ${sec.b.id} + ${fmt(bridgeRate)} + `) + + // Trunk lines are STATIC only — every per-guest path will travel + // over them with its own pulse, so no need for a shared trunk + // pulse that would mix all guests into one velocity. + const hbPath = orthLink(hostX + radHost + 5, hostY, bridgesX - radBridge - 5, sec.bridgeY, 14) + linksStatic.push(``) + + sec.buses.forEach((bus) => { + const conn = orthLink(bridgesX + radBridge + 5, sec.bridgeY, busX0, bus.busY, 12) + linksStatic.push(``) + + const cellCount = bus.pairs.length + const lastCellCentre = busX0 + (cellCount - 1) * bus.cellW + bus.cellW / 2 + const tapR = 14 + const busEndX = lastCellCentre - tapR + const busPath = `M ${busX0} ${bus.busY} L ${busEndX} ${bus.busY}` + linksStatic.push(``) + + const emitGuest = (g: Guest, cx: number, circleY: number, labelY: number, subY: number) => { + const stroke = COLORS[g.kind] || COLORS.gray + // Heavy-consumer halo: a concentric ring emanates from the + // circle when total rate ≥ 5 MB/s. Pulses outward and fades, + // making "hot" guests visible at a glance even without + // reading the rate label. + const rateMBs = (g.rx || 0) + (g.tx || 0) + const haloSVG = rateMBs >= 5 + ? `` + : "" + nodes.push(` + ${haloSVG} + + ${svgIcon(g.kind, cx, circleY, 14, stroke)} + ${g.label} + ${fmt(g.rx + g.tx)} + `) + } + + bus.pairs.forEach((pair, col) => { + const cx = busX0 + col * bus.cellW + bus.cellW / 2 + if (pair.top) { + const g = pair.top + const circleY = bus.busY - topCellH + radGuest + 8 + // Text sits BELOW the circle (between circle and bus) — + // this is the locked layout convention. The tap therefore + // must STOP where the text begins, otherwise it would + // visually overlap the label/rate. + const labelY = circleY + radGuest + 14 + const subY = labelY + 14 + // Coming from the bus (below) the tap stops just under the + // SUB text — the first text element it would meet. 8 px of + // padding so the tap visibly approaches the text block + // without crossing it. + const tapTopEnd = subY + 8 + const tap = curvedTap(cx, bus.busY, tapTopEnd, tapR) + linksStatic.push(``) + if (g.rx > 0.005 || g.tx > 0.005) { + // ONE continuous pulse from host all the way to the + // guest. The beam recovers smoothly across trunk, bus + // and tap without resync or speed/size jumps at the + // curves. + const fullD = buildFullFlowPath( + hostX, hostY, radHost, bridgesX, sec.bridgeY, radBridge, + busX0, bus.busY, cx, tapTopEnd, tapR, + ) + const dur = durFor(g.rx + g.tx) + const totalRate = g.rx + g.tx + if (g.rx > 0.005) linksPulse.push({ d: fullD, type: "rx", strokeWidth: TAP_PULSE_WIDTH, animDur: dur, key: `top-rx-${g.id}`, rate: totalRate }) + if (g.tx > 0.005) linksPulse.push({ d: fullD, type: "tx", strokeWidth: TAP_PULSE_WIDTH, animDur: dur, key: `top-tx-${g.id}`, rate: totalRate }) + } + emitGuest(g, cx, circleY, labelY, subY) + } + if (pair.bot) { + const g = pair.bot + // Was bus.busY + 30 + radGuest — moved the guest farther + // from the bus so the tap is long enough for the beam to + // visibly travel down between the curve and the circle. + const circleY = bus.busY + 60 + radGuest + const labelY = circleY + radGuest + 14 + const subY = labelY + 14 + // Coming from the bus (above) the tap stops 5 px above + // the circle's top edge — never crosses the circle ring. + // The label/rate sit BELOW the circle and remain free. + const tapEnd = circleY - radGuest - 5 + const tap = curvedTap(cx, bus.busY, tapEnd, tapR) + linksStatic.push(``) + if (g.rx > 0.005 || g.tx > 0.005) { + const fullD = buildFullFlowPath( + hostX, hostY, radHost, bridgesX, sec.bridgeY, radBridge, + busX0, bus.busY, cx, tapEnd, tapR, + ) + const dur = durFor(g.rx + g.tx) + const totalRate = g.rx + g.tx + if (g.rx > 0.005) linksPulse.push({ d: fullD, type: "rx", strokeWidth: TAP_PULSE_WIDTH, animDur: dur, key: `bot-rx-${g.id}`, rate: totalRate }) + if (g.tx > 0.005) linksPulse.push({ d: fullD, type: "tx", strokeWidth: TAP_PULSE_WIDTH, animDur: dur, key: `bot-tx-${g.id}`, rate: totalRate }) + } + emitGuest(g, cx, circleY, labelY, subY) + } + }) + }) + }) + + // Each NIC's line animates only when that NIC's own rate is above + // the threshold. Was 5 KB/s, which flickered on/off when a NIC + // hovered around that mark; 2 KB/s sits clearly above the + // background noise floor so the animation stays solid. + const NIC_PULSE_MIN_MBPS = 0.002 // 2 KB/s + + data.nics.forEach((n, i) => { + const y = nicY0 + i * nicSpacing + const active = n.status ? n.status === "up" : n.rx + n.tx > 0 + if (!active) return + // Add a 5-px gap between each circle and the trunk line so the + // 9-px-wide static lane doesn't visually overlap the node ring. + const path = orthLink(nicX + radNic + 5, y, hostX - radHost - 5, hostY, 14) + linksStatic.push(``) + // Per-NIC pulse speed mirrors the per-guest logic: more traffic + // on this NIC → faster pulse on its line. Each NIC reads + // independently from the others. + const nicDur = durFor(n.rx + n.tx) + if (n.rx > NIC_PULSE_MIN_MBPS) linksPulse.push({ d: path, type: "rx", strokeWidth: TRUNK_PULSE_WIDTH, animDur: nicDur, key: `nic-rx-${n.id}` }) + if (n.tx > NIC_PULSE_MIN_MBPS) linksPulse.push({ d: path, type: "tx", strokeWidth: TRUNK_PULSE_WIDTH, animDur: nicDur, key: `nic-tx-${n.id}` }) + }) + + // Static svg = lines + nodes. Pulses are returned SEPARATELY as + // data so NetworkFlow can render them as JSX s. That keeps + // each pulse's DOM node stable across re-renders → CSS animations + // never restart unless that specific pulse changes. + return { + svg: linksStatic.join("") + nodes.join(""), + pulses: linksPulse, + height: H, + } +} + +// Mobile/tree layout. Approved design (mirrors _simulations/network-flow.html): +// NIC → PROXMOX → vmbr → VM/LXC, each level in its OWN x column. +// The trunk (host) is ONLY on HOST_X; bridges are in BRIDGE_X (a +// separate column → trunk doesn't pass through them); the bridge's +// own sub-trunk lives at SUB_TRUNK_X and fans out to its guests in +// an arc (some above, some below the bridge.cy). All elbows use Q +// curves; no sharp 90° corners anywhere. +function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData[]; height: number; viewBox: string } { + // Smaller W → SVG scales up on the mobile screen, nodes look bigger. + // All four x-columns evenly spaced so curve→target distances are + // homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px). + const W = 380 + const top = activeConsumers(data.consumers) + const bridges = visibleBridges(data.bridges, top) + const host = data.consumers.find((c) => c.kind === "host") + + const linksStatic: string[] = [] + const linksPulse: PulseData[] = [] + const nodes: string[] = [] + + // Layout constants — kept inline so the function is self-contained. + // Spacing model: + // - HOST_X → BRIDGE_X → SUB_TRUNK_X use a fixed inter-column + // step (homogeneous between trunk, bridge and its spine). + // - GUEST_X sits at the MIDDLE between the bridge's sub-trunk + // (the reference line the guests actually hang from) and the + // right edge of the canvas. So the spine-to-guest leg can be + // longer than the others, which is exactly what we want — the + // stub into each guest visibly stretches before the curve. + const HOST_X = 56 + const COL_STEP = 60 + const BRIDGE_X = HOST_X + COL_STEP + const SUB_TRUNK_X = HOST_X + COL_STEP * 2 + const GUEST_X = Math.round((SUB_TRUNK_X + W) / 2) + const RAD_HOST = 26 + const RAD_NIC = 22 + const RAD_BRIDGE = 24 + const RAD_GUEST = 20 + const NIC_TOP_Y = 6 + const NIC_PITCH_X_PREFERRED = 88 + const NIC_LEFT_MARGIN = 8 + // NIC line geometry, split into TWO independent quantities so we + // can tune them separately: + // NIC_PATH_START_OFFSET — gap between the circle bottom and where + // the line BEGINS (must clear the sub text + // below the circle so the line doesn't + // cross the rate label). + // NIC_VERTICAL_LEG — actual length of the vertical drop + // BEFORE the curve to the convergence row. + const NIC_PATH_START_OFFSET = 46 // clears SUB_OFFSET_Y + text height + const NIC_VERTICAL_LEG = 56 + const HOST_GAP_FROM_CONVERGE = 56 + const GUEST_ROW_H = 100 // más separación vertical entre + // guests para que sub no toque + // el círculo del siguiente + const BRIDGE_PITCH_PAD = 36 + // Vertical positions of the label and the sub (rate) BELOW each + // node's circle. Both grew when the font went up to 12.5 px; this + // matched gap keeps them readable without overlap. + const LABEL_OFFSET_Y = 16 // gap circle bottom → label baseline + const SUB_OFFSET_Y = 32 // gap circle bottom → sub baseline + // (16 px between label and sub) + const CORNER_R = 12 + const PULSE_THRESHOLD = 0.005 + + // Orthogonal NIC→host path with an EXPLICIT convergence y. All NICs + // (regardless of which row they sit in) drop vertically until they + // hit `convergeY`, then run horizontally to HOST_X and drop to + // host top. This guarantees that a row-0 NIC's line never crosses + // the circle of a row-1 NIC below it. + const vPath = (x1: number, y1: number, x2: number, y2: number, + convergeY: number, r = CORNER_R): string => { + if (Math.abs(x2 - x1) < 2) return `M ${x1} ${y1} L ${x2} ${y2}` + const midY = convergeY + const dx = x2 > x1 ? 1 : -1 + return [ + `M ${x1} ${y1}`, + `L ${x1} ${midY - r}`, + `Q ${x1} ${midY} ${x1 + dx * r} ${midY}`, + `L ${x2 - dx * r} ${midY}`, + `Q ${x2} ${midY} ${x2} ${midY + r}`, + `L ${x2} ${y2}`, + ].join(" ") + } + // Single-elbow path (host trunk → bridge left edge, or any "drop + // then turn right" connector). Q corner where the verticals meet. + const elbow = (x1: number, y1: number, x2: number, y2: number, r = CORNER_R): string => { + const dy = y2 > y1 ? 1 : -1 + const dx = x2 > x1 ? 1 : -1 + return [ + `M ${x1} ${y1}`, + `L ${x1} ${y2 - dy * r}`, + `Q ${x1} ${y2} ${x1 + dx * r} ${y2}`, + `L ${x2} ${y2}`, + ].join(" ") + } + + // ─── 1. NICs ALWAYS in a single horizontal row ──────────── + // No wrap to a second row — multiple rows cause NIC paths to cross + // the circles of NICs sitting underneath them. Instead, the pitch + // shrinks dynamically when there are many NICs so they all fit. + // Row is centred around HOST_X (not the canvas) so a single NIC + // sits directly above the host — straight vertical path, no weird + // S-curve. When the row is too wide to be centred there without + // falling off the canvas, it slides right (or left, clamped). + const nicCount = data.nics.length + const fitWidth = W - 2 * (NIC_LEFT_MARGIN + RAD_NIC) + const dynamicPitch = nicCount > 1 + ? Math.min(NIC_PITCH_X_PREFERRED, fitWidth / (nicCount - 1)) + : 0 + const rowWidth = (nicCount - 1) * dynamicPitch + const minStart = NIC_LEFT_MARGIN + RAD_NIC + const maxStart = W - NIC_LEFT_MARGIN - RAD_NIC - rowWidth + const idealStart = HOST_X - rowWidth / 2 + const startX = Math.max(minStart, Math.min(maxStart, idealStart)) + const nicCy = NIC_TOP_Y + RAD_NIC + const nicGeom = data.nics.map((n, i) => ({ + n, cx: startX + i * dynamicPitch, cy: nicCy, r: RAD_NIC, + })) + + // Convergence row: single y-line BELOW every NIC at which all + // drops bend horizontally toward HOST_X. The vertical "leg" of the + // NIC path (NIC_VERTICAL_LEG) is what makes the line visibly run + // a stretch BEFORE turning into the curve. + const nicPathStartY = nicCy + RAD_NIC + NIC_PATH_START_OFFSET + const convergeY = nicPathStartY + NIC_VERTICAL_LEG + const hostY = convergeY + HOST_GAP_FROM_CONVERGE + RAD_HOST + const hostTopY = hostY - RAD_HOST - 4 + + // NIC → host orth+Q via shared convergence row. + nicGeom.forEach(({ n, cx, cy, r }) => { + const startX = cx, startY = cy + r + NIC_PATH_START_OFFSET + const pathD = vPath(startX, startY, HOST_X, hostTopY, convergeY) + linksStatic.push(``) + const active = n.status ? n.status === "up" : n.rx + n.tx > 0 + if (active) { + const rate = n.rx + n.tx + if (n.rx > 0.001) linksPulse.push({ d: pathD, type: "rx", strokeWidth: 3, key: `m-nic-rx-${n.id}`, rate }) + if (n.tx > 0.001) linksPulse.push({ d: pathD, type: "tx", strokeWidth: 3, key: `m-nic-tx-${n.id}`, rate }) + } + const isDown = n.status === "down" + const color = isDown ? COLORS.gray : COLORS.nic + nodes.push(` + + ${svgIcon("nic", cx, cy, 13, color)} + ${n.id} + ${isDown ? "down" : n.link} + `) + }) + + // ─── 2. Host PROXMOX — LEFT column, label/sub below ────── + nodes.push(` + + ${svgIcon("host", HOST_X, hostY, 20, COLORS.host)} + PROXMOX + ${fmt((host?.rx || 0) + (host?.tx || 0))} + `) + + // ─── 3. Bridges + guests, ARC layout around each bridge ── + // Each bridge has its OWN sub-trunk at SUB_TRUNK_X. Guests are + // distributed symmetrically around the bridge.cy (some above, some + // below) — fans out in an arc, uses the vertical gap efficiently. + let cursorY = hostY + RAD_HOST + 40 + type BridgeSlot = { + b: Bridge + cx: number; cy: number; r: number + guests: Array<{ g: Guest; cx: number; cy: number; r: number }> + rate: number + sumRx: number + sumTx: number + } + const bridgePos: BridgeSlot[] = [] + + bridges.forEach((b) => { + const guestsOfBridge = top.filter((c) => c.bridge === b.id && c.kind !== "host") + const rate = guestsOfBridge.reduce((a, g) => a + g.rx + g.tx, 0) + const sumRx = guestsOfBridge.reduce((a, g) => a + g.rx, 0) + const sumTx = guestsOfBridge.reduce((a, g) => a + g.tx, 0) + const N = guestsOfBridge.length + const mid = (N - 1) / 2 + const topSpan = mid * GUEST_ROW_H + const botSpan = (N - 1 - mid) * GUEST_ROW_H + // Bridge sits below cursorY with enough top clearance for the + // top-arc guest's label/sub. + const topClearance = topSpan + 24 + const bCy = cursorY + Math.max(RAD_BRIDGE + 6, topClearance) + const guests = guestsOfBridge.map((g, gi) => ({ + g, cx: GUEST_X, r: RAD_GUEST, + cy: bCy + (gi - mid) * GUEST_ROW_H, + })) + bridgePos.push({ b, cx: BRIDGE_X, cy: bCy, r: RAD_BRIDGE, guests, + rate, sumRx, sumTx }) + const bottom = N > 0 ? bCy + botSpan + RAD_GUEST + 22 : bCy + RAD_BRIDGE + 22 + cursorY = bottom + BRIDGE_PITCH_PAD + }) + + // Host trunk — vertical line at HOST_X from host bottom down to the + // last bridge's row. The trunk does NOT pass through any bridge: + // bridges live in BRIDGE_X column. + // Clears the host's sub label (rate "X KB/s") so the trunk doesn't + // start touching the text. SUB_OFFSET_Y + ~8 px breathing room. + const hostTrunkStartY = hostY + RAD_HOST + SUB_OFFSET_Y + 12 + if (bridgePos.length > 0) { + const last = bridgePos[bridgePos.length - 1] + linksStatic.push(``) + } + + // Per-bridge: host→bridge branch + sub-tree to guests. + bridgePos.forEach(({ b, cx, cy, r, guests, rate, sumRx, sumTx }) => { + // Host → bridge branch (elbow with Q corner) into bridge LEFT edge. + const branchHB = elbow(HOST_X, hostTrunkStartY, cx - r - 4, cy) + linksStatic.push(``) + if (rate > PULSE_THRESHOLD) { + if (sumRx > PULSE_THRESHOLD) linksPulse.push({ d: branchHB, type: "rx", strokeWidth: 3, key: `m-br-rx-${b.id}`, rate }) + if (sumTx > PULSE_THRESHOLD) linksPulse.push({ d: branchHB, type: "tx", strokeWidth: 3, key: `m-br-tx-${b.id}`, rate }) + } + + // Bridge node — label/sub BELOW. + nodes.push(` + + ${svgIcon("bridge", cx, cy, 13, COLORS.bridge)} + ${b.id} + ${fmt(rate)} + `) + + if (guests.length === 0) return + + // Per-guest path: exits bridge.right, runs horizontal to SUB_TRUNK_X, + // Q-curves into the vertical spine, rises/falls to the guest's row, + // Q-curves into the guest. Every elbow is Q — no sharp corners. + // Multiple paths overlap on the vertical spine and visually read as + // one continuous line. + const spineEnterX = cx + r + 4 + const spineX = SUB_TRUNK_X + guests.forEach(({ g, cx: gCx, cy: gCy, r: gR }) => { + const enterX = gCx - gR - 4 + let d: string + if (Math.abs(gCy - cy) < 2) { + d = `M ${spineEnterX} ${cy} L ${enterX} ${gCy}` + } else { + const dy = gCy > cy ? 1 : -1 + d = [ + `M ${spineEnterX} ${cy}`, + `L ${spineX - CORNER_R} ${cy}`, + `Q ${spineX} ${cy} ${spineX} ${cy + dy * CORNER_R}`, + `L ${spineX} ${gCy - dy * CORNER_R}`, + `Q ${spineX} ${gCy} ${spineX + CORNER_R} ${gCy}`, + `L ${enterX} ${gCy}`, + ].join(" ") + } + linksStatic.push(``) + const gRate = g.rx + g.tx + if (gRate > PULSE_THRESHOLD) { + if (g.rx > PULSE_THRESHOLD) linksPulse.push({ d, type: "rx", strokeWidth: 3, key: `m-g-rx-${b.id}-${g.id}`, rate: gRate }) + if (g.tx > PULSE_THRESHOLD) linksPulse.push({ d, type: "tx", strokeWidth: 3, key: `m-g-tx-${b.id}-${g.id}`, rate: gRate }) + } + const stroke = COLORS[g.kind] || COLORS.gray + const haloSVG = gRate >= 5 + ? `` + : "" + nodes.push(` + ${haloSVG} + + ${svgIcon(g.kind, gCx, gCy, 13, stroke)} + ${g.label} + ${fmt(gRate)} + `) + }) + }) + + const lastSlot = bridgePos[bridgePos.length - 1] + const lastBottomY = lastSlot + ? (lastSlot.guests.length + ? lastSlot.guests[lastSlot.guests.length - 1].cy + RAD_GUEST + : lastSlot.cy + RAD_BRIDGE) + : hostY + RAD_HOST + const H = lastBottomY + 36 + + return { + svg: linksStatic.join("") + nodes.join(""), + pulses: linksPulse, + height: H, + viewBox: `0 0 ${W} ${H}`, + } +} + +// ─── React component ──────────────────────────────────────── +export function NetworkFlow({ + data, onNodeClick, +}: { + data: NetworkFlowData + // Fires when the user taps/clicks any circle in the diagram. The + // parent component looks up the matching NetworkInterface and + // opens the per-interface details modal. + onNodeClick?: (name: string, kind: "nic" | "host" | "bridge" | "lxc" | "vm") => void +}) { + const ref = useRef(null) + const [width, setWidth] = useState(1320) + const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop") + + useEffect(() => { + const update = () => { + const w = ref.current?.offsetWidth || window.innerWidth + setWidth(w) + if (w < 700) setMode("mobile") + else if (w < 1100) setMode("tablet") + else setMode("desktop") + } + update() + window.addEventListener("resize", update) + return () => window.removeEventListener("resize", update) + }, []) + + // Stable memo key: the SVG is regenerated ONLY when something + // structurally relevant changes (mode, width, who's online, + // who's linked where, or a rate crossed into a different speed + // bucket). Exact rate values are excluded so micro-fluctuations + // between polls don't tear down the whole SVG (which would + // restart every CSS animation — the "rebound/reset" effect). + const memoKey = useMemo(() => { + const SB = [0.3, 0.5, 0.75, 1.0, 1.4, 1.8, 2.5] + const bucket = (rate: number) => { + const raw = 1.8 / Math.log10(1 + Math.max(0, rate || 0) * 30) + if (!isFinite(raw) || raw >= 2.5) return 2.5 + return SB.find((b) => b >= raw) ?? 2.5 + } + const sig = (r: number) => (r > 0.005 ? 1 : 0) + const nics = data.nics.map((n) => + `${n.id}:${n.status || ""}:${bucket(n.rx)}:${bucket(n.tx)}:${sig(n.rx)}:${sig(n.tx)}` + ).join("|") + const guests = data.consumers.map((c) => + `${c.id}:${c.bridge}:${c.kind}:${c.offline ? 1 : 0}:${bucket(c.rx)}:${bucket(c.tx)}:${sig(c.rx)}:${sig(c.tx)}` + ).join("|") + const bridges = data.bridges.map((b) => `${b.id}:${b.parent || ""}`).join("|") + return `${mode}|${width}|${nics}||${guests}||${bridges}` + }, [data, mode, width]) + + const { svgContent, pulses, viewBox, height } = useMemo(() => { + if (mode === "mobile") { + const out = renderVertical(data) + return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height } + } + const W = mode === "tablet" ? 1100 : 1320 + const out = renderHorizontal(data, W) + return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [memoKey]) + + return ( + + + + + Network Flow (PoC) + + + +
+ +
{ + // Event delegation — nodes are rendered inside an + // innerHTML SVG string (not JSX), so React can't attach + // per-node handlers directly. Each clickable node carries + // data-node-id + data-node-kind; we find the closest one + // from the click target. + if (!onNodeClick) return + const t = e.target as Element + const hit = t.closest?.("[data-node-id]") as Element | null + if (!hit) return + const id = hit.getAttribute("data-node-id") || "" + const kind = (hit.getAttribute("data-node-kind") || "") as + "nic" | "host" | "bridge" | "lxc" | "vm" + if (id && kind) onNodeClick(id, kind) + }} + > + {/* Layer 1 — static structure + nodes + text labels. + Replaced via innerHTML; cheap and only refreshes on + structural/bucket changes (see memoKey). */} + + {/* Layer 2 — animated pulses, rendered as JSX so each + path keeps its DOM identity across re-renders. CSS + animations restart ONLY when this specific path's + animation-duration changes, not on every poll. */} + + {pulses.map((p) => { + // Comet trail: dim base + three trailing copies that + // progressively lag in space and fade out + bright + // head on top. POSITIVE animation-delay shifts each + // trail BEHIND the head by that fraction of the cycle. + // pathLength=100 normalises the beam length across + // all path sizes (so a short tap shows the same visible + // beam as a long trunk). + const baseClass = p.type === "rx" ? "nf-beam-base-rx" : "nf-beam-base-tx" + const tailClass = p.type === "rx" ? "nf-beam-rx" : "nf-beam-tx" + const headClass = p.type === "rx" ? "nf-beam-head-rx" : "nf-beam-head-tx" + const dur = p.animDur ?? 1.5 + const t1 = `${(dur * 0.06).toFixed(3)}s` + const t2 = `${(dur * 0.13).toFixed(3)}s` + const t3 = `${(dur * 0.22).toFixed(3)}s` + // Head glow tier — escalates with the guest's rate. + const intensity = (p.rate || 0) >= 30 + ? "nf-beam-head-hot" + : (p.rate || 0) >= 1 ? "nf-beam-head-warm" : "" + const headWidth = (p.rate || 0) >= 30 ? p.strokeWidth + 1 : p.strokeWidth + return ( + + + + + + + + ) + })} + +
+
+
+
+ ) +} diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx index 73e8ae09..162f4eb4 100644 --- a/AppImage/components/network-metrics.tsx +++ b/AppImage/components/network-metrics.tsx @@ -4,9 +4,10 @@ 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 } from 'lucide-react' +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" @@ -69,6 +70,14 @@ interface NetworkInterface { // 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 bond_slaves?: string[] bond_active_slave?: string | null @@ -146,6 +155,13 @@ function renderPhysicalInterfaceCardV2( const firstAddr = iface.addresses?.[0]?.ip || "" const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1) const speedStr = formatSpeed(iface.speed) + // 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) + : "" + const bridgesUsing = iface.used_by_bridges || [] const errIn = iface.errors_in ?? 0 const errOut = iface.errors_out ?? 0 const dropIn = iface.drops_in ?? 0 @@ -176,11 +192,16 @@ function renderPhysicalInterfaceCardV2( - {/* Header L2: speed | duplex (compact, live link info). */} + {/* Header L2: speed + max (when negotiated < hw) | duplex. */}
{speedStr} + {maxSpeedStr && ( + + · max {maxSpeedStr} + + )} {iface.duplex || "—"}
@@ -204,6 +225,16 @@ function renderPhysicalInterfaceCardV2( MTU {iface.mtu || "—"} + {bridgesUsing.length > 0 && ( +
+ + 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 "—" @@ -364,7 +395,10 @@ export function NetworkMetrics() { error, isLoading, } = useSWR("/api/network", fetcher, { - refreshInterval: 15000, + // 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, }) @@ -653,13 +687,16 @@ export function NetworkMetrics() { {/* Latency Card with Sparkline */} - setLatencyModalOpen(true)} > Network Latency - +
+ + +
@@ -744,6 +781,87 @@ export function NetworkMetrics() { + {/* 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.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), + status: (p.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 */} @@ -791,7 +909,7 @@ export function NetworkMetrics() { > {/* First row: Icon, Name, Type Badge, Physical Interface (responsive), Status */}
- +
{interface_.name}
@@ -907,14 +1025,14 @@ export function NetworkMetrics() { > {/* First row: Icon, Name, VM/LXC Badge, VM Name, Status */}
- +
{interface_.name}
{vmTypeBadge.label} {interface_.vm_name && ( -
→ {interface_.vm_name}
+
→ {interface_.vm_name}
)}
setSelectedInterface(null)}> - + diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index ef409571..d6229a74 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -541,7 +541,12 @@ export function SystemOverview() { > Temperature - +
+ + {systemData.temperature > 0 && ( + + )} +
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 4025bb1b..4896e6c2 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -8,7 +8,7 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Textarea } from "./ui/textarea" @@ -1980,11 +1980,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { />
- {/* Disk I/O */} + {/* Disk I/O — cumulative counters from Proxmox + API: bytes read/written since the VM/LXC + was last started, not a per-period rate. */}
- Disk I/O + Disk I/O (since boot)
@@ -1998,11 +2000,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
- {/* Network I/O */} + {/* Network I/O — cumulative counters from + Proxmox API: bytes in/out since the VM/LXC + was last started. */}
- Network I/O + Network I/O (since boot)
@@ -2542,19 +2546,86 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { Network
- {/* Network Interfaces with proper keys */} + {/* Network Interfaces with proper keys. + Renders BOTH a human-readable + breakdown (bridge, IP, gw, MAC, + host iface) AND the raw config + string so power users still see + the underlying Proxmox config. */} {Object.keys(vmDetails.config) .filter((key) => key.match(/^net\d+$/)) - .map((netKey) => ( -
-
- Network Interface {netKey.replace("net", "")} + .map((netKey) => { + const raw = vmDetails.config[netKey] as string + // Parse "name=eth0,bridge=vmbr0,gw=1.2.3.4,..." + const parsed: Record = {} + raw.split(",").forEach((pair) => { + const eq = pair.indexOf("=") + if (eq > 0) { + parsed[pair.slice(0, eq).trim()] = + pair.slice(eq + 1).trim() + } else if (pair && !parsed.model) { + // bare "virtio" / "e1000" → NIC model + parsed.model = pair.trim() + } + }) + const idx = netKey.replace("net", "") + // For VMs, the host-side iface is + // tapi; for LXC it's + // vethi. Surface it so + // users can correlate with the + // "VM & LXC Network Interfaces" + // card on the network page. + const hostIface = selectedVM.type === "lxc" + ? `veth${selectedVM.vmid}i${idx}` + : `tap${selectedVM.vmid}i${idx}` + const Field = ({ label, value, mono }: + { label: string; value: string; mono?: boolean }) => ( +
+ {label} + {value}
-
- {vmDetails.config[netKey]} + ) + return ( +
+
+ + + Network Interface {idx} + + {parsed.name && ( + + ({parsed.name}) + + )} + + host: {hostIface} + +
+
+ {parsed.bridge && } + {parsed.ip && } + {parsed.ip6 && } + {parsed.gw && } + {parsed.gw6 && } + {parsed.hwaddr && } + {parsed.virtio && } + {parsed.e1000 && } + {parsed.type && } + {parsed.tag && } + {parsed.mtu && } + {parsed.rate && } + {parsed.firewall === "1" && } +
+
+ Raw config +
+ {raw} +
+
-
- ))} + ) + })}
{vmDetails.config.nameserver && (
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3d04eba0..c440522d 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -4435,55 +4435,212 @@ def api_storage_observations(): except Exception as e: return jsonify({'observations': [], 'error': str(e)}), 500 -# ─── Per-NIC live rate cache ──────────────────────────────────────── -# Module-scope so it survives between requests but disappears on -# service restart (rate after restart re-bootstraps on the second -# request — matches operator expectations). Guarded by a lock so -# concurrent /api/network calls from different tabs don't race each -# other into a corrupt previous snapshot. +# ─── Background sampler ──────────────────────────────────────── +# A daemon thread polls psutil every 2 s and feeds the per-NIC rate +# cache. This way the very first /api/network HTTP request after a +# page load already has enough history to return a rate — no more +# 15-20 s "waiting for data" on the Network Flow card. +def _net_rate_sampler_loop(): + # Fast bootstrap: take TWO samples 1 s apart so the very first + # /api/network response (which may arrive in under a second of the + # service starting) already has a valid delta to derive rates + # from. After that, normal 2 s cadence. + try: + io = psutil.net_io_counters(pernic=True) + _compute_per_nic_rates_live(io) + time.sleep(1.0) + io = psutil.net_io_counters(pernic=True) + _compute_per_nic_rates_live(io) + except Exception: + pass + while True: + try: + io = psutil.net_io_counters(pernic=True) + _compute_per_nic_rates_live(io) + except Exception: + pass + time.sleep(2.0) + +_NET_RATE_SAMPLER_STARTED = False +def _ensure_net_rate_sampler(): + global _NET_RATE_SAMPLER_STARTED + if _NET_RATE_SAMPLER_STARTED: + return + _NET_RATE_SAMPLER_STARTED = True + t = _net_threading.Thread(target=_net_rate_sampler_loop, daemon=True) + t.start() + + +# Start the sampler immediately at module load — the dashboard can +# now ask for rates from its very first poll, instead of waiting for +# its own request to bootstrap the cache. +try: + _ensure_net_rate_sampler() +except Exception: + pass + + +# ─── Per-NIC live rate (moving window) ────────────────────────── +# Keep the last ~30s of byte counters and derive the rate from the +# oldest-vs-newest sample. This is robust against bursty traffic +# (where a single poll may see 0 bytes between packets) — the window +# spans multiple poll periods so the rate stays above zero as long +# as traffic flowed at any point inside it. import threading as _net_threading -_NET_RATE_CACHE = {'time': 0.0, 'per_nic': {}} _NET_RATE_LOCK = _net_threading.Lock() -# Skip rate computation when the cache is older than this — a paused -# tab returning after a long sleep would otherwise read a delta over -# minutes/hours and surface it as "the current rate". -_NET_RATE_MAX_AGE_S = 60.0 +_NET_RATE_HISTORY = {} # iface → list[(t, rx_bytes, tx_bytes)] +_NET_RATE_WINDOW_TARGET_S = 5.0 # rate is averaged over this many seconds + # — short window = near-instant feedback + # so a Network Flow display matches what + # the guest itself reports in real time. +_NET_RATE_WINDOW_MAX_S = 60.0 # samples older than this are discarded +_NET_RATE_MAX_SAMPLES = 60 +_NET_RATE_STICKY = {} # iface → {rx: last_nonzero_rx, tx, t} +_NET_RATE_STICKY_TTL_S = 3600.0 # 1 h — effectively keep the last seen + # non-zero rate visible until the iface + # disappears (VM stopped) or 1 h passes. + # The Network Flow card must NEVER show + # a "live" guest dropping to 0 just + # because there's a brief silence. def _compute_per_nic_rates_live(net_io_per_nic): - """Return ``{iface: {rx_Bps, tx_Bps}}`` for the window between the - previous call and now. - - First call after service start (or after a >60 s gap) returns an - empty dict — the next call has a valid delta. Counter wraps (NIC - reset) are clamped to 0 instead of surfacing a negative rate. + """Return ``{iface: {rx_Bps, tx_Bps}}`` averaged over ~30 s of + history. If the calculated rate dips to 0 but we saw nonzero + traffic within the last STICKY_TTL seconds, return that recent + value instead — bursty NICs (especially the WAN uplink) flicker + between bursts and we don't want the diagram blinking to 0. """ now = time.time() rates = {} with _NET_RATE_LOCK: - prev_time = _NET_RATE_CACHE.get('time', 0.0) - prev_data = _NET_RATE_CACHE.get('per_nic', {}) - elapsed = now - prev_time if prev_time > 0 else 0 - use_delta = 0 < elapsed <= _NET_RATE_MAX_AGE_S - new_data = {} for iface, io in net_io_per_nic.items(): - new_data[iface] = { - 'bytes_recv': io.bytes_recv, - 'bytes_sent': io.bytes_sent, - } - if not use_delta or iface not in prev_data: + hist = _NET_RATE_HISTORY.setdefault(iface, []) + cutoff = now - _NET_RATE_WINDOW_MAX_S + hist = [s for s in hist if s[0] >= cutoff] + hist.append((now, io.bytes_recv, io.bytes_sent)) + if len(hist) > _NET_RATE_MAX_SAMPLES: + hist = hist[-_NET_RATE_MAX_SAMPLES:] + _NET_RATE_HISTORY[iface] = hist + if len(hist) < 2: continue - prev = prev_data[iface] - rx_delta = max(0, io.bytes_recv - prev['bytes_recv']) - tx_delta = max(0, io.bytes_sent - prev['bytes_sent']) + # Find the first sample that is at least WINDOW_TARGET_S + # old. If none exist (e.g. service just restarted, history + # is still warming up), fall back to the OLDEST available + # sample — gives the widest possible window so the rate is + # still computable instead of yielding window=0. + # + # Bug fix: the previous loop set ``oldest = s`` on EVERY + # iteration, so when no sample qualified it ended up + # pointing to the newest one — window=0 → continue → no + # rate ever published until 30 s of history accumulated. + # Result: visible "drops to 0" each time the cache thinned. + oldest = hist[0] + for s in hist: + if now - s[0] >= _NET_RATE_WINDOW_TARGET_S: + oldest = s + break + window = now - oldest[0] + if window <= 0: + continue + rx_rate = max(0, io.bytes_recv - oldest[1]) / window + tx_rate = max(0, io.bytes_sent - oldest[2]) / window + # Sticky last-nonzero values to avoid 0-flicker. + sticky = _NET_RATE_STICKY.setdefault(iface, {'rx': 0.0, 'tx': 0.0, 't': 0.0}) + if rx_rate > 0: + sticky['rx'] = rx_rate + sticky['t'] = now + elif now - sticky['t'] < _NET_RATE_STICKY_TTL_S and sticky['rx'] > 0: + rx_rate = sticky['rx'] + if tx_rate > 0: + sticky['tx'] = tx_rate + sticky['t'] = now + elif now - sticky['t'] < _NET_RATE_STICKY_TTL_S and sticky['tx'] > 0: + tx_rate = sticky['tx'] rates[iface] = { - 'rx_Bps': round(rx_delta / elapsed, 2), - 'tx_Bps': round(tx_delta / elapsed, 2), + 'rx_Bps': round(rx_rate, 2), + 'tx_Bps': round(tx_rate, 2), } - _NET_RATE_CACHE['time'] = now - _NET_RATE_CACHE['per_nic'] = new_data return rates +# ─── Resolve the bridge each interface belongs to ───────────── +# /sys/class/net//master is a symlink pointing to the master +# device (bridge or bond). This is the kernel's authoritative source. +def _iface_master(iface_name): + try: + master_path = f'/sys/class/net/{iface_name}/master' + if os.path.islink(master_path): + return os.path.basename(os.readlink(master_path)) + except Exception: + pass + return None + + +# Walks through Proxmox firewall bridges (fwbrXXX) to reach the real +# vmbr. With firewall on, a VM's veth100i0 lands on fwbr100i0; the +# real vmbr is reached via the matching fwpr100p0 sitting in vmbr0. +# Chain: veth/tap → fwbr (intermediate) → fwpr (peer) → vmbr (real). +def _resolve_bridge_owner(iface_name): + import re + master = _iface_master(iface_name) + if not master: + return None + if master.startswith('fwbr'): + fwpr = re.sub(r'^fwbr', 'fwpr', master) + fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr) + try: + if os.path.exists(f'/sys/class/net/{fwpr}'): + real = _iface_master(fwpr) + if real: + return real + except Exception: + pass + return master + + +# ─── ethtool: max link speed per NIC ──────────────────────────── +# Parses "Supported link modes:" — the highest baseT value is the +# hardware ceiling. Cached per-iface because subprocess + parse runs +# ~150-500 ms per NIC, and Supported link modes essentially never +# changes for a given NIC at runtime. +_ETHTOOL_MAX_CACHE = {} +def _ethtool_max_speed(iface_name): + if iface_name in _ETHTOOL_MAX_CACHE: + return _ETHTOOL_MAX_CACHE[iface_name] + try: + import subprocess, re + result = subprocess.run( + ['ethtool', iface_name], + capture_output=True, text=True, timeout=2, + ) + if result.returncode != 0: + _ETHTOOL_MAX_CACHE[iface_name] = 0 + return 0 + max_speed = 0 + in_supported = False + for line in result.stdout.splitlines(): + stripped = line.strip() + if 'Supported link modes' in stripped: + in_supported = True + tail = stripped.split(':', 1)[1].strip() if ':' in stripped else '' + tokens = tail.split() + elif in_supported and line.startswith((' ', '\t')) and ':' not in stripped: + tokens = stripped.split() + else: + in_supported = False + tokens = [] + for tok in tokens: + m = re.match(r'(\d+)base', tok) + if m: + val = int(m.group(1)) + if val > max_speed: + max_speed = val + _ETHTOOL_MAX_CACHE[iface_name] = max_speed + return max_speed + except Exception: + return 0 + + def get_interface_type(interface_name): """Detect the type of network interface""" try: @@ -4693,6 +4850,7 @@ def get_network_info(): # the user actually wants to see — no extra sleep needed. # Cache lives at module scope; staleness is bounded so a paused # tab + late refresh doesn't return absurd jumps as "rate". + _ensure_net_rate_sampler() per_nic_rates = _compute_per_nic_rates_live(net_io_per_nic) physical_active_count = 0 @@ -4795,6 +4953,22 @@ def get_network_info(): interface_info['rx_Bps'] = rate['rx_Bps'] interface_info['tx_Bps'] = rate['tx_Bps'] + if interface_type == 'physical': + # Hardware ceiling from ethtool; UI shows it next to the + # negotiated speed when the two differ. + max_speed = _ethtool_max_speed(interface_name) + if max_speed: + interface_info['max_speed'] = max_speed + + # Master device (bridge or bond) — for tap*/veth*/bond + # interfaces this is the bridge they're enslaved to. + # We walk through any intermediate fwbr to reach the real + # vmbr so a guest behind the Proxmox firewall still lands + # under its true bridge in the Network Flow diagram. + master = _resolve_bridge_owner(interface_name) + if master: + interface_info['bridge_owner'] = master + if interface_type == 'bond': bond_info = get_bond_info(interface_name) interface_info['bond_mode'] = bond_info['mode'] @@ -4827,6 +5001,24 @@ def get_network_info(): network_data['bridge_total_count'] = bridge_total_count network_data['vm_lxc_active_count'] = vm_lxc_active_count network_data['vm_lxc_total_count'] = vm_lxc_total_count + + # Map physical NICs ↔ bridges using them. For a bridge with a + # direct NIC parent, that NIC gets the bridge in its list. For + # bridges sitting on top of a bond, every bond slave gets the + # bridge listed too. + bridges_per_nic = {} + for bridge in network_data['bridge_interfaces']: + phys = bridge.get('bridge_physical_interface') or '' + if not phys: + continue + if phys.startswith('bond'): + for slave in bridge.get('bridge_bond_slaves') or []: + bridges_per_nic.setdefault(slave, []).append(bridge['name']) + else: + bridges_per_nic.setdefault(phys, []).append(bridge['name']) + for phy in network_data['physical_interfaces']: + if phy['name'] in bridges_per_nic: + phy['used_by_bridges'] = bridges_per_nic[phy['name']] # print(f"[v0] Physical interfaces: {physical_active_count} active out of {physical_total_count} total") pass @@ -17050,29 +17242,32 @@ if __name__ == '__main__': # ── Temperature & Latency history collector ── # Initialize SQLite DB and start background thread to record CPU temp + latency every 60s if init_temperature_db() and init_latency_db(): - # Record initial readings immediately - _record_temperature() - _record_latency() - # Sprint 14: per-disk temperature history shares the same DB - # and the same 60s collector loop — initialize the table and - # take a baseline sample so the first chart draw isn't empty. - try: - import disk_temperature_history - if disk_temperature_history.init_disk_temperature_db(): - disk_temperature_history.record_all_disk_temperatures() - except Exception as e: - print(f"[ProxMenux] Disk temperature history init failed: {e}") - - # Sprint 14.7: managed-installs registry. Run a detection - # sweep at startup so manual NVIDIA/Tailscale installs that - # predated this build show up immediately — without waiting - # for the first 24h notification cycle to populate the file. - try: - import managed_installs - managed_installs.detect_and_register() - print("[ProxMenux] Managed-installs registry initialised") - except Exception as e: - print(f"[ProxMenux] managed_installs init failed: {e}") + # The heavy baseline-collection + detection sweeps below + # used to run inline here and added ~9 s of cold-start time + # (per-disk SMART scan + Tailscale/NVIDIA subprocess sweeps). + # Move them to a daemon thread so app.run() can start + # accepting requests immediately. The dashboard endpoints + # that read this data simply return empty/cached until the + # first cycle finishes a few seconds later. + def _deferred_startup_inits(): + try: + _record_temperature() + _record_latency() + except Exception as e: + print(f"[ProxMenux] initial temp/latency record failed: {e}") + try: + import disk_temperature_history + if disk_temperature_history.init_disk_temperature_db(): + disk_temperature_history.record_all_disk_temperatures() + except Exception as e: + print(f"[ProxMenux] Disk temperature history init failed: {e}") + try: + import managed_installs + managed_installs.detect_and_register() + print("[ProxMenux] Managed-installs registry initialised") + except Exception as e: + print(f"[ProxMenux] managed_installs init failed: {e}") + threading.Thread(target=_deferred_startup_inits, daemon=True).start() # Self-healing maintenance run on every startup. Two passes, both # idempotent and safe to run repeatedly. They exist because issues @@ -17303,14 +17498,14 @@ if __name__ == '__main__': ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(ssl_cert, ssl_key) print("[ProxMenux] Starting Flask server with SSL (using flask-sock for WebSockets)...") - app.run(host='::', port=8008, debug=False, ssl_context=ssl_context) + app.run(host='::', port=8008, debug=False, ssl_context=ssl_context, threaded=True) else: # HTTP mode - use Flask dev server (simpler, works fine without SSL) print("[ProxMenux] Starting Flask server with HTTP...") - app.run(host='::', port=8008, debug=False) + app.run(host='::', port=8008, debug=False, threaded=True) except Exception as e: if ssl_ctx and not gevent_available: print(f"[ProxMenux] SSL startup failed ({e}), falling back to HTTP") - app.run(host='::', port=8008, debug=False) + app.run(host='::', port=8008, debug=False, threaded=True) else: raise e