30 Commits
v1.2.2 ... main

Author SHA1 Message Date
ProxMenuxBot
132a52c7b2 Update helpers_cache.json 2026-06-15 11:49:19 +00:00
ProxMenuxBot
bf1f68d5e6 Update helpers_cache.json 2026-06-13 19:12:04 +00:00
ProxMenuxBot
f066eb0a60 Update helpers_cache.json 2026-06-13 02:40:36 +00:00
ProxMenuxBot
fe81ae8f7d Update helpers_cache.json 2026-06-12 09:58:41 +00:00
ProxMenuxBot
0b8c0f57f8 Update helpers_cache.json 2026-06-11 20:11:23 +00:00
ProxMenuxBot
8e9c853a81 Update helpers_cache.json 2026-06-11 15:39:44 +00:00
ProxMenuxBot
1f13a35c51 Update helpers_cache.json 2026-06-11 10:10:01 +00:00
ProxMenuxBot
38845197d8 Update helpers_cache.json 2026-06-10 20:16:47 +00:00
ProxMenuxBot
d9d63b8f1c Update helpers_cache.json 2026-06-09 19:58:09 +00:00
ProxMenuxBot
dac5ff72a3 Update helpers_cache.json 2026-06-07 12:30:17 +00:00
ProxMenuxBot
083f8e5fd7 Update helpers_cache.json 2026-06-07 00:36:27 +00:00
ProxMenuxBot
218ab2aa89 Update helpers_cache.json 2026-06-06 18:25:14 +00:00
ProxMenuxBot
9a938d129b Update helpers_cache.json 2026-06-06 12:24:58 +00:00
ProxMenuxBot
09abef2d15 Update helpers_cache.json 2026-06-06 00:34:59 +00:00
ProxMenuxBot
0a09fa4987 Update helpers_cache.json 2026-06-05 18:37:30 +00:00
MacRimi
9656b04a3e Merge branch 'main' of https://github.com/MacRimi/ProxMenux 2026-06-05 17:12:27 +02:00
MacRimi
3629fe8848 Add beta 1.2.2.1 2026-06-05 17:12:23 +02:00
ProxMenuxBot
e6fe598e7a Update helpers_cache.json 2026-06-05 12:57:15 +00:00
MacRimi
e855fca0b3 new beta 1.2.2.1 2026-06-03 18:04:58 +02:00
MacRimi
9b0e498c6d Merge pull request #229 from MacRimi/feature/installer-clear-beta-marker
install_proxmenux: clear stale beta_version.txt on every stable install
2026-06-03 16:35:37 +02:00
MacRimi
371f61fa08 install_proxmenux: clear stale beta_version.txt on every stable install
A user who rode the beta channel and later switched back to stable
keeps a leftover beta_version.txt under /usr/local/share/proxmenux/.
The `menu` launcher's beta-mode update check (`check_updates_beta`)
short-circuits when that file isn't present, but it stays put across
stable installs and updates today, so the user keeps seeing the
"Beta update available" prompt on top of the legitimate stable one
even though they're no longer on the beta channel.

Drop the marker on every stable install/update, in both the update
path (around the `cp ./version.txt` near the scripts-tree wipe) and
the fresh-install path (the symmetric block lower in the file).
The comment about which files survive a scripts-tree wipe is
updated to no longer mention beta_version.txt, since that's exactly
what we're removing.

