diff --git a/AppImage/ProxMenux-1.2.2.2-beta.AppImage b/AppImage/ProxMenux-1.2.2.2-beta.AppImage index 970c1cc5..97726a5e 100755 Binary files a/AppImage/ProxMenux-1.2.2.2-beta.AppImage and b/AppImage/ProxMenux-1.2.2.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 639c4834..2a08d8b7 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -aa78161adc4e3c39ab9c20bdf94ca646745894e3ad09ea188e30997233d92f87 ProxMenux-1.2.2.2-beta.AppImage +5dd5ce284a6d83250dec808b5d07fe14cd0b0ac71769a113f6f355e57c0a9882 ProxMenux-1.2.2.2-beta.AppImage diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index d986d7c4..28487e73 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery, Usb, BrainCircuit, AlertCircle } from "lucide-react" import { Download } from "lucide-react" import { Button } from "@/components/ui/button" +import { getDiskType } from "../lib/disk-type" import useSWR from "swr" import { useState, useEffect } from "react" import { @@ -2460,25 +2461,10 @@ return ( ) .map((device, index) => { const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { - let diskType = "HDD" - - // Check if it's NVMe - if (diskName.startsWith("nvme")) { - diskType = "NVMe" - } - // Check rotation rate for SSD vs HDD - else if (rotationRate !== undefined && rotationRate !== null) { - // Handle both number and string formats - const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate - if (rateNum === 0 || isNaN(rateNum)) { - diskType = "SSD" - } - } - // If rotation_rate is "Solid State Device" string - else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) { - diskType = "SSD" - } - + // Classifier lives in lib/disk-type.ts — same rules + // the Storage page uses, so a drive can't show up as + // HDD here while showing as SSD there. + const diskType = getDiskType(diskName, rotationRate) const badgeStyles: Record = { NVMe: { className: "bg-purple-500/10 text-purple-500 border-purple-500/20", @@ -2600,38 +2586,22 @@ return (
Type {(() => { - const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { - let diskType = "HDD" - - if (diskName.startsWith("nvme")) { - diskType = "NVMe" - } else if (rotationRate !== undefined && rotationRate !== null) { - const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate - if (rateNum === 0 || isNaN(rateNum)) { - diskType = "SSD" - } - } else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) { - diskType = "SSD" - } - - const badgeStyles: Record = { - NVMe: { - className: "bg-purple-500/10 text-purple-500 border-purple-500/20", - label: "NVMe SSD", - }, - SSD: { - className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", - label: "SSD", - }, - HDD: { - className: "bg-blue-500/10 text-blue-500 border-blue-500/20", - label: "HDD", - }, - } - return badgeStyles[diskType] + const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate) + const badgeStyles: Record = { + NVMe: { + className: "bg-purple-500/10 text-purple-500 border-purple-500/20", + label: "NVMe SSD", + }, + SSD: { + className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", + label: "SSD", + }, + HDD: { + className: "bg-blue-500/10 text-blue-500 border-blue-500/20", + label: "HDD", + }, } - - const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate) + const diskBadge = badgeStyles[diskType] return {diskBadge.label} })()}
diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 71a4ec1b..eb07e51b 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -48,7 +48,7 @@ import { import { ScriptTerminalModal } from "./script-terminal-modal" import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchTerminalTicket } from "../lib/terminal-ws" -import { formatStorage } from "../lib/utils" +import { formatStorage, formatBytes } from "../lib/utils" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── @@ -316,18 +316,6 @@ const methodBadgeCls = (m: string | undefined): string => { } } -// formatStorage() in lib/utils.ts expects GIGABYTES as input — it's -// the storage layer's native unit. For raw log file sizes coming from -// os.path.getsize() we need a byte-aware formatter; passing N bytes to -// formatStorage() rendered "442 GB" for a 442-byte log. Tiny inline. -const formatBytes = (n: number): string => { - if (!Number.isFinite(n) || n < 0) return "—" - if (n < 1024) return `${n} B` - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` - if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` - return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB` -} - const formatRunAt = (iso: string | null) => { if (!iso) return null try { @@ -509,6 +497,26 @@ export function HostBackup() { )} + {/* Surface the profile mode at row level so the + operator can tell at a glance whether the + job ships the default path set or a custom + list — without having to open the detail + modal. Same wording the modal uses. */} + + {j.profile_mode === "custom" ? "custom" : "default"} + {!j.enabled && !j.manual && ( disabled @@ -656,7 +664,7 @@ export function HostBackup() {

- All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS snapshots from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS snapshots are extracted on-demand only when you request them. + All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them.

{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
@@ -677,7 +685,7 @@ export function HostBackup() {
) : unifiedArchives.length === 0 ? (
- No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have snapshots. + No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups.
) : (
@@ -7205,14 +7213,15 @@ function RollbackPlanView({ plan }: { plan: RollbackPlan }) { {children} ) - // Row uses flex-wrap + min-w-0 + gap so labels and pills flow to - // a new line on narrow screens instead of pushing the card wider. + // Each pill is a direct flex child of the row (no inner wrapper) + // so flex-wrap can break a long list of IDs mid-row on mobile. + // The previous nested `
` wouldn't + // wrap because its width was capped by the parent's flex layout, + // and the whole block would overflow the modal instead. const Row = ({ label, items }: { label: string; items: (string | number)[] }) => ( -
+
{label} -
- {items.map((id) => {id})} -
+ {items.map((id) => {id})}
) diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index c363b72f..71926545 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -8,8 +8,10 @@ import { Progress } from "@/components/ui/progress" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { fetchApi } from "../lib/api-config" +import { formatStorage as sharedFormatStorage } from "../lib/utils" import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal" import { DiskTemperatureCard } from "./disk-temperature-card" +import { getDiskType as resolveDiskType } from "../lib/disk-type" import { useDiskTempThresholds, loadDiskTempThresholds, @@ -146,17 +148,9 @@ interface RemoteMountsData { error?: string } -const formatStorage = (sizeInGB: number): string => { - if (sizeInGB < 1) { - // Less than 1 GB, show in MB - return `${(sizeInGB * 1024).toFixed(1)} MB` - } else if (sizeInGB > 999) { - return `${(sizeInGB / 1024).toFixed(2)} TB` - } else { - // Between 1 and 999 GB, show in GB - return `${sizeInGB.toFixed(2)} GB` - } -} +// Re-exported under the local name so the rest of this large file +// stays untouched. Single source of truth lives in lib/utils.ts. +const formatStorage = sharedFormatStorage // Translate the short ATA/SCSI error codes that appear inside `{ ... }` // in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line @@ -624,21 +618,12 @@ export function StorageOverview() { return `${rpm.toLocaleString()} RPM` } - const getDiskType = (diskName: string, rotationRate: number | undefined): string => { - if (diskName.startsWith("nvme")) { - return "NVMe" - } - // rotation_rate = -1 means HDD but RPM is unknown (detected via kernel rotational flag) - // rotation_rate = 0 or undefined means SSD - // rotation_rate > 0 means HDD with known RPM - if (rotationRate === -1) { - return "HDD" - } - if (!rotationRate || rotationRate === 0) { - return "SSD" - } - return "HDD" - } + // Thin wrapper over the shared classifier so the rest of the file + // doesn't need to be touched. The actual rules live in + // lib/disk-type.ts (single source of truth across Storage page, + // Hardware page, and any future consumer). + const getDiskType = (diskName: string, rotationRate: number | undefined): string => + resolveDiskType(diskName, rotationRate) const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => { const diskType = getDiskType(diskName, rotationRate) diff --git a/AppImage/lib/disk-type.ts b/AppImage/lib/disk-type.ts new file mode 100644 index 00000000..c6060110 --- /dev/null +++ b/AppImage/lib/disk-type.ts @@ -0,0 +1,30 @@ +// Shared classifier for physical-disk type. Lives here because the +// Storage page and the Hardware page used to ship their own copies +// and silently drifted — old SSDs (e.g. OCZ-SOLID2) that don't expose +// a SMART rotation rate fell through Hardware's HDD-as-default branch +// and got mislabelled, while the Storage page got it right. +// +// Backend convention for `rotation_rate`: +// undefined / null / 0 → SSD (no platters reported) +// -1 → HDD detected via /sys rotational flag, +// but the drive doesn't expose RPM +// > 0 → HDD with known RPM +// string "Solid State" → SSD (smartctl wording on a few vendors) + +export type DiskType = "NVMe" | "SSD" | "HDD" + +export function getDiskType( + diskName: string, + rotationRate: number | string | null | undefined, +): DiskType { + if (diskName.startsWith("nvme")) return "NVMe" + if (rotationRate === -1) return "HDD" + if (typeof rotationRate === "string") { + if (rotationRate.includes("Solid State")) return "SSD" + const parsed = Number.parseInt(rotationRate, 10) + if (Number.isNaN(parsed) || parsed === 0) return "SSD" + return "HDD" + } + if (rotationRate == null || rotationRate === 0) return "SSD" + return "HDD" +} diff --git a/AppImage/lib/utils.ts b/AppImage/lib/utils.ts index 4024e486..b764a643 100644 --- a/AppImage/lib/utils.ts +++ b/AppImage/lib/utils.ts @@ -19,3 +19,20 @@ export function formatStorage(sizeInGB: number): string { return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB` } } + +// Byte-aware formatter. Scales B → KB → MB → GB → TB. Use when the +// raw value comes in bytes (log file sizes from os.path.getsize(), +// PBS / Borg datastore capacity reported by the backend in bytes). +// The Backups page used to ship its own copy that capped at GB, so a +// 7 TB datastore showed up as "7311.55 GB" — extracted here so it +// can't drift again. +export function formatBytes(n: number): string { + if (!Number.isFinite(n) || n < 0) return "—" + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` + if (n < 1024 * 1024 * 1024 * 1024) { + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB` + } + return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB` +} diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index a92100a0..6dbbe1a2 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -597,22 +597,50 @@ class JournalWatcher: if self._running: time.sleep(5) # Wait before restart + # Discard a saved cursor older than this many seconds — resuming + # from a stale cursor replays every event journald has produced + # since the file was last touched, which surfaces as duplicate + # notifications. 15 min comfortably covers graceful shutdowns, + # cron-restart loops and brief outages while keeping a multi-day + # downtime from spamming the operator. Reported on RimegraVE + # (.1.10): the service was last stopped 11 days before a restart; + # on respawn the JournalWatcher fired 16 backup_start notifs in + # 40 min for vzdump runs that PVE had logged hours-to-days + # earlier. source=journal in notification_history proved the + # event chain came from this replay path. + _CURSOR_STALE_AFTER_SEC = 15 * 60 + def _run_journalctl(self): - """Run journalctl -f and process output line by line.""" - # Persist the cursor across watcher restarts so we don't lose events - # in the 5s gap between subprocess crash and respawn. journalctl - # writes the file with the latest seen cursor and on next start - # resumes from there. Falls back to -n 0 (start from now) only on - # the very first run when the cursor file doesn't exist yet. + """Run journalctl -f and process output line by line. + + The cursor file lets us pick up after a short watcher crash + without dropping events, but we drop it before invoking + journalctl if it's older than _CURSOR_STALE_AFTER_SEC. + That forces journalctl back to `-n 0` (start from now) + instead of replaying days of history as if it were live. + """ cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt' try: Path(cursor_file).parent.mkdir(parents=True, exist_ok=True) except Exception: pass + cursor_path = Path(cursor_file) + if cursor_path.exists(): + try: + age = time.time() - cursor_path.stat().st_mtime + if age > self._CURSOR_STALE_AFTER_SEC: + cursor_path.unlink(missing_ok=True) + print( + f"[JournalWatcher] Cursor stale " + f"({int(age)}s old) — restarting from now to " + f"avoid replaying historical events" + ) + except OSError: + pass cmd = ['journalctl', '-f', '-o', 'json', '--no-pager', f'--cursor-file={cursor_file}'] - if not Path(cursor_file).exists(): - cmd.extend(['-n', '0']) # First run: don't replay history + if not cursor_path.exists(): + cmd.extend(['-n', '0']) # First run (or stale): don't replay history self._process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/README.md b/README.md index 4d14c012..c3f1326d 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ journalctl -u proxmenux-monitor -n 50 ## 🔧 Dependencies -The following dependencies are installed automatically during setup: +The following Debian packages are installed automatically during setup: | Package | Purpose | |---|---| @@ -146,12 +146,9 @@ The following dependencies are installed automatically during setup: | `curl` | Downloads and connectivity checks | | `jq` | JSON processing | | `git` | Repository cloning and updates | -| `python3` + `python3-venv` | Translation support *(Translation version only)* | -| `googletrans` | Google Translate library *(Translation version only)* | +| `python3` + `python3-pip` | ProxMenux Monitor (Flask web dashboard) | -> **🛡️ Security Note / VirusTotal False Positive** -> -> If you scan the raw installation URL on VirusTotal, you might see a 1/95 detection by heuristic engines like *Chong Lua Dao*. This is a **known false positive**. Because this script uses the standard `curl | bash` installation pattern and downloads legitimate binaries (like `jq` from its official GitHub release), overly aggressive scanners flag the *behavior*. The script is 100% open source and safe to review. You can read more about this in [Issue #162](https://github.com/MacRimi/ProxMenux/issues/162). +UI translations ship as pre-built JSON files per language (English, Spanish, French, German, Italian, Portuguese). There is no runtime translation dependency. ---