mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +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) => {
|
||||
// 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 < {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 (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user