If the user re-opts into the beta program, install_proxmenux_beta
re-creates the file — this only clears stale state that the user no
longer has any way to update from anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 16:34:20 +02:00
ProxMenuxBot
fa6ff43c6f Update helpers_cache.json 2026-06-03 13:32:28 +00:00
MacRimi
bde36dd241 Merge pull request #225 from MacRimi/revert/v1.2.2.1-bump
Revert v1.2.2.1 patch release — keep code fix, drop unnecessary bump
2026-06-02 21:08:15 +02:00
MacRimi
ae91fc4cdd Revert v1.2.2.1 patch release — keep code fix, drop unnecessary bump
PR #223 shipped the install_proxmenux.sh unit-rewrite fix together
with a version bump to 1.2.2.1 and a matching CHANGELOG entry.
With both the fix (#223) and the menu self-heal (#224) already in
main the bump turns out to be unnecessary for recovery:

* Users on v1.2.1 stable updating now pull the corrected installer
  from main and arrive at v1.2.2 working.
* Users stuck on a broken v1.2.2 get repaired by
  `auto_repair_monitor_unit` on every menu launch.
* Users on a healthy v1.2.2 had nothing to fix.

Leaving 1.2.2.1 published would force a no-op update prompt across
every healthy v1.2.2 install. Revert version.txt to 1.2.2 and drop
the v1.2.2.1 CHANGELOG section (EN+ES) so the public release notes
stay clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:06:39 +02:00
MacRimi
6c7a8cae92 Merge pull request #224 from MacRimi/hotfix/v1.2.2.1-menu-self-heal
v1.2.2.1 part 2: self-heal monitor unit on menu launch (#222)
2026-06-02 20:48:00 +02:00
MacRimi
7cea5563a7 menu: self-heal broken monitor unit on launch (belt-and-suspenders for #222)
The installer fix in this PR rewrites the systemd unit on every
v1.2.2.x update, which catches every user once they accept the
update prompt. But the prompt in `menu` uses `--defaultno` so a
user who presses Enter by reflex stays on the broken state and
opens a fresh issue, which is the scenario unfolding in #222.

Add a tiny `auto_repair_monitor_unit` function that runs before
`check_updates` on every menu launch. It only touches anything when
the bug's exact fingerprint is present:

  1. /etc/systemd/system/proxmenux-monitor.service exists
  2. Its ExecStart points at /usr/local/share/proxmenux/ProxMenux-Monitor.AppImage
  3. The extracted AppRun is already on disk at /usr/local/share/proxmenux/monitor-app/AppRun

When all three are true the function rewrites the unit, reloads
systemd, restarts the service, and logs a single msg_ok line. For
healthy installs and for hosts that never had the Monitor at all,
it returns immediately without touching anything — safe to ship
unconditionally.

Verified on .55 by simulating the broken unit (ExecStart on the
bare AppImage → 203/EXEC + activating loop) and running the new
menu script: unit rewritten to AppRun, service active, single
"ProxMenux Monitor unit repaired and restarted" line printed.

CHANGELOG entries (EN+ES) updated to mention the auto-repair so
users on the broken state know the simpler recovery is now "just
run menu".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:44:12 +02:00
MacRimi
755c289894 Merge pull request #223 from MacRimi/hotfix/v1.2.2.1-monitor-unit-rewrite
v1.2.2.1: rewrite monitor unit on update to point at AppRun (fixes #222)
2026-06-02 20:34:22 +02:00
MacRimi
17c5b89cc8 v1.2.2.1: rewrite monitor unit on every update to point at AppRun (#222)
The v1.2.2 install layout extracts the AppImage into
/usr/local/share/proxmenux/monitor-app/ and runs AppRun out of that
directory — but install_proxmenux_monitor's update branch only
called create_monitor_service on fresh installs, leaving the inherited
unit's `ExecStart=/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage`
in place. That path used to be the FUSE-mounted AppImage entry point,
which v1.2.2 deliberately replaced to clear a Wazuh rule-521 false
positive on /tmp/.mount_*. On PVE 9.x / Debian 13 the bare AppImage
fails to exec straight away (status=203/EXEC) so the service entered
the activating loop reported in #222 and never came back up.

Always rewrite the unit before the post-update `systemctl start` —
idempotent for installs whose unit is already correct, recovering
for those whose isn't. The new helper
`_proxmenux_rewrite_monitor_unit_for_apprun` mirrors the unit body
the fresh-install path emits in `create_monitor_service`, with the
same template-from-repo / inline-fallback fork, so both paths
converge on the same content.

Reproduced and validated on PVE 9.x lab:

  before:
    Process: ExecStart=/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage
             (code=exited, status=203/EXEC)
    Active: activating (auto-restart)

  after:
    ExecStart=/usr/local/share/proxmenux/monitor-app/AppRun
    Active: active (running)

Bumps version.txt to 1.2.2.1 so the existing menu update path picks
this up automatically. For users already stuck on a broken v1.2.2,
re-running the installer manually applies the same fix:
  bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 20:31:33 +02:00
MacRimi
32a3e20c76 Merge pull request #221 from MacRimi/hotfix/doc-nav-storage-share-anchors
Hotfix: doc-navigation skips sidebar anchor-only section headers
2026-06-02 19:50:41 +02:00
MacRimi
5e795a654d doc-navigation: skip sidebar anchor-only entries from Prev/Next walk
`#host` and `#lxc-net` are visual sidebar section headers for the
Storage Share Manager page — they group their submenu items in the
sidebar tree but point back at the parent Overview with an anchor,
so they aren't standalone docs the reader advances to. Including
them in the flat Previous/Next sequence produced two regressions:

* On `/docs/storage-share/#host` the Next button targeted `#host`
  again, so clicking it didn't move. The earlier hash-tracking fix
  intended to catch this, but a `useEffect` with an empty dep array
  only runs on mount — and Next.js Link navigations don't fire
  `hashchange` when the path changes too, so a cross-page navigation
  that lands on `#host` (sidebar click) rendered with hash="" and
  re-collapsed to the section header.
* On `/docs/storage-share/lxc-mount-points/` the Next button pointed
  at `#lxc-net` instead of advancing to `lxc-nfs-client`, since the
  section header sat between the two real pages in the flat list.

Filter out any sidebar entry whose href contains `#` at walk time so
the flat list only carries real pages. With them gone, an anchored
URL collapses to its parent Overview and Next walks straight into
the first subpage. The hash effect + state are no longer needed so
the component drops them, keeping only the pathname-based match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 19:49:36 +02:00
18 changed files with 1484 additions and 678 deletions

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -1459,10 +1459,12 @@ export function NotificationSettings() {
{renderChannelCategories("telegram")}
{renderQuietHours("telegram")}
{renderDailyDigest("telegram")}
{/* Send Test */}
{/* Send Test — channel-colored button (#226). All five
channels follow the same `bg-<channel>-600 ... text-white`
pattern; the color matches the active-tab tint above. */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("telegram")}
disabled={testing === "telegram" || !config.channels.telegram?.bot_token}
>
@@ -1553,10 +1555,10 @@ export function NotificationSettings() {
{renderChannelCategories("gotify")}
{renderQuietHours("gotify")}
{renderDailyDigest("gotify")}
{/* Send Test */}
{/* Send Test — channel-colored (see Telegram block). */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
className="h-7 px-3 text-xs rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("gotify")}
disabled={testing === "gotify" || !config.channels.gotify?.url}
>
@@ -1635,10 +1637,10 @@ export function NotificationSettings() {
{renderChannelCategories("discord")}
{renderQuietHours("discord")}
{renderDailyDigest("discord")}
{/* Send Test */}
{/* Send Test — channel-colored (see Telegram block). */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
className="h-7 px-3 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("discord")}
disabled={testing === "discord" || !config.channels.discord?.webhook_url}
>
@@ -1780,10 +1782,10 @@ export function NotificationSettings() {
{renderChannelCategories("email")}
{renderQuietHours("email")}
{renderDailyDigest("email")}
{/* Send Test */}
{/* Send Test — channel-colored (see Telegram block). */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 disabled:opacity-50"
className="h-7 px-3 text-xs rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("email")}
disabled={testing === "email" || !config.channels.email?.to_addresses}
>
@@ -1881,9 +1883,11 @@ export function NotificationSettings() {
{renderChannelCategories("apprise")}
{renderQuietHours("apprise")}
{renderDailyDigest("apprise")}
<div className="flex justify-end pt-2 border-t border-border/50">
{/* Send Test — left-aligned + channel-colored, matching
the other four channels (was right-aligned, #226). */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
className="h-7 px-3 text-xs rounded-md bg-cyan-600 hover:bg-cyan-700 text-white transition-colors flex items-center gap-1.5 disabled:opacity-50"
onClick={() => handleTest("apprise")}
disabled={testing === "apprise" || !config.channels.apprise?.url}
>

View File

@@ -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"

View File

@@ -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",
},
]

View File

@@ -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>

View File

@@ -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 &amp; 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>

View File

@@ -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 &amp; 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>

View File

@@ -1,6 +1,6 @@
{
"name": "ProxMenux-Monitor",
"version": "1.2.2",
"version": "1.2.2.1-beta",
"description": "Proxmox System Monitoring Dashboard",
"private": true,
"scripts": {

View File

@@ -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,

View File

@@ -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()
@@ -993,7 +1067,42 @@ class HealthMonitor:
pass
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:

View File

@@ -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:
@@ -1445,7 +1447,68 @@ class HealthPersistence:
except Exception:
_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)'

View File

@@ -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(

View File

@@ -682,14 +682,25 @@ install_proxmenux_monitor() {
fi
msg_ok "ProxMenux Monitor v$appimage_version installed."
if [ "$service_exists" = false ]; then
return 0 # New installation - service needs to be created
else
# The v1.2.2 install layout extracts the AppImage into
# MONITOR_RUNTIME_DIR/ and runs AppRun out of that directory
# (`extract_appimage_to_runtime_dir` above), so the unit must
# point at AppRun — not at the bare AppImage. Existing users
# updating from v1.2.1.x stable still have a unit whose
# ExecStart targets `/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage`
# which was fine when the AppImage was FUSE-mounted but breaks
# under PVE 9.x / Debian 13 (status=203/EXEC, GitHub issue #222).
# Rewrite the unit on every update — idempotent for users
# whose unit is already correct.
_proxmenux_rewrite_monitor_unit_for_apprun
systemctl start proxmenux-monitor.service
sleep 2
if systemctl is-active --quiet proxmenux-monitor.service; then
update_config "proxmenux_monitor" "updated"
@@ -702,6 +713,44 @@ install_proxmenux_monitor() {
fi
}
# Idempotent rewriter of the proxmenux-monitor unit file. Used by the
# update path in `install_proxmenux_monitor` so that existing
# installations updated to v1.2.2+ get their ExecStart corrected to
# point at the extracted AppRun even when the unit already exists.
# Mirrors `create_monitor_service`'s unit body so both code paths
# converge on the same file content. Returns 0 always; failures are
# logged so the surrounding flow can still attempt the start and
# report a more accurate failure to the user.
_proxmenux_rewrite_monitor_unit_for_apprun() {
local exec_path="$MONITOR_RUNTIME_DIR/AppRun"
if [ -f "$TEMP_DIR/systemd/proxmenux-monitor.service" ]; then
sed "s|ExecStart=.*|ExecStart=$exec_path|g" \
"$TEMP_DIR/systemd/proxmenux-monitor.service" > "$MONITOR_SERVICE_FILE"
else
cat > "$MONITOR_SERVICE_FILE" << EOF
[Unit]
Description=ProxMenux Monitor - Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$MONITOR_INSTALL_DIR
ExecStart=$exec_path
Restart=on-failure
RestartSec=10
Environment="PORT=$MONITOR_PORT"
[Install]
WantedBy=multi-user.target
EOF
fi
systemctl daemon-reload
return 0
}
create_monitor_service() {
local exec_path="$MONITOR_RUNTIME_DIR/AppRun"
@@ -876,11 +925,20 @@ install_normal_version() {
cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
# A user that previously rode the beta train and then switched back
# to stable would still have a leftover beta_version.txt under
# $BASE_DIR, which makes the `menu` update check (check_updates_beta)
# offer a "Beta update available" prompt on top of the legitimate
# stable one. Clearing the marker on every stable install/update
# keeps the stable install honestly stable — if the user opts into
# the beta program again, the beta installer will recreate the file.
rm -f "$BASE_DIR/beta_version.txt"
# Wipe the scripts tree before copying so any file removed upstream
# (renamed, consolidated, deprecated) disappears from the user install.
# Only $BASE_DIR/scripts/ is cleared; config.json, cache.json,
# components_status.json, version.txt, beta_version.txt, monitor.db,
# smart/, oci/ and the AppImage live outside this path and are preserved.
# components_status.json, version.txt, monitor.db, smart/, oci/ and
# the AppImage live outside this path and are preserved.
rm -rf "$BASE_DIR/scripts"
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
@@ -1024,6 +1082,12 @@ install_translation_version() {
cp "./version.txt" "$LOCAL_VERSION_FILE"
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
# Clear any leftover beta_version.txt — see the equivalent block
# in the update path above for the rationale (in short: prevents
# the menu from offering a phantom "Beta update available" after a
# user has switched back to the stable channel).
rm -f "$BASE_DIR/beta_version.txt"
mkdir -p "$BASE_DIR/scripts"
cp -r "./scripts/"* "$BASE_DIR/scripts/"
chmod -R +x "$BASE_DIR/scripts/"

File diff suppressed because it is too large Load Diff

65
menu
View File

@@ -46,6 +46,70 @@ is_beta() {
[[ "$beta_flag" == "active" ]]
}
# ── Recover broken Monitor unit before anything else ──────
#
# v1.2.2 changed the AppImage layout: the binary is extracted to
# /usr/local/share/proxmenux/monitor-app/ and the systemd unit
# executes AppRun out of that directory. The install_proxmenux.sh
# update path before v1.2.2.1 only rewrote the unit on fresh installs,
# so every user updating from v1.2.1 stable inherited the old unit
# whose ExecStart still pointed at the bare AppImage. On PVE 9.x /
# Debian 13 that bare AppImage hits status=203/EXEC immediately and
# the service enters the activating loop reported in #222.
#
# Re-running the new installer fixes it permanently, but the update
# prompt below uses --defaultno so a user pressing Enter by reflex
# stays broken. Patch the unit defensively at every menu launch: if
# the bug's exact fingerprint is present (unit ExecStart matches the
# bare AppImage path AND the extracted AppRun already exists) we
# silently rewrite the unit and bounce the service. The check is a
# no-op for healthy installs and for hosts that never installed the
# Monitor at all, so it's safe to run unconditionally.
auto_repair_monitor_unit() {
local unit_file="/etc/systemd/system/proxmenux-monitor.service"
local extracted_runtime="/usr/local/share/proxmenux/monitor-app"
local apprun="$extracted_runtime/AppRun"
local bare_appimage="/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"
[[ -f "$unit_file" && -x "$apprun" ]] || return 0
grep -q "^ExecStart=${bare_appimage}\$" "$unit_file" || return 0
local port working_dir
port=$(awk -F'"' '/^Environment="PORT=/ {print $2; exit}' "$unit_file" 2>/dev/null \
| sed 's/^PORT=//')
[[ -z "$port" ]] && port="8008"
working_dir=$(awk -F'=' '/^WorkingDirectory=/ {print $2; exit}' "$unit_file" 2>/dev/null)
[[ -z "$working_dir" ]] && working_dir="/usr/local/share/proxmenux"
cat > "$unit_file" <<EOF
[Unit]
Description=ProxMenux Monitor - Web Dashboard
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$working_dir
ExecStart=$apprun
Restart=on-failure
RestartSec=10
Environment="PORT=$port"
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload >/dev/null 2>&1
systemctl restart proxmenux-monitor.service >/dev/null 2>&1
sleep 2
if systemctl is-active --quiet proxmenux-monitor.service 2>/dev/null; then
type msg_ok >/dev/null 2>&1 \
&& msg_ok "$(translate 'ProxMenux Monitor unit repaired and restarted')" \
|| echo "[ProxMenux] Monitor unit repaired and restarted"
fi
}
# ── Check for updates ──────────────────────────────────────
check_updates() {
if is_beta; then
@@ -127,5 +191,6 @@ main_menu() {
load_language
initialize_cache
auto_repair_monitor_unit
check_updates
main_menu

View File

@@ -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

View File

@@ -9,7 +9,6 @@
import { Link, usePathname } from "@/i18n/navigation"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
import { sidebarItems } from "@/components/DocSidebar"
interface DocNavigationProps {
@@ -31,6 +30,21 @@ interface FlatPage {
sectionI18nKey?: string
}
// Sidebar entries whose href contains a fragment (`#host`, `#lxc-net`,
// …) are visual section headers that group submenu items inside an
// existing physical page (currently only Storage Share Manager uses
// this pattern — `Host storage integration` and `LXC network sharing`
// are headers for groups of subpages, but their href is the parent
// Overview page with an anchor). They aren't standalone docs the
// reader can advance to, so including them in the Previous/Next walk
// produces two regressions: on the page they anchor (`#host`) Next
// circles back to the same URL, and on a regular page that happens to
// sit next to one of them in the flat list (`lxc-mount-points`) Next
// jumps to the section header instead of skipping to the next real
// subpage. Skip them at walk time so both cases collapse to "the next
// real page in reading order".
const isAnchorOnlyHref = (href: string) => href.includes("#")
function walkSubmenu(
items: SubMenuItem[],
section: string,
@@ -38,13 +52,15 @@ function walkSubmenu(
out: FlatPage[],
) {
items.forEach((sub) => {
out.push({
title: sub.title,
i18nKey: sub.i18nKey,
href: sub.href,
section,
sectionI18nKey,
})
if (!isAnchorOnlyHref(sub.href)) {
out.push({
title: sub.title,
i18nKey: sub.i18nKey,
href: sub.href,
section,
sectionI18nKey,
})
}
if (sub.submenu && sub.submenu.length > 0) {
walkSubmenu(sub.submenu, section, sectionI18nKey, out)
}
@@ -56,26 +72,6 @@ export function DocNavigation({ className }: DocNavigationProps) {
const tNav = useTranslations("docNav")
const tSidebar = useTranslations("docSidebar")
// Capture the URL hash (`#host`, `#lxc-net`, …) on the client so we
// can disambiguate Previous/Next when a single doc page hosts several
// sidebar entries via in-page anchors (Storage Share Manager is the
// canonical case: /docs/storage-share + /docs/storage-share#host +
// /docs/storage-share#lxc-net are three distinct sidebar items but a
// single physical page; usePathname() returns the same string for
// all three because the fragment is not part of the path).
//
// SSR can't see the hash, so we hydrate with an empty string and
// refresh on mount + on hashchange. The brief render before
// hydration just shows the navigation as if the user were at the
// parent page — same behaviour as before this fix, so no regression.
const [hash, setHash] = useState("")
useEffect(() => {
const sync = () => setHash(window.location.hash || "")
sync()
window.addEventListener("hashchange", sync)
return () => window.removeEventListener("hashchange", sync)
}, [])
const tItem = (i18nKey: string | undefined, fallback: string) => {
if (!i18nKey) return fallback
try {
@@ -89,7 +85,7 @@ export function DocNavigation({ className }: DocNavigationProps) {
const flatItems: FlatPage[] = []
sidebarItems.forEach((item) => {
if (item.href) {
if (item.href && !isAnchorOnlyHref(item.href)) {
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
}
@@ -124,29 +120,14 @@ export function DocNavigation({ className }: DocNavigationProps) {
const stripTrailingSlash = (s: string) => (s !== "/" ? s.replace(/\/+$/, "") : s)
const normalizedPathname = stripTrailingSlash(pathname)
// Match attempt order:
// 1. pathname + hash (e.g. /docs/storage-share#host) — exact match
// against sidebar items that intentionally point to an in-page
// anchor as the "current location" for navigation purposes.
// 2. pathname alone — the regular case, no anchor in the URL.
//
// Without step 1, every anchor visit collapsed to the parent page
// and Next/Previous walked from there — so on /docs/storage-share#host
// the bottom bar offered the same #host as Next (no movement) and on
// /docs/storage-share/lxc-mount-points/ Next pointed back at #host
// because the entire flat list got indexed from position 0.
const effectivePath = normalizedPathname + hash
let currentPageIndex = -1
if (hash) {
currentPageIndex = allPages.findIndex(
(page) => stripTrailingSlash(page.href) === effectivePath,
)
}
if (currentPageIndex === -1) {
currentPageIndex = allPages.findIndex(
(page) => stripTrailingSlash(page.href) === normalizedPathname,
)
}
// Anchored URLs (`/docs/storage-share/#host`) share their pathname
// with the Overview page, so they collapse to that page's flat-list
// index — Previous walks back into the previous section as usual and
// Next advances to the first real subpage (`host-nfs`) instead of
// looping back to the same anchor.
const currentPageIndex = allPages.findIndex(
(page) => stripTrailingSlash(page.href) === normalizedPathname,
)
const prevPage = currentPageIndex > 0 ? allPages[currentPageIndex - 1] : null
const nextPage = currentPageIndex < allPages.length - 1 ? allPages[currentPageIndex + 1] : null