- {section.rowGroups
- ? section.rowGroups.map((group) => (
-
-
- {group.label}
-
- {renderField([section.id, group.subKey, "warning"], "Warning")}
- {renderField([section.id, group.subKey, "critical"], "Critical")}
+
+ {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) => (
+
+
+ {group.label}
- ))
- : section.fields.map((f) => renderField(f.path, f.label))}
+ {renderThresholdRange([section.id, group.subKey])}
+
+ ))
+ ) : 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.
+ <>
+
+ RAM
+
+ {renderThresholdRange(["memory"])}
+
+
+ Swap (critical only)
+
+ {renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
+
+ >
+ ) : 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.
+
+ {section.fields.map((f) => renderField(f.path, f.label))}
+
+ )}
)
diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx
index 739fc151..73e8ae09 100644
--- a/AppImage/components/network-metrics.tsx
+++ b/AppImage/components/network-metrics.tsx
@@ -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
+}
+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 (
+
onOpen(iface)}
+ >
+ {/* Header L1: identity (icon + name + type) | status. */}
+
+
+
+
{iface.name}
+ {chip.label}
+
+
+
+ {iface.status || "?"}
+
+
+
+ {/* Header L2: speed | duplex (compact, live link info). */}
+
+
+
+ {speedStr}
+
+ {iface.duplex || "—"}
+
+
+ {/* Separator. */}
+
+
+ {/* Stats: key uppercase left · value right. */}
+
+ {firstAddr && (
+
+
+ IP
+
+
+ {firstAddr}{extraAddrs > 0 ? ` (+${extraAddrs})` : ""}
+
+
+ )}
+
+ MTU
+ {iface.mtu || "—"}
+
+ {/* 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. */}
+
+
+ Received
+
+
+ {iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
+
+
+
+
+ Sent
+
+
+ {iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
+
+
+ {(totalErrors > 0 || totalDrops > 0) && (
+ <>
+ {totalErrors > 0 && (
+
+ Errors
+
+
+ {totalErrors.toLocaleString()}
+
+
+ )}
+ {totalDrops > 0 && (
+
+ Drops
+
+
+ {totalDrops.toLocaleString()}
+
+
+ )}
+ >
+ )}
+
+
+ {/* Footer: MAC (left, mono) + arrow CTA (right). */}
+
+ {iface.mac_address ? (
+
+ MAC: {iface.mac_address}
+
+ ) : (
+
+ )}
+
+ →
+
+
+
+ )
+}
+
+
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() {
-
- {networkData.physical_interfaces.map((interface_, index) => {
- const typeBadge = getInterfaceTypeBadge(interface_.type)
-
- return (
-
setSelectedInterface(interface_)}
- >
- {/* First row: Icon, Name, Type Badge, Status */}
-
-
-
-
{interface_.name}
-
- {typeBadge.label}
-
-
-
- {interface_.status.toUpperCase()}
-
-
-
- {/* Second row: Details - Responsive layout */}
-
-
-
IP Address
-
- {interface_.addresses.length > 0 ? interface_.addresses[0].ip : "N/A"}
-
-
-
-
-
Speed
-
-
- {formatSpeed(interface_.speed)}
-
-
-
-
-
Duplex
-
{interface_.duplex}
-
-
-
-
MTU
-
{interface_.mtu}
-
-
- {interface_.mac_address && (
-
-
MAC
-
- {interface_.mac_address}
-
-
- )}
-
-
- )
- })}
+ {/* Same responsive grid as the Storage page: 3 cols desktop,
+ 2 cols tablet, 1 col mobile. Cards self-size so a row of
+ long interface names won't push others off-screen. */}
+
+ {networkData.physical_interfaces.map((iface) =>
+ renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
+ )}
diff --git a/AppImage/components/node-metrics-charts.tsx b/AppImage/components/node-metrics-charts.tsx
index 83fde19f..104e59f0 100644
--- a/AppImage/components/node-metrics-charts.tsx
+++ b/AppImage/components/node-metrics-charts.tsx
@@ -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 (
+
+
+ {fmt(stats.avg)}{suffix}
+ avg
+
+
+ {fmt(stats.max)}{suffix}
+ max
+
+
+ {fmt(stats.min)}{suffix}
+ min
+
+
+ )
+}
+
+
export function NodeMetricsCharts() {
const [timeframe, setTimeframe] = useState("day")
const [data, setData] = useState
([])
+ // 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(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 */}
-
-
- CPU Usage & Load Average
-
+
+
+
+ CPU Usage & Load Average
+
+
+
@@ -395,10 +450,13 @@ export function NodeMetricsCharts() {
{/* Memory Usage Chart */}
-
-
- Memory Usage
-
+
+
+
+ Memory Usage
+
+
+
diff --git a/AppImage/components/process-detail-modal.tsx b/AppImage/components/process-detail-modal.tsx
index 058e1086..36931de9 100644
--- a/AppImage/components/process-detail-modal.tsx
+++ b/AppImage/components/process-detail-modal.tsx
@@ -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" ? (
{p.cpu.toFixed(1)}
+ {/* 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 && (
+
+ avg {p.cpu_avg.toFixed(1)}
+
+ )}
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 82364ea4..3d04eba0 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -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/
/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)