update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-24 23:40:22 +02:00
parent 87a29f324b
commit 202068124b
9 changed files with 148 additions and 112 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
aa78161adc4e3c39ab9c20bdf94ca646745894e3ad09ea188e30997233d92f87 ProxMenux-1.2.2.2-beta.AppImage 5dd5ce284a6d83250dec808b5d07fe14cd0b0ac71769a113f6f355e57c0a9882 ProxMenux-1.2.2.2-beta.AppImage
+20 -50
View File
@@ -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 { 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 { Download } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getDiskType } from "../lib/disk-type"
import useSWR from "swr" import useSWR from "swr"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { import {
@@ -2460,25 +2461,10 @@ return (
) )
.map((device, index) => { .map((device, index) => {
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => {
let diskType = "HDD" // Classifier lives in lib/disk-type.ts — same rules
// the Storage page uses, so a drive can't show up as
// Check if it's NVMe // HDD here while showing as SSD there.
if (diskName.startsWith("nvme")) { const diskType = getDiskType(diskName, rotationRate)
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"
}
const badgeStyles: Record<string, { className: string; label: string }> = { const badgeStyles: Record<string, { className: string; label: string }> = {
NVMe: { NVMe: {
className: "bg-purple-500/10 text-purple-500 border-purple-500/20", className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
@@ -2600,38 +2586,22 @@ return (
<div className="flex justify-between border-b border-border/50 pb-2"> <div className="flex justify-between border-b border-border/50 pb-2">
<span className="text-sm font-medium text-muted-foreground">Type</span> <span className="text-sm font-medium text-muted-foreground">Type</span>
{(() => { {(() => {
const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate)
let diskType = "HDD" const badgeStyles: Record<string, { className: string; label: string }> = {
NVMe: {
if (diskName.startsWith("nvme")) { className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
diskType = "NVMe" label: "NVMe SSD",
} else if (rotationRate !== undefined && rotationRate !== null) { },
const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate SSD: {
if (rateNum === 0 || isNaN(rateNum)) { className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
diskType = "SSD" label: "SSD",
} },
} else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) { HDD: {
diskType = "SSD" className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
} label: "HDD",
},
const badgeStyles: Record<string, { className: string; label: string }> = {
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 diskBadge = badgeStyles[diskType]
const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate)
return <Badge className={diskBadge.className}>{diskBadge.label}</Badge> return <Badge className={diskBadge.className}>{diskBadge.label}</Badge>
})()} })()}
</div> </div>
+30 -21
View File
@@ -48,7 +48,7 @@ import {
import { ScriptTerminalModal } from "./script-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal"
import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchApi, getApiUrl } from "../lib/api-config"
import { fetchTerminalTicket } from "../lib/terminal-ws" 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_*) ── // ── 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) => { const formatRunAt = (iso: string | null) => {
if (!iso) return null if (!iso) return null
try { try {
@@ -509,6 +497,26 @@ export function HostBackup() {
<Lock className="h-3.5 w-3.5" /> <Lock className="h-3.5 w-3.5" />
</Badge> </Badge>
)} )}
{/* 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. */}
<Badge
variant="outline"
className={`text-[10px] uppercase tracking-wide ${
j.profile_mode === "custom"
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
: "border-border text-muted-foreground bg-background/40"
}`}
title={
j.profile_mode === "custom"
? "Custom path list — only the paths the operator picked"
: "Default path list — ProxMenux's recommended host config set"
}
>
{j.profile_mode === "custom" ? "custom" : "default"}
</Badge>
{!j.enabled && !j.manual && ( {!j.enabled && !j.manual && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5"> <Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
disabled disabled
@@ -656,7 +664,7 @@ export function HostBackup() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-[11px] text-muted-foreground mb-3"> <p className="text-[11px] text-muted-foreground mb-3">
All backups visible from this host local <code className="font-mono">.tar.zst</code> 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 <code className="font-mono">.tar.zst</code> 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.
</p> </p>
{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && ( {remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
<div className="text-[11px] text-amber-500 mb-3 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/5 space-y-1"> <div className="text-[11px] text-amber-500 mb-3 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/5 space-y-1">
@@ -677,7 +685,7 @@ export function HostBackup() {
</div> </div>
) : unifiedArchives.length === 0 ? ( ) : unifiedArchives.length === 0 ? (
<div className="text-sm text-muted-foreground py-4"> <div className="text-sm text-muted-foreground py-4">
No backups found yet. Use <span className="font-medium">Run manual backup</span> above, configure a scheduled job, or check that the configured PBS / Borg destinations have snapshots. No backups found yet. Use <span className="font-medium">Run manual backup</span> above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups.
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -7205,14 +7213,15 @@ function RollbackPlanView({ plan }: { plan: RollbackPlan }) {
{children} {children}
</code> </code>
) )
// Row uses flex-wrap + min-w-0 + gap so labels and pills flow to // Each pill is a direct flex child of the row (no inner wrapper)
// a new line on narrow screens instead of pushing the card wider. // so flex-wrap can break a long list of IDs mid-row on mobile.
// The previous nested `<div className="flex flex-wrap">` 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)[] }) => ( const Row = ({ label, items }: { label: string; items: (string | number)[] }) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 min-w-0"> <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1.5">
<span className="text-muted-foreground shrink-0">{label}</span> <span className="text-muted-foreground shrink-0">{label}</span>
<div className="flex flex-wrap gap-1.5 min-w-0"> {items.map((id) => <Pill key={id}>{id}</Pill>)}
{items.map((id) => <Pill key={id}>{id}</Pill>)}
</div>
</div> </div>
) )
+11 -26
View File
@@ -8,8 +8,10 @@ import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { formatStorage as sharedFormatStorage } from "../lib/utils"
import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal" import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal"
import { DiskTemperatureCard } from "./disk-temperature-card" import { DiskTemperatureCard } from "./disk-temperature-card"
import { getDiskType as resolveDiskType } from "../lib/disk-type"
import { import {
useDiskTempThresholds, useDiskTempThresholds,
loadDiskTempThresholds, loadDiskTempThresholds,
@@ -146,17 +148,9 @@ interface RemoteMountsData {
error?: string error?: string
} }
const formatStorage = (sizeInGB: number): string => { // Re-exported under the local name so the rest of this large file
if (sizeInGB < 1) { // stays untouched. Single source of truth lives in lib/utils.ts.
// Less than 1 GB, show in MB const formatStorage = sharedFormatStorage
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`
}
}
// Translate the short ATA/SCSI error codes that appear inside `{ ... }` // Translate the short ATA/SCSI error codes that appear inside `{ ... }`
// in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line // in a raw kernel observation (e.g. `error: { IDNF }`) into a one-line
@@ -624,21 +618,12 @@ export function StorageOverview() {
return `${rpm.toLocaleString()} RPM` return `${rpm.toLocaleString()} RPM`
} }
const getDiskType = (diskName: string, rotationRate: number | undefined): string => { // Thin wrapper over the shared classifier so the rest of the file
if (diskName.startsWith("nvme")) { // doesn't need to be touched. The actual rules live in
return "NVMe" // lib/disk-type.ts (single source of truth across Storage page,
} // Hardware page, and any future consumer).
// rotation_rate = -1 means HDD but RPM is unknown (detected via kernel rotational flag) const getDiskType = (diskName: string, rotationRate: number | undefined): string =>
// rotation_rate = 0 or undefined means SSD resolveDiskType(diskName, rotationRate)
// rotation_rate > 0 means HDD with known RPM
if (rotationRate === -1) {
return "HDD"
}
if (!rotationRate || rotationRate === 0) {
return "SSD"
}
return "HDD"
}
const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => { const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => {
const diskType = getDiskType(diskName, rotationRate) const diskType = getDiskType(diskName, rotationRate)
+30
View File
@@ -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"
}
+17
View File
@@ -19,3 +19,20 @@ export function formatStorage(sizeInGB: number): string {
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB` 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`
}
+36 -8
View File
@@ -597,22 +597,50 @@ class JournalWatcher:
if self._running: if self._running:
time.sleep(5) # Wait before restart 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): def _run_journalctl(self):
"""Run journalctl -f and process output line by line.""" """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 The cursor file lets us pick up after a short watcher crash
# writes the file with the latest seen cursor and on next start without dropping events, but we drop it before invoking
# resumes from there. Falls back to -n 0 (start from now) only on journalctl if it's older than _CURSOR_STALE_AFTER_SEC.
# the very first run when the cursor file doesn't exist yet. 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' cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt'
try: try:
Path(cursor_file).parent.mkdir(parents=True, exist_ok=True) Path(cursor_file).parent.mkdir(parents=True, exist_ok=True)
except Exception: except Exception:
pass 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', cmd = ['journalctl', '-f', '-o', 'json', '--no-pager',
f'--cursor-file={cursor_file}'] f'--cursor-file={cursor_file}']
if not Path(cursor_file).exists(): if not cursor_path.exists():
cmd.extend(['-n', '0']) # First run: don't replay history cmd.extend(['-n', '0']) # First run (or stale): don't replay history
self._process = subprocess.Popen( self._process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
+3 -6
View File
@@ -138,7 +138,7 @@ journalctl -u proxmenux-monitor -n 50
## 🔧 Dependencies ## 🔧 Dependencies
The following dependencies are installed automatically during setup: The following Debian packages are installed automatically during setup:
| Package | Purpose | | Package | Purpose |
|---|---| |---|---|
@@ -146,12 +146,9 @@ The following dependencies are installed automatically during setup:
| `curl` | Downloads and connectivity checks | | `curl` | Downloads and connectivity checks |
| `jq` | JSON processing | | `jq` | JSON processing |
| `git` | Repository cloning and updates | | `git` | Repository cloning and updates |
| `python3` + `python3-venv` | Translation support *(Translation version only)* | | `python3` + `python3-pip` | ProxMenux Monitor (Flask web dashboard) |
| `googletrans` | Google Translate library *(Translation version only)* |
> **🛡️ Security Note / VirusTotal False Positive** UI translations ship as pre-built JSON files per language (English, Spanish, French, German, Italian, Portuguese). There is no runtime translation dependency.
>
> 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).
--- ---