mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-11 11:06:24 +00:00
Add beta 1.2.2.1
This commit is contained in:
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2.1-beta</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -300,26 +300,49 @@ export function NetworkMetrics() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Network Overview Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">Received:</span>
|
||||
<span className="text-base lg:text-xl font-bold text-green-500">↓ {trafficInFormatted}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">Sent:</span>
|
||||
<span className="text-base lg:text-xl font-bold text-blue-500">↑ {trafficOutFormatted}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
{/* ── Network Traffic (preview restyle: Down/Up dual headline + stacked bar) ── */}
|
||||
{(() => {
|
||||
const downBytes = networkData.traffic.bytes_recv || 0
|
||||
const upBytes = networkData.traffic.bytes_sent || 0
|
||||
const totalBytes = downBytes + upBytes
|
||||
const downPct = totalBytes > 0 ? (downBytes / totalBytes) * 100 : 50
|
||||
const upPct = totalBytes > 0 ? (upBytes / totalBytes) * 100 : 50
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
<span className="text-green-500">↓</span> Down
|
||||
</div>
|
||||
<div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
<span className="text-blue-500">↑</span> Up
|
||||
</div>
|
||||
<div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
||||
<div style={{ width: `${downPct}%`, background: '#22c55e' }}></div>
|
||||
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>Down {Math.round(downPct)}%</span>
|
||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>Up {Math.round(upPct)}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active Interfaces</CardTitle>
|
||||
@@ -330,10 +353,10 @@ export function NetworkMetrics() {
|
||||
{(networkData.physical_active_count ?? 0) + (networkData.bridge_active_count ?? 0)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs">
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
Physical: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20 text-xs">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
Bridges: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -343,31 +366,43 @@ export function NetworkMetrics() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Merged Network Config & Health Card */}
|
||||
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
|
||||
<Badge variant="outline" className={healthColor}>
|
||||
{healthStatus}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Hostname</span>
|
||||
<span className="text-xs font-medium text-foreground truncate max-w-[120px]">{hostname}</span>
|
||||
{(() => {
|
||||
const lossPct = Number.parseFloat(avgPacketLoss) || 0
|
||||
const lossColor =
|
||||
lossPct >= 5 ? 'text-red-500' :
|
||||
lossPct >= 1 ? 'text-orange-500' :
|
||||
lossPct > 0 ? 'text-yellow-500' :
|
||||
'text-blue-500'
|
||||
return (
|
||||
<div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}>
|
||||
{avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span>
|
||||
<span className="text-sm font-normal text-muted-foreground">Packet Loss</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">Hostname:</div>
|
||||
<div className="font-medium font-mono truncate">{hostname}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Primary DNS</span>
|
||||
<span className="text-xs font-medium text-foreground font-mono">{primaryDNS}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">DNS:</div>
|
||||
<div className="font-medium font-mono truncate">{primaryDNS}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Packet Loss</span>
|
||||
<span className="text-xs font-medium text-foreground">{avgPacketLoss}%</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">Errors:</div>
|
||||
<div className="font-medium font-mono">{totalErrors}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Errors</span>
|
||||
<span className="text-xs font-medium text-foreground">{totalErrors}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">Domain:</div>
|
||||
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -858,7 +858,7 @@ export function ProxmoxDashboard() {
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2.1-beta</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.2.2" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.2.2.1-beta" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -217,20 +217,16 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Sliders className="h-5 w-5" />,
|
||||
text: "Health Monitor Thresholds - Per-category Warning and Critical levels for CPU, memory, temperature, storage and more, configurable from Settings. The same numbers feed the colour ranges of the dashboard widgets, so every green / amber / red state maps to a definite range relative to the configured pair",
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
text: "Header Critical badge now respects dismissals (#228) - Permanently silencing every critical alert in a category used to leave the badge stuck on Critical even though the popup correctly reported 0 critical. The rollup that drives /api/system-info now runs a dismiss-aware pass over every category, so the badge, the popup and any API consumer all see the same view",
|
||||
},
|
||||
{
|
||||
icon: <BellOff className="h-5 w-5" />,
|
||||
text: "Granular dismiss control - Each Health Monitor alert can now be dismissed for 24 hours, 7 days or Permanently via a per-event dropdown. A new Active Suppressions panel in Settings lists every silenced alert with a Re-enable button, gated by Edit mode. Permanent dismisses can only be reverted from there",
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
text: "Auto-reconcile of stale alerts - Errors for resources that no longer exist now auto-clear within the regular cleanup cycle. New cases: a PVE storage removed via pvesm, an NFS/CIFS share whose mount target is no longer in /proc/mounts (the lazy-umount case reported in the field), and LXC mount-capacity alerts whose CT has been deleted",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
text: "Apprise notification channel - One Apprise URL reaches ~80 services (Pushover, ntfy, Slack, Matrix, mailto, signal, ...) with full feature parity to the native channels: per-event toggles, Quiet Hours and Daily Digest all apply",
|
||||
},
|
||||
{
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
text: "LXC update detection - Per-CT apt list --upgradable / apk list -u scan from Settings, with an automatic cache refresh on long-running containers so months-old metadata no longer hides real upstream backlog",
|
||||
text: "Notification Send Test buttons unified (#226) - All five channel Send Test buttons (Telegram, Gotify, Discord, Email, Apprise) now sit on the left side and carry their channel's brand colour with white text, instead of Apprise being the right-aligned cyan outlier",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -690,103 +690,213 @@ export function StorageOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.total.toFixed(1)} TB</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{storageData.disk_count} physical disks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
|
||||
{(() => {
|
||||
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
|
||||
const localPct = totalGB > 0 ? (totalLocalUsed / totalGB) * 100 : 0
|
||||
const remotePct = totalGB > 0 ? (totalRemoteUsed / totalGB) * 100 : 0
|
||||
const freeGB = Math.max(0, totalGB - totalLocalUsed - totalRemoteUsed)
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Storage Used</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const totalUsed = totalLocalUsed + totalRemoteUsed
|
||||
const usedStr = formatStorage(totalUsed)
|
||||
return (
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<span className="text-3xl font-bold leading-none">{usedStr.split(' ')[0]}</span>
|
||||
<span className="text-base font-medium ml-1 text-muted-foreground">{usedStr.split(' ')[1]}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{storageData.disk_count} disks</Badge>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
||||
<div style={{ width: `${localPct}%`, background: '#3b82f6' }} title={`Local ${formatStorage(totalLocalUsed)}`}></div>
|
||||
<div style={{ width: `${remotePct}%`, background: '#06b6d4' }} title={`Remote ${formatStorage(totalRemoteUsed)}`}></div>
|
||||
<div style={{ flex: 1, background: 'rgba(99,102,241,0.15)' }} title={`Free ${formatStorage(freeGB)}`}></div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full" style={{ background: '#3b82f6' }}></span>Local</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalUsed)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full" style={{ background: '#06b6d4' }}></span>Remote</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteUsed)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground"><span className="w-1.5 h-1.5 rounded-full opacity-50" style={{ background: 'currentColor' }}></span>Free</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Local Used</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalLocalUsed)}</div>
|
||||
<p className="text-xs mt-1">
|
||||
<span className={getUsageColor(Number.parseFloat(localUsagePercent))}>{localUsagePercent}%</span>
|
||||
<span className="text-muted-foreground"> of </span>
|
||||
<span className="text-green-500">{formatStorage(totalLocalCapacity)}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Local Used (preview restyle: donut + mini-bars Used/Free) ── */}
|
||||
{(() => {
|
||||
const pct = Number.parseFloat(localUsagePercent)
|
||||
const freeGB = Math.max(0, totalLocalCapacity - totalLocalUsed)
|
||||
const stroke = pct >= 90 ? '#ef4444' : pct >= 75 ? '#f59e0b' : '#22c55e'
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Local Used</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
||||
strokeDasharray={`${pct} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(pct)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Used</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalUsed)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: stroke }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Free</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${100 - pct}%`, background: 'rgba(99,102,241,0.45)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalLocalCapacity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Remote Used</CardTitle>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">
|
||||
{remoteStorageCount > 0 ? formatStorage(totalRemoteUsed) : "None"}
|
||||
</div>
|
||||
<p className="text-xs mt-1">
|
||||
{remoteStorageCount > 0 ? (
|
||||
<>
|
||||
<span className={getUsageColor(Number.parseFloat(remoteUsagePercent))}>{remoteUsagePercent}%</span>
|
||||
<span className="text-muted-foreground"> of </span>
|
||||
<span className="text-green-500">{formatStorage(totalRemoteCapacity)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No remote storage</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Remote Used (preview restyle: donut + mini-bars Used/Free) ── */}
|
||||
{(() => {
|
||||
const has = remoteStorageCount > 0
|
||||
const pct = has ? Number.parseFloat(remoteUsagePercent) : 0
|
||||
const freeGB = has ? Math.max(0, totalRemoteCapacity - totalRemoteUsed) : 0
|
||||
const stroke = pct >= 90 ? '#ef4444' : pct >= 75 ? '#f59e0b' : '#22c55e'
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Remote Used</CardTitle>
|
||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{has ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
||||
strokeDasharray={`${pct} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(pct)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Used</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteUsed)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: stroke }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Free</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(freeGB)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${100 - pct}%`, background: 'rgba(99,102,241,0.45)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{formatStorage(totalRemoteCapacity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-muted-foreground">None</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">No remote storage</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Physical Disks</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||
<div className="space-y-1 mt-1">
|
||||
<p className="text-xs">
|
||||
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
||||
{diskTypesBreakdown.ssd > 0 && (
|
||||
<>
|
||||
{diskTypesBreakdown.nvme > 0 && ", "}
|
||||
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.hdd > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.usb > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0 || diskTypesBreakdown.hdd > 0) && ", "}
|
||||
<span className="text-orange-400">{diskTypesBreakdown.usb} USB</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
{diskHealthBreakdown.warning > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
|
||||
</>
|
||||
)}
|
||||
{diskHealthBreakdown.critical > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Physical Disks (preview restyle: headline + type strip + health badge) ── */}
|
||||
{(() => {
|
||||
const total = Math.max(1, storageData.disk_count || 0)
|
||||
const seg = 100 / total
|
||||
const allHealthy = diskHealthBreakdown.warning === 0 && diskHealthBreakdown.critical === 0
|
||||
const healthBadge = allHealthy
|
||||
? <Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">✓ all healthy</Badge>
|
||||
: diskHealthBreakdown.critical > 0
|
||||
? <Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">{diskHealthBreakdown.critical} critical</Badge>
|
||||
: <Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">{diskHealthBreakdown.warning} warning</Badge>
|
||||
const seg_purple = '#a855f7'
|
||||
const seg_cyan = '#06b6d4'
|
||||
const seg_blue = '#3b82f6'
|
||||
const seg_orange = '#f97316'
|
||||
const segments: Array<{ color: string }> = []
|
||||
for (let i = 0; i < diskTypesBreakdown.nvme; i++) segments.push({ color: seg_purple })
|
||||
for (let i = 0; i < diskTypesBreakdown.ssd; i++) segments.push({ color: seg_cyan })
|
||||
for (let i = 0; i < diskTypesBreakdown.hdd; i++) segments.push({ color: seg_blue })
|
||||
for (let i = 0; i < diskTypesBreakdown.usb; i++) segments.push({ color: seg_orange })
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Physical Disks</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<span className="text-3xl font-bold leading-none">{storageData.disk_count}</span>
|
||||
<span className="text-base font-medium ml-1 text-muted-foreground">disks</span>
|
||||
</div>
|
||||
{healthBadge}
|
||||
</div>
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
||||
{segments.map((s, i) => (
|
||||
<div key={i} style={{ width: `${seg}%`, background: s.color }}></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-between text-sm text-muted-foreground gap-x-2 gap-y-1">
|
||||
{diskTypesBreakdown.nvme > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_purple }}></span>{diskTypesBreakdown.nvme} NVMe</span>}
|
||||
{diskTypesBreakdown.ssd > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_cyan }}></span>{diskTypesBreakdown.ssd} SSD</span>}
|
||||
{diskTypesBreakdown.hdd > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_blue }}></span>{diskTypesBreakdown.hdd} HDD</span>}
|
||||
{diskTypesBreakdown.usb > 0 && <span className="flex items-center gap-1 whitespace-nowrap"><span className="w-1.5 h-1.5 rounded-full" style={{ background: seg_orange }}></span>{diskTypesBreakdown.usb} USB</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
@@ -1477,7 +1587,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
)}
|
||||
{(disk.observations_count ?? 0) > 0 && (
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1 text-[10px] px-1.5 py-0">
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
{disk.observations_count}
|
||||
</Badge>
|
||||
@@ -2150,7 +2260,7 @@ export function StorageOverview() {
|
||||
<h4 className="font-semibold mb-2 flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
Observations
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20 text-[10px] px-1.5 py-0">
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
|
||||
{diskObservations.length}
|
||||
</Badge>
|
||||
</h4>
|
||||
@@ -3627,7 +3737,7 @@ ${observationsHtml}
|
||||
<!-- Footer -->
|
||||
<div class="rpt-footer">
|
||||
<div>Report generated by ProxMenux Monitor</div>
|
||||
<div>ProxMenux Monitor v1.2.2</div>
|
||||
<div>ProxMenux Monitor v1.2.2.1-beta</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -21,9 +21,12 @@ interface TempDataPoint {
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
cpu_user?: number // preview restyle
|
||||
cpu_system?: number // preview restyle
|
||||
memory_usage: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_cached?: number // preview restyle
|
||||
temperature: number
|
||||
temperature_sparkline?: TempDataPoint[]
|
||||
uptime: string
|
||||
@@ -395,64 +398,121 @@ export function SystemOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
{/* ── CPU Usage (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.cpu_usage}%</div>
|
||||
<Progress value={systemData.cpu_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">Real-time usage</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||
strokeDasharray={`${systemData.cpu_usage} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.cpu_usage)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">User</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.cpu_user ?? 0}%` }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">System</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${systemData.cpu_system ?? 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Cores</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Memory (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.memory_used.toFixed(1)} GB</div>
|
||||
<Progress value={systemData.memory_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<span className="text-green-500 font-medium">{systemData.memory_usage.toFixed(1)}%</span> of{" "}
|
||||
{systemData.memory_total} GB
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||
strokeDasharray={`${systemData.memory_usage} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.memory_usage)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Used</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.memory_usage}%` }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Cached</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${systemData.memory_cached !== undefined && systemData.memory_total > 0 ? (systemData.memory_cached / systemData.memory_total) * 100 : 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
Active VM & LXC
|
||||
</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM & LXC</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.vms ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-8 bg-muted rounded w-12"></div>
|
||||
<div className="h-5 bg-muted rounded w-24"></div>
|
||||
<div className="h-4 bg-muted rounded w-32"></div>
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
</Badge>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
|
||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.running} running</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1 flex-wrap">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.vms} VMs</Badge>
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{vmStats.stopped} stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -471,7 +531,7 @@ export function SystemOverview() {
|
||||
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||
</span>
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
<Badge variant="outline" className={`${tempStatus.color}`}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ interface VMData {
|
||||
status: string
|
||||
type: string
|
||||
cpu: number
|
||||
maxcpu?: number
|
||||
mem: number
|
||||
maxmem: number
|
||||
disk: number
|
||||
@@ -418,13 +419,13 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
||||
/>
|
||||
<h3 className="font-mono font-semibold truncate">{mp.target}</h3>
|
||||
{mp.mp_index && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{mp.mp_index}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={typeBadgeClass[mp.type]}>{typeLabel[mp.type]}</Badge>
|
||||
{mp.runtime_fstype && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{mp.runtime_fstype}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -524,7 +525,7 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
||||
if (configEntries.length === 0) return null
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">
|
||||
Mount attributes (LXC config)
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -550,7 +551,7 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
|
||||
exist. */}
|
||||
{(mp.runtime_mounted === true) && (keyValues.length > 0 || flags.length > 0) && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">
|
||||
Runtime mount options
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
@@ -1300,139 +1301,178 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total VMs & LXCs</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{safeVMData.length}</div>
|
||||
<div className="vm-badges mt-2">
|
||||
<Badge variant="outline" className="vm-badge bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{safeVMData.filter((vm) => vm.status === "running").length} Running
|
||||
</Badge>
|
||||
<Badge variant="outline" className="vm-badge bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{safeVMData.filter((vm) => vm.status === "stopped").length} Stopped
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 hidden lg:block">Virtual machines configured</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total CPU</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{(safeVMData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) * 100).toFixed(0)}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Allocated CPU usage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Memory</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Memory Usage (current) */}
|
||||
{physicalMemoryGB !== null && usedMemoryGB !== null && memoryUsagePercent !== null ? (
|
||||
<div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{usedMemoryGB.toFixed(1)} GB</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<span className={getMemoryPercentTextColor(memoryUsagePercent)}>
|
||||
{memoryUsagePercent.toFixed(1)}%
|
||||
</span>{" "}
|
||||
of {physicalMemoryGB.toFixed(1)} GB
|
||||
</div>
|
||||
<Progress value={memoryUsagePercent} className="h-2 [&>div]:bg-blue-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-xl lg:text-2xl font-bold text-muted-foreground">--</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">Loading memory usage...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allocated RAM (configured) - Split into Running and Total */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
{/* Layout para desktop */}
|
||||
<div className="hidden lg:flex items-center justify-between">
|
||||
<div className="flex gap-6">
|
||||
{/* Running allocation - most important */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* ── Total VMs & LXCs (preview restyle: B-headline + pills, matching Overview) ── */}
|
||||
{(() => {
|
||||
const running = safeVMData.filter((vm) => vm.status === "running").length
|
||||
const stopped = safeVMData.filter((vm) => vm.status === "stopped").length
|
||||
const total = safeVMData.length
|
||||
const vms = safeVMData.filter((vm) => vm.type === "qemu" || vm.type === "vm").length
|
||||
const lxc = safeVMData.filter((vm) => vm.type === "lxc").length
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total VMs & LXCs</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-foreground">{runningAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Running Allocated</div>
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{running}</span>
|
||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {total}</span>
|
||||
</div>
|
||||
{/* Total allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-muted-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Total Allocated</div>
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{running} running</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-1 flex-wrap">
|
||||
{vms > 0 && (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vms} VMs</Badge>
|
||||
)}
|
||||
{lxc > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{lxc} LXC</Badge>
|
||||
)}
|
||||
{stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{stopped} stopped</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── Total CPU Allocated (preview restyle: donut + Used/Configured/In use) ── */}
|
||||
{(() => {
|
||||
const allocPct = safeVMData.reduce((sum, vm) => sum + (vm.cpu || 0), 0) * 100
|
||||
const configuredVCPU = safeVMData.reduce((sum, vm) => sum + (vm.maxcpu || 0), 0)
|
||||
const inUseVCPU = safeVMData
|
||||
.filter((vm) => vm.status === "running")
|
||||
.reduce((sum, vm) => sum + (vm.maxcpu || 0), 0)
|
||||
const stroke = allocPct >= 90 ? '#ef4444' : allocPct >= 75 ? '#f59e0b' : '#3b82f6'
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total CPU Allocated</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
||||
strokeDasharray={`${Math.min(100, allocPct)} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(allocPct)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Used</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{Math.round(allocPct)}%</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, allocPct)}%`, background: stroke }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Configured</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{configuredVCPU || '—'} vCPU</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">In use</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{inUseVCPU || '—'} vCPU</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{physicalMemoryGB !== null && (
|
||||
<div>
|
||||
{isMemoryOvercommit ? (
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
Exceeds Physical
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
Within Limits
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Layout para movil */}
|
||||
<div className="lg:hidden space-y-2">
|
||||
<div className="flex gap-4">
|
||||
{/* Running allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-foreground">{runningAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Running</div>
|
||||
</div>
|
||||
{/* Total allocation */}
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-muted-foreground">{totalAllocatedMemoryGB} GB</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
{/* ── Total Memory (preview restyle: donut + mini-bars Used/Allocated) ── */}
|
||||
{(() => {
|
||||
const usedPct = memoryUsagePercent ?? 0
|
||||
const usedGB = usedMemoryGB ?? 0
|
||||
const totalGB = physicalMemoryGB ?? 0
|
||||
const allocPct = totalGB > 0 ? (allocatedMemoryGB / totalGB) * 100 : 0
|
||||
const stroke = usedPct >= 90 ? '#ef4444' : usedPct >= 75 ? '#f59e0b' : '#3b82f6'
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Memory</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||
<circle cx="18" cy="18" r="15.9155" fill="none" stroke={stroke} strokeWidth="3"
|
||||
strokeDasharray={`${usedPct} 100`} strokeLinecap="round"
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(usedPct)}%</text>
|
||||
</svg>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Used</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{usedGB.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${usedPct}%`, background: stroke }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Alloc</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{allocatedMemoryGB.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, allocPct)}%`, background: isMemoryOvercommit ? '#f59e0b' : 'rgba(99,102,241,0.55)' }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-medium font-mono whitespace-nowrap">{totalGB.toFixed(0)} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{physicalMemoryGB !== null && (
|
||||
<div>
|
||||
{isMemoryOvercommit ? (
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
Exceeds Physical
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
Within Limits
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Disk</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{formatStorage(safeVMData.reduce((sum, vm) => sum + (vm.maxdisk || 0), 0) / 1024 ** 3)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Allocated disk space</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Total Disk (preview restyle: headline + 2-segment stacked bar Used/Alloc-not-Used) ── */}
|
||||
{(() => {
|
||||
const usedGB = safeVMData.reduce((sum, vm) => sum + (vm.disk || 0), 0) / 1024 ** 3
|
||||
const allocGB = safeVMData.reduce((sum, vm) => sum + (vm.maxdisk || 0), 0) / 1024 ** 3
|
||||
const utilPct = allocGB > 0 ? (usedGB / allocGB) * 100 : 0
|
||||
const idleGB = Math.max(0, allocGB - usedGB)
|
||||
const stroke = utilPct >= 90 ? '#ef4444' : utilPct >= 75 ? '#f59e0b' : '#3b82f6'
|
||||
const usedSeg = allocGB > 0 ? (usedGB / allocGB) * 100 : 0
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Disk</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<div>
|
||||
<span className="text-xl lg:text-2xl font-bold leading-none">{formatStorage(usedGB)}</span>
|
||||
<span className="text-sm font-medium ml-1 text-muted-foreground">used</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{Math.round(utilPct)}% util</Badge>
|
||||
</div>
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden gap-[2px]">
|
||||
<div style={{ width: `${usedSeg}%`, background: stroke }} title={`Used ${formatStorage(usedGB)}`}></div>
|
||||
<div style={{ flex: 1, background: 'rgba(168,85,247,0.45)' }} title={`Idle ${formatStorage(idleGB)}`}></div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: stroke }}></span>Used {formatStorage(usedGB)}</span>
|
||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full" style={{ background: 'rgba(168,85,247,0.55)' }}></span>Alloc {formatStorage(allocGB)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
@@ -1465,11 +1505,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
onClick={() => handleVMClick(vm)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
<Badge variant="outline" className={`text-xs flex-shrink-0 ${getStatusColor(vm.status)}`}>
|
||||
<Badge variant="outline" className={`flex-shrink-0 ${getStatusColor(vm.status)}`}>
|
||||
{getStatusIcon(vm.status)}
|
||||
{vm.status.toUpperCase()}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-xs flex-shrink-0 ${typeBadge.color}`}>
|
||||
<Badge variant="outline" className={`flex-shrink-0 ${typeBadge.color}`}>
|
||||
{typeBadge.icon}
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
@@ -2835,7 +2875,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
|
||||
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -4858,7 +4858,8 @@ def get_proxmox_vms():
|
||||
'netin': resource.get('netin', 0),
|
||||
'netout': resource.get('netout', 0),
|
||||
'diskread': resource.get('diskread', 0),
|
||||
'diskwrite': resource.get('diskwrite', 0)
|
||||
'diskwrite': resource.get('diskwrite', 0),
|
||||
'maxcpu': resource.get('maxcpu', 0)
|
||||
}
|
||||
# Decorate LXC rows with the apt update status if the
|
||||
# managed_installs registry has it. Absent key means
|
||||
@@ -7640,14 +7641,26 @@ def api_system():
|
||||
try:
|
||||
from health_monitor import health_monitor
|
||||
_hist = health_monitor.state_history.get('cpu_usage') or []
|
||||
cpu_usage = _hist[-1]['value'] if _hist else psutil.cpu_percent(interval=0.1)
|
||||
if _hist:
|
||||
_last = _hist[-1]
|
||||
cpu_usage = _last['value']
|
||||
cpu_user_pct = _last.get('user', 0)
|
||||
cpu_system_pct = _last.get('system', 0)
|
||||
else:
|
||||
cpu_usage = psutil.cpu_percent(interval=0.1)
|
||||
cpu_user_pct = 0
|
||||
cpu_system_pct = 0
|
||||
except Exception:
|
||||
cpu_usage = psutil.cpu_percent(interval=0.1)
|
||||
cpu_user_pct = 0
|
||||
cpu_system_pct = 0
|
||||
|
||||
memory = psutil.virtual_memory()
|
||||
memory_used_gb = memory.used / (1024 ** 3)
|
||||
memory_total_gb = memory.total / (1024 ** 3)
|
||||
memory_usage_percent = memory.percent
|
||||
# Preview restyle: cached + buffers in GB
|
||||
memory_cached_gb = round((getattr(memory, 'cached', 0) + getattr(memory, 'buffers', 0)) / (1024 ** 3), 1)
|
||||
|
||||
# Get temperature
|
||||
temp = get_cpu_temperature()
|
||||
@@ -7677,9 +7690,12 @@ def api_system():
|
||||
|
||||
return jsonify({
|
||||
'cpu_usage': round(cpu_usage, 1),
|
||||
'cpu_user': cpu_user_pct,
|
||||
'cpu_system': cpu_system_pct,
|
||||
'memory_usage': round(memory_usage_percent, 1),
|
||||
'memory_total': round(memory_total_gb, 1),
|
||||
'memory_used': round(memory_used_gb, 1),
|
||||
'memory_cached': memory_cached_gb,
|
||||
'temperature': temp,
|
||||
'temperature_sparkline': temp_sparkline,
|
||||
'uptime': uptime,
|
||||
@@ -9616,6 +9632,35 @@ def api_node_metrics():
|
||||
if 'zfsarc' not in item or item.get('zfsarc', 0) == 0:
|
||||
item['zfsarc'] = zfs_arc_size
|
||||
|
||||
# 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
|
||||
# `get_temperature_history` uses for its 24h view so the look
|
||||
# is consistent across the dashboard's 24h charts.
|
||||
if timeframe == 'day' and rrd_data:
|
||||
bucket_seconds = 300 # 5-min
|
||||
buckets = {}
|
||||
for item in rrd_data:
|
||||
t = item.get('time')
|
||||
if t is None:
|
||||
continue
|
||||
bk = (int(t) // bucket_seconds) * bucket_seconds
|
||||
if bk not in buckets:
|
||||
buckets[bk] = {'_count': 0, '_sums': {}}
|
||||
b = buckets[bk]
|
||||
b['_count'] += 1
|
||||
for k, v in item.items():
|
||||
if k == 'time' or not isinstance(v, (int, float)) or isinstance(v, bool):
|
||||
continue
|
||||
b['_sums'][k] = b['_sums'].get(k, 0) + v
|
||||
rrd_data = []
|
||||
for bk in sorted(buckets.keys()):
|
||||
b = buckets[bk]
|
||||
point = {'time': bk}
|
||||
for k, total in b['_sums'].items():
|
||||
point[k] = total / b['_count']
|
||||
rrd_data.append(point)
|
||||
|
||||
payload = {
|
||||
'node': local_node,
|
||||
'timeframe': timeframe,
|
||||
|
||||
@@ -453,10 +453,19 @@ class HealthMonitor:
|
||||
"""Lightweight CPU sample: read usage % and append to history. ~30ms cost."""
|
||||
try:
|
||||
cpu_percent = psutil.cpu_percent(interval=0)
|
||||
try:
|
||||
_times = psutil.cpu_times_percent(interval=0)
|
||||
cpu_user = round(_times.user + getattr(_times, 'nice', 0), 1)
|
||||
cpu_system = round(_times.system + getattr(_times, 'irq', 0) + getattr(_times, 'softirq', 0), 1)
|
||||
except Exception:
|
||||
cpu_user = 0
|
||||
cpu_system = 0
|
||||
current_time = time.time()
|
||||
state_key = 'cpu_usage'
|
||||
self.state_history[state_key].append({
|
||||
'value': cpu_percent,
|
||||
'user': cpu_user,
|
||||
'system': cpu_system,
|
||||
'time': current_time
|
||||
})
|
||||
# Prune entries older than 6 minutes
|
||||
@@ -608,6 +617,71 @@ class HealthMonitor:
|
||||
|
||||
return self.cached_results[cache_key]
|
||||
|
||||
def _apply_dismiss_aware_status(self, check_block: Dict[str, Any]) -> None:
|
||||
"""In-place demote a check block's `status` to OK when every
|
||||
underlying error is already user-acknowledged.
|
||||
|
||||
Two flavours, matching how categories actually structure their
|
||||
output:
|
||||
|
||||
* Categories that aggregate inner checks (a `checks` dict whose
|
||||
values each hold an individual `error_key`) — every non-OK
|
||||
inner check must be acknowledged for the block to demote.
|
||||
This is how `_check_lxc_mount_capacity`, the storage block,
|
||||
the disk SMART block, etc. shape their results.
|
||||
* Categories with a single error_key at the top level (CPU
|
||||
hysteresis, certificates, the simpler updates rows) — that
|
||||
one error_key has to be acknowledged.
|
||||
|
||||
When the block demotes, we set ``status='OK'`` and stamp
|
||||
``all_dismissed=True`` so the front-end (`fetchHealthInfoCount`
|
||||
and the Health modal) can still surface the row as INFO if it
|
||||
wants — the data flow that used to derive "X categories with
|
||||
dismissed items" from `dismissed[]` keeps working unchanged.
|
||||
|
||||
No-op for blocks whose status is already OK / INFO / UNKNOWN —
|
||||
UNKNOWN intentionally never gets dismissed away because the
|
||||
user didn't ack a failing check, the check failed to run.
|
||||
"""
|
||||
if not isinstance(check_block, dict):
|
||||
return
|
||||
status = check_block.get('status', 'OK')
|
||||
if status not in ('WARNING', 'CRITICAL'):
|
||||
return
|
||||
|
||||
try:
|
||||
inner_checks = check_block.get('checks')
|
||||
if isinstance(inner_checks, dict) and inner_checks:
|
||||
any_unack = False
|
||||
for inner in inner_checks.values():
|
||||
if not isinstance(inner, dict):
|
||||
continue
|
||||
inner_status = inner.get('status', 'OK')
|
||||
if inner_status not in ('WARNING', 'CRITICAL'):
|
||||
continue
|
||||
ek = inner.get('error_key')
|
||||
if ek and health_persistence.is_error_acknowledged(ek):
|
||||
inner['dismissed'] = True
|
||||
if health_persistence.is_error_permanently_acknowledged(ek):
|
||||
inner['permanent'] = True
|
||||
else:
|
||||
any_unack = True
|
||||
if not any_unack:
|
||||
check_block['status'] = 'OK'
|
||||
check_block['all_dismissed'] = True
|
||||
return
|
||||
|
||||
ek = check_block.get('error_key')
|
||||
if ek and health_persistence.is_error_acknowledged(ek):
|
||||
check_block['dismissed'] = True
|
||||
if health_persistence.is_error_permanently_acknowledged(ek):
|
||||
check_block['permanent'] = True
|
||||
check_block['status'] = 'OK'
|
||||
check_block['all_dismissed'] = True
|
||||
except Exception as e:
|
||||
# Dismiss check should never crash the health pipeline.
|
||||
print(f"[HealthMonitor] _apply_dismiss_aware_status failed: {e}")
|
||||
|
||||
def get_overall_status(self) -> Dict[str, Any]:
|
||||
"""Get overall health status summary with minimal overhead"""
|
||||
details = self.get_detailed_status()
|
||||
@@ -994,6 +1068,41 @@ class HealthMonitor:
|
||||
else:
|
||||
self._unknown_counts[cat_key] = 0
|
||||
|
||||
# --- Dismiss-aware re-derivation of issue lists (root fix for #228) ---
|
||||
# Each `_check_*` above already populated `details[<category>]` with
|
||||
# its raw status and pushed an entry into critical_issues /
|
||||
# warning_issues / info_issues. That raw status doesn't know which
|
||||
# error_keys the user has acknowledged, so a category whose only
|
||||
# remaining problems are all dismissed (e.g. nine permanently-
|
||||
# silenced LXC mount alerts) was still pushing the global `overall`
|
||||
# to CRITICAL. The popup's frontend rollup had to compensate for
|
||||
# this server-side gap, which is how the badge ("Critical" in the
|
||||
# header) and the panel ("0 Critical" inside) ended up disagreeing.
|
||||
#
|
||||
# Apply the existing per-block dismiss filter (`_annotate_dismissed`
|
||||
# downstream is the visual-merge cousin of this) to every
|
||||
# category, then rebuild the issue lists from the post-filter
|
||||
# statuses. The pre-existing inline appends are discarded — they
|
||||
# represented the pre-fix view.
|
||||
critical_issues = []
|
||||
warning_issues = []
|
||||
info_issues = []
|
||||
for cat_key in list(details.keys()):
|
||||
block = details.get(cat_key)
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
self._apply_dismiss_aware_status(block)
|
||||
status = block.get('status', 'OK')
|
||||
reason = (block.get('reason') or '').strip()
|
||||
label = cat_key.replace('_', ' ').capitalize()
|
||||
entry = f"{label}: {reason}" if reason else label
|
||||
if status == 'CRITICAL':
|
||||
critical_issues.append(entry)
|
||||
elif status == 'WARNING':
|
||||
warning_issues.append(entry)
|
||||
elif status == 'INFO':
|
||||
info_issues.append(entry)
|
||||
|
||||
# --- Determine Overall Status ---
|
||||
# Severity: CRITICAL > WARNING > UNKNOWN (capped at WARNING) > INFO > OK
|
||||
if critical_issues:
|
||||
|
||||
@@ -1367,6 +1367,8 @@ class HealthPersistence:
|
||||
_zfs_pools_cache = None
|
||||
_mount_points_cache = None
|
||||
_pve_services_cache = None
|
||||
_pvesm_storages_cache = None
|
||||
_remote_mount_targets_cache = None
|
||||
|
||||
def check_vm_ct_cached(vmid):
|
||||
if vmid not in _vm_ct_exists_cache:
|
||||
@@ -1446,6 +1448,67 @@ class HealthPersistence:
|
||||
_mount_points_cache = set()
|
||||
return _mount_points_cache
|
||||
|
||||
def get_pvesm_storages():
|
||||
"""Return the set of pvesm storage IDs currently configured.
|
||||
|
||||
Used to auto-resolve `storage_unavailable_*` and
|
||||
`pve_storage_full_*` errors after the user removes the
|
||||
corresponding entry from `pvesm`/Datacenter > Storage. The
|
||||
check function would otherwise keep firing on a path that
|
||||
no longer has any business existing.
|
||||
"""
|
||||
nonlocal _pvesm_storages_cache
|
||||
if _pvesm_storages_cache is None:
|
||||
_pvesm_storages_cache = set()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pvesm', 'status'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n')[1:]:
|
||||
parts = line.split()
|
||||
if parts:
|
||||
_pvesm_storages_cache.add(parts[0])
|
||||
except Exception:
|
||||
# On failure leave the cache as an empty set rather
|
||||
# than `None` — that prevents us from re-trying every
|
||||
# row in the active_errors loop, and the empty set
|
||||
# means we won't auto-resolve anything (safer than
|
||||
# falsely resolving when pvesm is momentarily down).
|
||||
_pvesm_storages_cache = set()
|
||||
return _pvesm_storages_cache
|
||||
return _pvesm_storages_cache
|
||||
|
||||
def get_remote_mount_targets():
|
||||
"""Return the set of mount targets currently in /proc/mounts
|
||||
for remote filesystems (NFS/CIFS/SMB).
|
||||
|
||||
Lets us tell apart a `mount_stale_<target>` whose underlying
|
||||
mount the user has umount'd (so the alert is now stale data
|
||||
that should self-clear) from one the user genuinely needs
|
||||
attention on (the mount is still active but the share is
|
||||
unreachable). Without this distinction the alert pinned
|
||||
forever once the user removed the PVE storage and lazy-
|
||||
umount'd it, which is the case @UBLI-WLAN reported.
|
||||
"""
|
||||
nonlocal _remote_mount_targets_cache
|
||||
if _remote_mount_targets_cache is None:
|
||||
_remote_mount_targets_cache = set()
|
||||
try:
|
||||
with open('/proc/mounts', 'r', encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
fstype = parts[2]
|
||||
# Match the same fstypes mount_monitor watches.
|
||||
if fstype in ('nfs', 'nfs4', 'cifs', 'smb', 'smbfs') or fstype.startswith(('nfs', 'cifs', 'smb')):
|
||||
_remote_mount_targets_cache.add(parts[1])
|
||||
except OSError:
|
||||
pass
|
||||
return _remote_mount_targets_cache
|
||||
|
||||
def get_pve_services_status():
|
||||
nonlocal _pve_services_cache
|
||||
if _pve_services_cache is None:
|
||||
@@ -1617,9 +1680,72 @@ class HealthPersistence:
|
||||
should_resolve = True
|
||||
resolution_reason = 'No longer in cluster'
|
||||
|
||||
# === PVE STORAGE REMOVED ===
|
||||
# Errors that name a PVE storage (storage_unavailable_<id>,
|
||||
# pve_storage_full_<id>) outlive the storage itself when the
|
||||
# user removes it from pvesm. Until this hook landed, the
|
||||
# check function kept stat'ing /mnt/pve/<id> after every
|
||||
# iteration, found the path missing, and persisted a fresh
|
||||
# CRITICAL — reported by @UBLI-WLAN on June 4 2026.
|
||||
if not should_resolve and error_key:
|
||||
storage_match = None
|
||||
if error_key.startswith('storage_unavailable_'):
|
||||
storage_match = error_key[len('storage_unavailable_'):]
|
||||
elif error_key.startswith('pve_storage_full_'):
|
||||
storage_match = error_key[len('pve_storage_full_'):]
|
||||
if storage_match:
|
||||
pvesm_set = get_pvesm_storages()
|
||||
# Only treat as removed when `pvesm status` ran AND
|
||||
# returned a non-empty list. An empty set could mean
|
||||
# pvesm timed out, in which case it's safer not to
|
||||
# resolve anything.
|
||||
if pvesm_set and storage_match not in pvesm_set:
|
||||
should_resolve = True
|
||||
resolution_reason = f'Storage {storage_match} removed from pvesm'
|
||||
|
||||
# === LXC MOUNT FOR DELETED CT ===
|
||||
# `_check_lxc_mount_capacity` records
|
||||
# `lxc_mount_<vmid>_<mount>`, which the VM/CT block above
|
||||
# misses because the prefix isn't one of `vm_/ct_/vmct_`.
|
||||
# When the CT is gone the disk-fill alert is meaningless.
|
||||
if not should_resolve and error_key and error_key.startswith('lxc_mount_'):
|
||||
# `lxc_mount_<vmid>_<mount-path-tokens>` — VMID is the
|
||||
# first integer block after the prefix.
|
||||
m = re.match(r'^lxc_mount_(\d+)_', error_key)
|
||||
if m:
|
||||
lxc_vmid = m.group(1)
|
||||
if not check_vm_ct_cached(lxc_vmid):
|
||||
should_resolve = True
|
||||
resolution_reason = f'CT {lxc_vmid} no longer exists'
|
||||
|
||||
# === ORPHAN REMOTE MOUNT ===
|
||||
# `_check_remote_mounts` records `mount_<status>_<target>`
|
||||
# for every NFS/CIFS/SMB target that's in /proc/mounts but
|
||||
# fails to stat. When the user removes the PVE storage,
|
||||
# PVE often does a lazy umount: the kernel mount entry is
|
||||
# gone (or the /mnt/pve/<id> target was deleted on top), so
|
||||
# subsequent scans never see the mount again — but the
|
||||
# already-persisted error has no auto-resolve path.
|
||||
# Resolve the error when the target is no longer in
|
||||
# /proc/mounts as a remote mount.
|
||||
if not should_resolve and error_key and error_key.startswith('mount_'):
|
||||
# `mount_stale_<target>` or `mount_readonly_<target>`
|
||||
# — possibly LXC-scoped as `mount_<status>_ct<id>:<target>`.
|
||||
stripped = error_key.split('_', 2)
|
||||
if len(stripped) == 3:
|
||||
key_target = stripped[2]
|
||||
# LXC-scoped entries (`ct123:/mnt/foo`) are left for
|
||||
# the VM/CT cleanup path; the host-side reconciler
|
||||
# only owns host-level targets.
|
||||
if not key_target.startswith('ct'):
|
||||
targets = get_remote_mount_targets()
|
||||
if key_target not in targets:
|
||||
should_resolve = True
|
||||
resolution_reason = 'Remote mount no longer present (orphan auto-cleared)'
|
||||
|
||||
# === TEMPERATURE ERRORS ===
|
||||
# Temperature errors - check if sensor still exists (unlikely to change, resolve after 24h of no activity)
|
||||
elif category == 'temperature':
|
||||
if not should_resolve and category == 'temperature':
|
||||
if last_seen_hours > 24:
|
||||
should_resolve = True
|
||||
resolution_reason = 'Temperature error stale (>24h no activity)'
|
||||
|
||||
@@ -170,19 +170,46 @@ def _detect_nvidia_xfree86() -> Optional[dict]:
|
||||
def _detect_coral_host() -> list[dict]:
|
||||
out: list[dict] = []
|
||||
|
||||
# PCIe / M.2 — gasket-dkms package version, falling back to the
|
||||
# registered DKMS version if the package was force-removed but the
|
||||
# built modules still exist.
|
||||
# PCIe / M.2 — version detection has three sources, tried in this
|
||||
# order of trust:
|
||||
#
|
||||
# 1. The marker file `/var/lib/proxmenux/coral_gasket_version`
|
||||
# written by `install_coral.sh` after a successful DKMS
|
||||
# install — contains the feranick release tag actually
|
||||
# installed (e.g. `1.0-18.4`). This is the only source that
|
||||
# knows the fork's patch level.
|
||||
# 2. `dpkg-query gasket-dkms` — the Debian package version, only
|
||||
# present when the user installed via .deb rather than the
|
||||
# ProxMenux script.
|
||||
# 3. `dkms status` — the upstream module version registered with
|
||||
# DKMS, which is always the bare `1.0`. Useful as a "modules
|
||||
# are present" indicator but doesn't reveal the fork patch
|
||||
# level, so the update-availability check would always fire a
|
||||
# false positive against feranick's `1.0-N` tags. Reported on
|
||||
# .50 after a successful re-install kept showing the update
|
||||
# notification.
|
||||
pcie_version: Optional[str] = None
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if r.returncode == 0 and "ok installed" in r.stdout:
|
||||
pcie_version = r.stdout.split("|", 1)[1].strip()
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
with open("/var/lib/proxmenux/coral_gasket_version",
|
||||
"r", encoding="utf-8", errors="replace") as fh:
|
||||
marker = fh.read().strip()
|
||||
# Sanity check: the file should hold something that looks
|
||||
# like a version tag, not an error message or empty line.
|
||||
if marker and re.match(r"^[A-Za-z0-9._+-]+$", marker):
|
||||
pcie_version = marker
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not pcie_version:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if r.returncode == 0 and "ok installed" in r.stdout:
|
||||
pcie_version = r.stdout.split("|", 1)[1].strip()
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if not pcie_version:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
|
||||
@@ -363,6 +363,35 @@ EOF
|
||||
fi
|
||||
msg_ok "$(translate 'Drivers compiled and installed via DKMS.') (source: ${GASKET_SOURCE_USED})"
|
||||
|
||||
# Track which feranick release was just installed. Without this, the
|
||||
# Monitor's update-notification path (managed_installs._check_coral_host)
|
||||
# only has dkms status to read, which always reports the bare upstream
|
||||
# version `1.0` regardless of feranick patch level — so any new
|
||||
# `1.0-N` tag from the fork triggered a false-positive "update
|
||||
# available" notification even right after a fresh install.
|
||||
#
|
||||
# Resolve the latest tag from GitHub at install time and persist it
|
||||
# alongside ProxMenux state. Best-effort: a curl failure or rate-limit
|
||||
# leaves the marker absent and the detector falls back to the old
|
||||
# behaviour, which is fine for clean re-installs.
|
||||
if [[ "$GASKET_SOURCE_USED" == "feranick" ]]; then
|
||||
local CORAL_MARKER_DIR="/var/lib/proxmenux"
|
||||
local CORAL_MARKER_FILE="${CORAL_MARKER_DIR}/coral_gasket_version"
|
||||
local FERANICK_LATEST
|
||||
FERANICK_LATEST=$(curl -fsSL --max-time 5 \
|
||||
"https://api.github.com/repos/feranick/gasket-driver/releases/latest" 2>>"$LOG_FILE" \
|
||||
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
||||
| head -1 \
|
||||
| sed -E 's/.*"([^"]+)"$/\1/')
|
||||
if [[ -n "$FERANICK_LATEST" ]]; then
|
||||
mkdir -p "$CORAL_MARKER_DIR" >>"$LOG_FILE" 2>&1 || true
|
||||
echo "$FERANICK_LATEST" > "$CORAL_MARKER_FILE" 2>>"$LOG_FILE" || true
|
||||
echo "[install_coral] Recorded installed gasket-dkms version: $FERANICK_LATEST" >>"$LOG_FILE" 2>&1
|
||||
else
|
||||
echo "[install_coral] Could not resolve feranick latest tag — marker not written." >>"$LOG_FILE" 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
ensure_apex_group_and_udev
|
||||
|
||||
msg_info "$(translate 'Loading modules...')"
|
||||
@@ -595,6 +624,11 @@ complete_coral_uninstall() {
|
||||
/etc/apt/trusted.gpg.d/coral-edgetpu-archive-keyring.gpg \
|
||||
2>/dev/null || true
|
||||
|
||||
# Drop the gasket-dkms version marker written by the install path.
|
||||
# Leaving it around after a full uninstall would let the Monitor
|
||||
# claim a fictional driver version on the next reboot.
|
||||
rm -f /var/lib/proxmenux/coral_gasket_version 2>/dev/null || true
|
||||
|
||||
# Update component status if utils.sh exposes the helper (older
|
||||
# ProxMenux releases didn't have it; uninstall must still work).
|
||||
if declare -f update_component_status >/dev/null 2>&1; then
|
||||
|
||||
Reference in New Issue
Block a user