update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-28 15:17:30 +02:00
parent a3d282f33e
commit b2753be204
7 changed files with 752 additions and 116 deletions

View File

@@ -1 +1 @@
8b7f4694ade901c9b1058828efd32df05d6621f5648882a172b72d44d5fb6950 ProxMenux-1.2.2.2-beta.AppImage
58cf9c7ca0186d2c774e8cd58bbf0e8434ab75d4fb0d9ae580ca902549773d5d ProxMenux-1.2.2.2-beta.AppImage

View File

@@ -394,29 +394,13 @@ export function HealthThresholds() {
}
const renderField = (path: string[], label: string) => {
// Kept for single-value leaves that don't have a warn/crit pair
// (e.g. Memory's swap_critical). The pair-cases route to
// renderThresholdRange below.
const leaf = getLeaf(tree, path)
if (!leaf) return null
const key = pathKey(path)
const editingValue = pending[key] ?? String(leaf.value)
// Visual rules (rebuilt — the original used /40 opacity borders +
// a blue ring stacked on top of the colour border, both of which
// were nearly invisible in read-only mode and stacked weirdly when
// a value was customised):
//
// • Read-only mode (editMode=false): keep severity colour on the
// border at a higher opacity (/70 instead of /40) and on the
// background (/10) so the field is clearly readable, and
// restore foreground colour (no `opacity-70` washout). This is
// the default state the user sees most of the time — it must
// match the visual weight of the rest of the Settings page.
// • Edit mode + value matches the recommended default: severity
// border + soft severity bg, same as read-only.
// • Edit mode + value customised: ONE border in blue, replacing
// (not stacking on top of) the severity border. This is the
// single signal that "this value differs from recommended".
//
// `swap_critical` and any other `*_critical` leaf falls into the
// red bucket via the substring check.
const last = path[path.length - 1] || ""
const isCritical = last.toLowerCase().includes("critical")
const isWarning = last.toLowerCase().includes("warning")
@@ -456,6 +440,200 @@ export function HealthThresholds() {
)
}
// Single-handle slider for thresholds that don't have a warn/crit
// pair (only Memory's swap_critical today). Same visual language as
// the dual-handle: red handle, value above, OK / CRIT zones below
// — so the operator doesn't read it as a different control.
const renderSingleThresholdSlider = (path: string[], severity: "warning" | "critical" = "critical") => {
const leaf = getLeaf(tree, path)
if (!leaf) return null
const key = pathKey(path)
const val = Number(pending[key] ?? leaf.value)
const min = leaf.min
const max = leaf.max
const step = leaf.step || 1
const unit = leaf.unit || ""
const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
const custom = leaf.customised && !(key in pending)
const color = severity === "critical" ? "red" : "amber"
const handleClass = severity === "critical"
? "[&::-webkit-slider-thumb]:bg-red-500 [&::-moz-range-thumb]:bg-red-500"
: "[&::-webkit-slider-thumb]:bg-amber-500 [&::-moz-range-thumb]:bg-amber-500"
const numberColor = custom
? "text-blue-400"
: severity === "critical"
? "text-red-500"
: "text-amber-500"
const fillColor = severity === "critical" ? "bg-red-500/30" : "bg-amber-500/30"
return (
<div className="px-1 py-3">
<div className="relative h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
style={{ left: `${pct}%` }}
>
{val}{unit}
</span>
</div>
<div className="relative h-6">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
<div
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`}
style={{ left: `${pct}%`, right: 0 }}
/>
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`Recommended: ${leaf.recommended}${unit}`}
/>
</div>
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {val}{unit}</span>
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} &gt; {val}{unit}</span>
</div>
</div>
)
}
// Dual-handle range slider replacing the two stacked number inputs
// for warn/crit pairs. Visual model: a horizontal track split into
// three zones — OK (left of warning, muted), WARN→CRIT (between
// handles, amber-to-red gradient), and OVER-CRIT (right of critical,
// dark red overlay). The handles themselves stay coloured (amber for
// warning, red for critical) so the operator reads the same severity
// mapping they're used to from the old inputs. Numbers ride above
// each handle and double as a click-to-edit affordance — clicking a
// number swaps it for a tight `<Input type="number">` so precise
// values are still keyboard-friendly. Customised values surface as a
// small blue dot on the affected handle (same signal as the old blue
// ring, less visual weight).
const renderThresholdRange = (
basePath: string[],
options?: { hideLabels?: boolean }
) => {
const wPath = [...basePath, "warning"]
const cPath = [...basePath, "critical"]
const wLeaf = getLeaf(tree, wPath)
const cLeaf = getLeaf(tree, cPath)
if (!wLeaf || !cLeaf) return null
const wKey = pathKey(wPath)
const cKey = pathKey(cPath)
const wVal = Number(pending[wKey] ?? wLeaf.value)
const cVal = Number(pending[cKey] ?? cLeaf.value)
// Shared min/max from the backend leaf (both handles use the same
// range; backend validates warning <= critical on save).
const min = Math.min(wLeaf.min, cLeaf.min)
const max = Math.max(wLeaf.max, cLeaf.max)
const step = Math.max(wLeaf.step, cLeaf.step) || 1
const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
const wPct = pct(wVal)
const cPct = pct(cVal)
const unit = wLeaf.unit || cLeaf.unit || ""
const wCustom = wLeaf.customised && !(wKey in pending)
const cCustom = cLeaf.customised && !(cKey in pending)
const setVal = (key: string, value: number, peer: number, isWarn: boolean) => {
// Clamp on the fly: warning can't cross critical and vice-versa,
// matching the backend invariant so the user can't drag into an
// invalid state.
let v = value
if (isWarn && v >= peer) v = peer - step
if (!isWarn && v <= peer) v = peer + step
setPending((p) => ({ ...p, [key]: String(v) }))
}
return (
<div className="px-1 py-3">
{/* Numeric labels above each handle, positioned absolutely so
they ride above the corresponding thumb regardless of the
slider width. Pointer events disabled so they don't steal
clicks from the underlying range inputs. */}
<div className="relative h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
style={{ left: `${wPct}%` }}
>
{wVal}{unit}
</span>
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${cCustom ? "text-blue-400" : "text-red-500"}`}
style={{ left: `${cPct}%` }}
>
{cVal}{unit}
</span>
</div>
{/* Track + handles. Two real <input type="range"> stacked on
top of each other, both pointer-events:none on the bar but
pointer-events:auto on the thumbs (CSS in globals if you
want sleeker thumbs; here we use the default which already
looks decent on every browser). */}
<div className="relative h-6">
{/* Background track: OK zone (muted) running the full width */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
{/* Warn-to-Crit gradient between the two handles */}
<div
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
style={{
left: `${wPct}%`,
width: `${Math.max(0, cPct - wPct)}%`,
background: "linear-gradient(90deg, rgb(245 158 11), rgb(239 68 68))",
}}
/>
{/* OVER-CRIT zone (right of critical) — solid red dim */}
<div
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full bg-red-500/30"
style={{ left: `${cPct}%`, right: 0 }}
/>
{/* Two superposed range inputs. Pointer-events on the thumb
only, so the inert track bar above stays visible. */}
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
/>
<input
type="range"
min={min}
max={max}
step={step}
disabled={!editMode}
value={cVal}
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
/>
</div>
{/* Zone labels — explicit ranges so the operator knows where
"warn" starts and ends without having to read the handles. */}
{!options?.hideLabels && (
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<span>OK &lt; {wVal}{unit}</span>
<span className="text-center">WARN {wVal}{cVal}{unit}</span>
<span className="text-right">CRIT &gt; {cVal}{unit}</span>
</div>
)}
</div>
)
}
return (
<Card>
<CardHeader>
@@ -518,9 +696,9 @@ export function HealthThresholds() {
</div>
<CardDescription>
The Health Monitor and notifications fire when these thresholds are crossed.
Amber inputs are warning levels, red inputs are critical levels. A blue ring
marks a value you've customised away from the recommended default hover the
field to see the recommendation, or use Reset to restore it.
Drag the amber handle to set the warning level and the red handle to set the
critical level. Values that differ from the recommended default appear in blue
hover a handle to see the recommendation, or use Reset to restore it.
</CardDescription>
</CardHeader>
<CardContent>
@@ -556,7 +734,7 @@ export function HealthThresholds() {
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h4 className="text-sm font-medium">{section.title}</h4>
</div>
{!editMode && (
{editMode && (
<button
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
onClick={() => handleResetSection(section.id)}
@@ -571,18 +749,51 @@ export function HealthThresholds() {
{section.description}
</p>
)}
<div className="divide-y divide-border/40">
{section.rowGroups
? section.rowGroups.map((group) => (
<div key={group.subKey} className="py-1.5">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-0.5 px-1">
{group.label}
</div>
{renderField([section.id, group.subKey, "warning"], "Warning")}
{renderField([section.id, group.subKey, "critical"], "Critical")}
<div>
{section.rowGroups ? (
// Per-class disk temperature: one slider per row
// (HDD / SSD / NVMe / SAS). Group label sits on
// top of each slider so the operator scans the
// column from top down without losing context.
section.rowGroups.map((group) => (
<div key={group.subKey} className="py-1.5 border-b border-border/40 last:border-b-0">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
{group.label}
</div>
))
: section.fields.map((f) => renderField(f.path, f.label))}
{renderThresholdRange([section.id, group.subKey])}
</div>
))
) : section.id === "memory" ? (
// Memory & Swap is special: warn/crit pair for
// RAM, plus a single Swap threshold that has no
// companion (it's a "critical only" metric).
// Both use sliders so the section reads as one
// visual language end to end.
<>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
RAM
</div>
{renderThresholdRange(["memory"])}
<div className="border-t border-border/40">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
Swap (critical only)
</div>
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
</div>
</>
) : section.fields.length === 2 &&
section.fields[0].path[section.fields[0].path.length - 1] === "warning" &&
section.fields[1].path[section.fields[1].path.length - 1] === "critical" ? (
// Generic warn+crit pair (CPU, CPU temp, storage
// capacities …) → single slider.
renderThresholdRange([section.id])
) : (
// Fallback for any future section shape — keep
// the original per-field number inputs.
<div className="divide-y divide-border/40">
{section.fields.map((f) => renderField(f.path, f.label))}
</div>
)}
</div>
</div>
)

View File

@@ -4,7 +4,7 @@ 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 } from 'lucide-react'
import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer, EthernetPort, ArrowDown, ArrowUp, Box } from 'lucide-react'
import useSWR from "swr"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
@@ -63,6 +63,12 @@ interface NetworkInterface {
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
bond_mode?: string
bond_slaves?: string[]
bond_active_slave?: string | null
@@ -77,6 +83,209 @@ interface NetworkInterface {
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 <span className={`inline-block h-2 w-2 rounded-full shrink-0 ${cls}`} aria-hidden />
}
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 getInterfaceTypeChip(type: string) {
switch ((type || "").toLowerCase()) {
case "physical":
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" }
case "bridge":
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" }
case "bond":
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" }
case "vlan":
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: "VLAN" }
case "vm_lxc":
case "virtual":
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" }
default:
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || "Unknown" }
}
}
// 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,
) {
const Icon = getInterfaceIcon(iface)
const chip = getInterfaceTypeChip(iface.type)
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)
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 (
<div
key={iface.name}
className="border border-white/10 rounded-lg p-5 cursor-pointer bg-card hover:bg-white/5 transition-colors flex flex-col"
onClick={() => onOpen(iface)}
>
{/* Header L1: identity (icon + name + type) | status. */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
<h3 className="font-mono font-bold text-base break-all">{iface.name}</h3>
<Badge variant="outline" className={chip.className}>{chip.label}</Badge>
</div>
<span
className={`flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide shrink-0 ${
isUp ? "text-green-500" : "text-red-400"
}`}
>
<NetStatusDot tone={isUp ? "ok" : "fail"} />
{iface.status || "?"}
</span>
</div>
{/* Header L2: speed | duplex (compact, live link info). */}
<div className="flex items-center justify-between gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5" />
{speedStr}
</span>
<span className="capitalize">{iface.duplex || "—"}</span>
</div>
{/* Separator. */}
<div className="border-t border-border/60 my-3" />
{/* Stats: key uppercase left · value right. */}
<div className="space-y-2 text-sm">
{firstAddr && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
IP
</span>
<span className="font-medium text-right truncate font-mono text-xs">
{firstAddr}{extraAddrs > 0 ? ` (+${extraAddrs})` : ""}
</span>
</div>
)}
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">MTU</span>
<span className="font-medium">{iface.mtu || "—"}</span>
</div>
{/* 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. */}
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowDown className="h-3 w-3 text-green-500" /> Received
</span>
<span className="font-medium text-green-500 tabular-nums">
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
</span>
</div>
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
<ArrowUp className="h-3 w-3 text-blue-400" /> Sent
</span>
<span className="font-medium text-blue-400 tabular-nums">
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
</span>
</div>
{(totalErrors > 0 || totalDrops > 0) && (
<>
{totalErrors > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Errors</span>
<span
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalErrors) === "ok"
? "text-green-500"
: netCounterTone(totalErrors) === "warn"
? "text-yellow-500"
: "text-red-500"
}`}
>
<NetStatusDot tone={netCounterTone(totalErrors)} />
{totalErrors.toLocaleString()}
</span>
</div>
)}
{totalDrops > 0 && (
<div className="flex items-baseline justify-between gap-3">
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Drops</span>
<span
className={`font-medium flex items-center gap-1.5 ${
netCounterTone(totalDrops) === "ok"
? "text-green-500"
: netCounterTone(totalDrops) === "warn"
? "text-yellow-500"
: "text-red-500"
}`}
>
<NetStatusDot tone={netCounterTone(totalDrops)} />
{totalDrops.toLocaleString()}
</span>
</div>
)}
</>
)}
</div>
{/* Footer: MAC (left, mono) + arrow CTA (right). */}
<div className="border-t border-border/60 mt-auto pt-3 flex items-center justify-between gap-3">
{iface.mac_address ? (
<span className="text-[11px] text-foreground font-mono truncate min-w-0">
<span className="text-muted-foreground">MAC:</span> {iface.mac_address}
</span>
) : (
<span />
)}
<span
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0"
aria-label="View details"
>
</span>
</div>
</div>
)
}
const getInterfaceTypeBadge = (type: string) => {
switch (type) {
case "physical":
@@ -105,6 +314,19 @@ const getVMTypeBadge = (vmType: string | undefined) => {
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "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
@@ -534,76 +756,13 @@ export function NetworkMetrics() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{networkData.physical_interfaces.map((interface_, index) => {
const typeBadge = getInterfaceTypeBadge(interface_.type)
return (
<div
key={index}
className="flex flex-col gap-3 p-4 rounded-lg border border-white/10 bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => setSelectedInterface(interface_)}
>
{/* First row: Icon, Name, Type Badge, Status */}
<div className="flex items-center gap-3 flex-wrap">
<Wifi className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<div className="flex items-center gap-2 min-w-0 flex-1 flex-wrap">
<div className="font-medium text-foreground">{interface_.name}</div>
<Badge variant="outline" className={typeBadge.color}>
{typeBadge.label}
</Badge>
</div>
<Badge
variant="outline"
className={
interface_.status === "up"
? "bg-green-500/10 text-green-500 border-green-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20"
}
>
{interface_.status.toUpperCase()}
</Badge>
</div>
{/* Second row: Details - Responsive layout */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground text-xs">IP Address</div>
<div className="font-medium text-foreground font-mono text-sm truncate">
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : "N/A"}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Speed</div>
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
<Zap className="h-3 w-3" />
{formatSpeed(interface_.speed)}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">Duplex</div>
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">MTU</div>
<div className="font-medium text-foreground text-xs">{interface_.mtu}</div>
</div>
{interface_.mac_address && (
<div className="col-span-2 md:col-span-4">
<div className="text-muted-foreground text-xs">MAC</div>
<div className="font-medium text-foreground font-mono text-xs truncate">
{interface_.mac_address}
</div>
</div>
)}
</div>
</div>
)
})}
{/* 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. */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{networkData.physical_interfaces.map((iface) =>
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
)}
</div>
</CardContent>
</Card>

View File

@@ -72,9 +72,60 @@ interface MetricsError {
suggestion?: string
}
// AVG / MAX / MIN chip row for the chart card headers. Values come
// from the backend `period_stats` (calculated over the raw RRD points
// BEFORE downsampling), not from the displayed chart points — that's
// what makes a 1-minute CPU spike still appear in the 24h MAX even
// though the chart shows 5-min bucket averages.
//
// Colour choice: all three values render in the same foreground tone.
// The previous red(max)/green(min) scheme misread as severity (a
// healthy 10 % CPU max showed in red and looked like an alert).
//
// Responsive: on ≥sm the chips sit to the right of the title; on
// mobile they wrap below in their own row (the parent CardHeader uses
// `flex-col sm:flex-row`). Smaller text + tabular-nums keeps the
// chips compact enough that they don't crowd long titles.
type PeriodStat = { avg: number; max: number; min: number } | null
function ChartStatsHeader({
stats,
suffix = "",
}: {
stats: PeriodStat
suffix?: string
}) {
if (!stats) return null
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
return (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
<span>
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">avg</span>
</span>
<span>
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">max</span>
</span>
<span>
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">min</span>
</span>
</div>
)
}
export function NodeMetricsCharts() {
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState<NodeMetricsData[]>([])
// period_stats from the backend — computed over the raw RRD points
// BEFORE the 5-min downsampling so the chart header's MAX/MIN
// captures real per-minute extremes (a 1-min CPU spike still shows
// up on the 24h view's MAX).
const [periodStats, setPeriodStats] = useState<{
cpu?: PeriodStat
memory_used?: PeriodStat
}>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<MetricsError | null>(null)
const isMobile = useIsMobile()
@@ -164,6 +215,7 @@ export function NodeMetricsCharts() {
})
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
console.error("Error fetching node metrics:", err)
// fetchApi attaches the parsed JSON body to err.body. The metrics
@@ -323,10 +375,13 @@ export function NodeMetricsCharts() {
{/* CPU Usage + Load Average Chart */}
<Card className="bg-card border-border">
<CardHeader className="px-4 md:px-6">
<CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average
</CardTitle>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
CPU Usage & Load Average
</CardTitle>
<ChartStatsHeader stats={periodStats.cpu ?? null} suffix="%" />
</div>
</CardHeader>
<CardContent className="px-0 md:px-6">
<ResponsiveContainer width="100%" height={300}>
@@ -395,10 +450,13 @@ export function NodeMetricsCharts() {
{/* Memory Usage Chart */}
<Card className="bg-card border-border">
<CardHeader className="px-4 md:px-6">
<CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" />
Memory Usage
</CardTitle>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<CardTitle className="text-foreground flex items-center">
<MemoryStick className="h-5 w-5 mr-2" />
Memory Usage
</CardTitle>
<ChartStatsHeader stats={periodStats.memory_used ?? null} suffix=" GB" />
</div>
</CardHeader>
<CardContent className="px-0 pr-2 md:px-6">
<ResponsiveContainer width="100%" height={300}>

View File

@@ -17,6 +17,12 @@ interface ProcessInfo {
parent_pid?: number
user: string
cpu: number
/** CPU% averaged over the process's whole lifetime. Surfaces
* long-running idle processes that consume a steady baseline but
* don't spike during the 1-s sample window (an orphan `bash -s`
* in a sleep-loop, a polling daemon, etc.). Only meaningful on
* the CPU sort response. */
cpu_avg?: number
mem: number
rss_kb: number
command: string
@@ -213,6 +219,19 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
{sort === "cpu" ? (
<div className="flex flex-col items-end gap-1 min-w-0">
<span className={`font-mono text-sm font-semibold ${accent.text}`}>{p.cpu.toFixed(1)}</span>
{/* Show lifetime average only when it
materially differs from the live
sample (process consumes a steady
baseline but was idle at sample time
— orphaned bash loop, polling daemon).
The 0.5 / 1.5x thresholds skip cases
where avg and now match within sampler
noise. */}
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
<span className="font-mono text-[10px] text-amber-400" title="Average CPU% across this process's lifetime — useful for finding long-running idle baselines">
avg {p.cpu_avg.toFixed(1)}
</span>
)}
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
</div>

View File

@@ -4435,6 +4435,55 @@ 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.
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
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.
"""
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:
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'])
rates[iface] = {
'rx_Bps': round(rx_delta / elapsed, 2),
'tx_Bps': round(tx_delta / elapsed, 2),
}
_NET_RATE_CACHE['time'] = now
_NET_RATE_CACHE['per_nic'] = new_data
return rates
def get_interface_type(interface_name):
"""Detect the type of network interface"""
try:
@@ -4637,6 +4686,14 @@ def get_network_info():
# print(f"[v0] Error getting per-NIC stats: {e}")
pass
net_io_per_nic = {}
# Per-interface live rate (bytes/sec) computed from delta vs the
# previous call to this endpoint. The dashboard polls /api/network
# on a fixed cadence, so the delta between two polls is the rate
# 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".
per_nic_rates = _compute_per_nic_rates_live(net_io_per_nic)
physical_active_count = 0
physical_total_count = 0
@@ -4723,6 +4780,20 @@ def get_network_info():
interface_info['errors_out'] = io_stats.errout
interface_info['drops_in'] = io_stats.dropin
interface_info['drops_out'] = io_stats.dropout
# Live rate (B/s) from delta-since-last-call. Same
# vm_lxc-orientation flip as the cumulative bytes
# above — the host sees a VM's "send" as the bridge's
# "receive", so we invert when surfacing the rate from
# the VM's perspective.
rate = per_nic_rates.get(interface_name)
if rate:
if interface_type == 'vm_lxc':
interface_info['rx_Bps'] = rate['tx_Bps']
interface_info['tx_Bps'] = rate['rx_Bps']
else:
interface_info['rx_Bps'] = rate['rx_Bps']
interface_info['tx_Bps'] = rate['tx_Bps']
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
@@ -7877,6 +7948,18 @@ def api_processes():
return (comm, ppid, rss_kb, uid, cmdline)
if sort == 'cpu':
# System uptime — denominator for cpu_avg (CPU% averaged
# over the entire lifetime of the process). Used to
# surface long-running idle processes that don't show up
# in the 1-s delta (e.g. an orphaned `bash -s` that loops
# with `sleep 5` between iterations — averaged it's 4 %
# of one core for weeks, instantaneously it samples 0).
try:
with open('/proc/uptime') as f:
host_uptime_sec = float(f.read().split()[0])
except (OSError, ValueError):
host_uptime_sec = 0.0
# Pass 1: snapshot CPU time for every running PID.
snap1 = {pid: _read_cpu_time(pid) for pid in _list_pids()}
snap1 = {k: v for k, v in snap1.items() if v is not None}
@@ -7904,19 +7987,44 @@ def api_processes():
# the whole host (so values line up with the CPU Usage
# card — 1 % in the modal == 1 % of the host total).
cpu_pct = round((delta_jiffies / SC_CLK_TCK) * 100 / NCPU, 1)
# cpu_avg — proportion of host CPU this process has
# consumed across its entire lifetime. Read the
# per-process start time from /proc/<pid>/stat (field
# 21, in ticks since boot) and turn it into seconds via
# host_uptime - start_seconds. Falls back to 0.0 when
# start time isn't readable (kernel threads, perms).
cpu_avg = 0.0
try:
with open(f'/proc/{pid}/stat', 'rb') as f:
st_line = f.read()
st_rpar = st_line.rfind(b')')
st_rest = st_line[st_rpar + 2:].split()
starttime_ticks = int(st_rest[19]) # field 22 minus the 2 skipped after comm
proc_uptime_sec = max(1.0, host_uptime_sec - (starttime_ticks / SC_CLK_TCK))
cpu_avg = round((now / SC_CLK_TCK) * 100 / NCPU / proc_uptime_sec, 1)
except (OSError, FileNotFoundError, IndexError, ValueError):
pass
mem_pct = round((rss_kb / total_kb * 100) if total_kb else 0.0, 1)
results.append({
'pid': pid,
'parent_pid': pid,
'user': _user_name(uid),
'cpu': cpu_pct,
'cpu_avg': cpu_avg,
'mem': mem_pct,
'rss_kb': rss_kb,
'command': comm,
'cmdline': cmdline or comm,
})
results.sort(key=lambda r: r['cpu'], reverse=True)
# Sort by the larger of (now, avg) so a process is
# captured whether it's spiking right now OR running an
# always-on baseline. Without this an orphan bash loop
# never made the top-N and the operator had no UI
# surface to find it.
results.sort(key=lambda r: max(r['cpu'], r.get('cpu_avg', 0.0)), reverse=True)
processes = results[:limit]
else:
@@ -10099,6 +10207,83 @@ def api_node_metrics():
elif zfs_arc_size > 0 and ('zfsarc' not in item or item.get('zfsarc', 0) == 0):
item['zfsarc'] = zfs_arc_size
# Period stats — computed BEFORE downsampling so the
# AVG/MAX/MIN header in the chart reflects real per-minute
# extremes instead of averages.
#
# Three sources depending on the timeframe:
#
# - hour/day → PVE returns 1-min raw points. AVG/MAX/MIN
# of the in-memory list IS the truth.
#
# - week/month → PVE already downsamples to 30-min /
# ~1-hour points using consolidation function AVG, so
# the in-memory points are already averages. Taking
# max() of them gives "max of averages", NOT the real
# peak. We issue two extra pvesh calls per request
# (`--cf MAX` and `--cf MIN`) to recover the real
# extremes from PVE's own RRD consolidation. The
# extra calls add ~150 ms — only on week/month and
# only when the chart loads, so the overhead is small.
def _values_from(items, field_key, scale=1.0):
return [item[field_key] * scale for item in items
if isinstance(item.get(field_key), (int, float))
and not isinstance(item[field_key], bool)
and item[field_key] is not None]
def _stats_native(field_key, scale=1.0):
values = _values_from(rrd_data, field_key, scale)
if not values:
return None
return {
'avg': sum(values) / len(values),
'max': max(values),
'min': min(values),
}
def _pvesh_rrd(cf):
"""One extra pvesh call with a non-default CF.
Returns the parsed list or None on any failure — caller
falls back to the AVG-based numbers."""
try:
extra = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--cf', cf,
'--output-format', 'json'],
capture_output=True, text=True, timeout=10,
)
if extra.returncode == 0 and extra.stdout:
return json.loads(extra.stdout)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
pass
return None
def _build_stats(field_key, scale=1.0):
native = _stats_native(field_key, scale)
if native is None:
return None
# On week/month, the points we already have are AVG.
# Try to upgrade max/min to the real RRD extremes.
if timeframe in ('week', 'month'):
cf_max = _pvesh_rrd('MAX')
if cf_max:
vals = _values_from(cf_max, field_key, scale)
if vals:
native['max'] = max(vals)
cf_min = _pvesh_rrd('MIN')
if cf_min:
vals = _values_from(cf_min, field_key, scale)
if vals:
native['min'] = min(vals)
return native
period_stats = {
# cpu: RRD stores fraction 0-1, surface as %.
'cpu': _build_stats('cpu', scale=100.0),
# memory_used: bytes → GB so units match the chart.
'memory_used': _build_stats('memused', scale=1 / (1024 ** 3)),
}
# 24h downsampling: RRD returns ~1440 minute-level points which
# plots as a dense thicket of vertical spikes. Group into 5-min
# buckets and average each numeric field — same shape that
@@ -10131,7 +10316,11 @@ def api_node_metrics():
payload = {
'node': local_node,
'timeframe': timeframe,
'data': rrd_data
'data': rrd_data,
# AVG/MAX/MIN computed over the raw (pre-downsampling)
# points so the chart header captures real per-minute
# extremes even on multi-day timeframes.
'period_stats': period_stats,
}
_node_metrics_cache_set(timeframe, payload)
return jsonify(payload)