mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-03 14:26:24 +00:00
update 1.2.2.2 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
8b7f4694ade901c9b1058828efd32df05d6621f5648882a172b72d44d5fb6950 ProxMenux-1.2.2.2-beta.AppImage
|
58cf9c7ca0186d2c774e8cd58bbf0e8434ab75d4fb0d9ae580ca902549773d5d ProxMenux-1.2.2.2-beta.AppImage
|
||||||
|
|||||||
@@ -394,29 +394,13 @@ export function HealthThresholds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderField = (path: string[], label: string) => {
|
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)
|
const leaf = getLeaf(tree, path)
|
||||||
if (!leaf) return null
|
if (!leaf) return null
|
||||||
const key = pathKey(path)
|
const key = pathKey(path)
|
||||||
const editingValue = pending[key] ?? String(leaf.value)
|
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 last = path[path.length - 1] || ""
|
||||||
const isCritical = last.toLowerCase().includes("critical")
|
const isCritical = last.toLowerCase().includes("critical")
|
||||||
const isWarning = last.toLowerCase().includes("warning")
|
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 < {val}{unit}</span>
|
||||||
|
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} > {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 < {wVal}{unit}</span>
|
||||||
|
<span className="text-center">WARN {wVal}–{cVal}{unit}</span>
|
||||||
|
<span className="text-right">CRIT > {cVal}{unit}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -518,9 +696,9 @@ export function HealthThresholds() {
|
|||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
The Health Monitor and notifications fire when these thresholds are crossed.
|
||||||
Amber inputs are warning levels, red inputs are critical levels. A blue ring
|
Drag the amber handle to set the warning level and the red handle to set the
|
||||||
marks a value you've customised away from the recommended default — hover the
|
critical level. Values that differ from the recommended default appear in blue —
|
||||||
field to see the recommendation, or use Reset to restore it.
|
hover a handle to see the recommendation, or use Reset to restore it.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -556,7 +734,7 @@ export function HealthThresholds() {
|
|||||||
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
<h4 className="text-sm font-medium">{section.title}</h4>
|
<h4 className="text-sm font-medium">{section.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && (
|
{editMode && (
|
||||||
<button
|
<button
|
||||||
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
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)}
|
onClick={() => handleResetSection(section.id)}
|
||||||
@@ -571,18 +749,51 @@ export function HealthThresholds() {
|
|||||||
{section.description}
|
{section.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="divide-y divide-border/40">
|
<div>
|
||||||
{section.rowGroups
|
{section.rowGroups ? (
|
||||||
? section.rowGroups.map((group) => (
|
// Per-class disk temperature: one slider per row
|
||||||
<div key={group.subKey} className="py-1.5">
|
// (HDD / SSD / NVMe / SAS). Group label sits on
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-0.5 px-1">
|
// top of each slider so the operator scans the
|
||||||
{group.label}
|
// column from top down without losing context.
|
||||||
</div>
|
section.rowGroups.map((group) => (
|
||||||
{renderField([section.id, group.subKey, "warning"], "Warning")}
|
<div key={group.subKey} className="py-1.5 border-b border-border/40 last:border-b-0">
|
||||||
{renderField([section.id, group.subKey, "critical"], "Critical")}
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||||
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
))
|
{renderThresholdRange([section.id, group.subKey])}
|
||||||
: section.fields.map((f) => renderField(f.path, f.label))}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
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 useSWR from "swr"
|
||||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
@@ -63,6 +63,12 @@ interface NetworkInterface {
|
|||||||
errors_out?: number
|
errors_out?: number
|
||||||
drops_in?: number
|
drops_in?: number
|
||||||
drops_out?: 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_mode?: string
|
||||||
bond_slaves?: string[]
|
bond_slaves?: string[]
|
||||||
bond_active_slave?: string | null
|
bond_active_slave?: string | null
|
||||||
@@ -77,6 +83,209 @@ interface NetworkInterface {
|
|||||||
vm_status?: string
|
vm_status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same dot-prefix tone the Storage cards use, so a "no errors" /
|
||||||
|
// "errors present" cue reads identically across pages.
|
||||||
|
const NetStatusDot = ({ tone }: { tone: "ok" | "warn" | "fail" }) => {
|
||||||
|
const cls =
|
||||||
|
tone === "ok" ? "bg-green-500" : tone === "warn" ? "bg-yellow-500" : "bg-red-500"
|
||||||
|
return <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) => {
|
const getInterfaceTypeBadge = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "physical":
|
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" }
|
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 => {
|
const formatBytes = (bytes: number | undefined): string => {
|
||||||
if (!bytes || bytes === 0) return "0 B"
|
if (!bytes || bytes === 0) return "0 B"
|
||||||
const k = 1024
|
const k = 1024
|
||||||
@@ -534,76 +756,13 @@ export function NetworkMetrics() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
{/* Same responsive grid as the Storage page: 3 cols desktop,
|
||||||
{networkData.physical_interfaces.map((interface_, index) => {
|
2 cols tablet, 1 col mobile. Cards self-size so a row of
|
||||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
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">
|
||||||
return (
|
{networkData.physical_interfaces.map((iface) =>
|
||||||
<div
|
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -72,9 +72,60 @@ interface MetricsError {
|
|||||||
suggestion?: string
|
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() {
|
export function NodeMetricsCharts() {
|
||||||
const [timeframe, setTimeframe] = useState("day")
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
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 [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<MetricsError | null>(null)
|
const [error, setError] = useState<MetricsError | null>(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
@@ -164,6 +215,7 @@ export function NodeMetricsCharts() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setData(transformedData)
|
setData(transformedData)
|
||||||
|
setPeriodStats(result.period_stats || {})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error fetching node metrics:", err)
|
console.error("Error fetching node metrics:", err)
|
||||||
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
||||||
@@ -323,10 +375,13 @@ export function NodeMetricsCharts() {
|
|||||||
{/* CPU Usage + Load Average Chart */}
|
{/* CPU Usage + Load Average Chart */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="px-4 md:px-6">
|
<CardHeader className="px-4 md:px-6">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<TrendingUp className="h-5 w-5 mr-2" />
|
<CardTitle className="text-foreground flex items-center">
|
||||||
CPU Usage & Load Average
|
<TrendingUp className="h-5 w-5 mr-2" />
|
||||||
</CardTitle>
|
CPU Usage & Load Average
|
||||||
|
</CardTitle>
|
||||||
|
<ChartStatsHeader stats={periodStats.cpu ?? null} suffix="%" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 md:px-6">
|
<CardContent className="px-0 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
@@ -395,10 +450,13 @@ export function NodeMetricsCharts() {
|
|||||||
{/* Memory Usage Chart */}
|
{/* Memory Usage Chart */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="px-4 md:px-6">
|
<CardHeader className="px-4 md:px-6">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<MemoryStick className="h-5 w-5 mr-2" />
|
<CardTitle className="text-foreground flex items-center">
|
||||||
Memory Usage
|
<MemoryStick className="h-5 w-5 mr-2" />
|
||||||
</CardTitle>
|
Memory Usage
|
||||||
|
</CardTitle>
|
||||||
|
<ChartStatsHeader stats={periodStats.memory_used ?? null} suffix=" GB" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pr-2 md:px-6">
|
<CardContent className="px-0 pr-2 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ interface ProcessInfo {
|
|||||||
parent_pid?: number
|
parent_pid?: number
|
||||||
user: string
|
user: string
|
||||||
cpu: number
|
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
|
mem: number
|
||||||
rss_kb: number
|
rss_kb: number
|
||||||
command: string
|
command: string
|
||||||
@@ -213,6 +219,19 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
{sort === "cpu" ? (
|
{sort === "cpu" ? (
|
||||||
<div className="flex flex-col items-end gap-1 min-w-0">
|
<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>
|
<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="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||||
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4435,6 +4435,55 @@ def api_storage_observations():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'observations': [], 'error': str(e)}), 500
|
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):
|
def get_interface_type(interface_name):
|
||||||
"""Detect the type of network interface"""
|
"""Detect the type of network interface"""
|
||||||
try:
|
try:
|
||||||
@@ -4637,6 +4686,14 @@ def get_network_info():
|
|||||||
# print(f"[v0] Error getting per-NIC stats: {e}")
|
# print(f"[v0] Error getting per-NIC stats: {e}")
|
||||||
pass
|
pass
|
||||||
net_io_per_nic = {}
|
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_active_count = 0
|
||||||
physical_total_count = 0
|
physical_total_count = 0
|
||||||
@@ -4723,6 +4780,20 @@ def get_network_info():
|
|||||||
interface_info['errors_out'] = io_stats.errout
|
interface_info['errors_out'] = io_stats.errout
|
||||||
interface_info['drops_in'] = io_stats.dropin
|
interface_info['drops_in'] = io_stats.dropin
|
||||||
interface_info['drops_out'] = io_stats.dropout
|
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':
|
if interface_type == 'bond':
|
||||||
bond_info = get_bond_info(interface_name)
|
bond_info = get_bond_info(interface_name)
|
||||||
@@ -7877,6 +7948,18 @@ def api_processes():
|
|||||||
return (comm, ppid, rss_kb, uid, cmdline)
|
return (comm, ppid, rss_kb, uid, cmdline)
|
||||||
|
|
||||||
if sort == 'cpu':
|
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.
|
# Pass 1: snapshot CPU time for every running PID.
|
||||||
snap1 = {pid: _read_cpu_time(pid) for pid in _list_pids()}
|
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}
|
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
|
# the whole host (so values line up with the CPU Usage
|
||||||
# card — 1 % in the modal == 1 % of the host total).
|
# card — 1 % in the modal == 1 % of the host total).
|
||||||
cpu_pct = round((delta_jiffies / SC_CLK_TCK) * 100 / NCPU, 1)
|
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)
|
mem_pct = round((rss_kb / total_kb * 100) if total_kb else 0.0, 1)
|
||||||
results.append({
|
results.append({
|
||||||
'pid': pid,
|
'pid': pid,
|
||||||
'parent_pid': pid,
|
'parent_pid': pid,
|
||||||
'user': _user_name(uid),
|
'user': _user_name(uid),
|
||||||
'cpu': cpu_pct,
|
'cpu': cpu_pct,
|
||||||
|
'cpu_avg': cpu_avg,
|
||||||
'mem': mem_pct,
|
'mem': mem_pct,
|
||||||
'rss_kb': rss_kb,
|
'rss_kb': rss_kb,
|
||||||
'command': comm,
|
'command': comm,
|
||||||
'cmdline': cmdline or 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]
|
processes = results[:limit]
|
||||||
|
|
||||||
else:
|
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):
|
elif zfs_arc_size > 0 and ('zfsarc' not in item or item.get('zfsarc', 0) == 0):
|
||||||
item['zfsarc'] = zfs_arc_size
|
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
|
# 24h downsampling: RRD returns ~1440 minute-level points which
|
||||||
# plots as a dense thicket of vertical spikes. Group into 5-min
|
# plots as a dense thicket of vertical spikes. Group into 5-min
|
||||||
# buckets and average each numeric field — same shape that
|
# buckets and average each numeric field — same shape that
|
||||||
@@ -10131,7 +10316,11 @@ def api_node_metrics():
|
|||||||
payload = {
|
payload = {
|
||||||
'node': local_node,
|
'node': local_node,
|
||||||
'timeframe': timeframe,
|
'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)
|
_node_metrics_cache_set(timeframe, payload)
|
||||||
return jsonify(payload)
|
return jsonify(payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user