Update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-07-01 20:13:59 +02:00
parent d522b9a337
commit 6b173c42b6
4 changed files with 222 additions and 80 deletions

View File

@@ -250,6 +250,35 @@ function pathKey(path: string[]): string {
return path.join("/")
}
// Trim the visible slider range to a window around the saved +
// recommended values so the track has usable resolution (e.g. CPU
// 60100 instead of the backend's 0100). Derived from stable inputs
// so the range does NOT shift under an active drag.
function computeVisualRange(
values: number[],
backendMin: number,
backendMax: number,
step: number,
): { min: number; max: number } {
const totalRange = Math.max(1, backendMax - backendMin)
// Margin ≈ 25% of total range, clamped to at least 5 steps so tiny
// step sizes (e.g. step=1 on 0100) still get a usable window.
const rawMargin = Math.max(step * 5, Math.round(totalRange * 0.25))
const lo = Math.min(...values)
const hi = Math.max(...values)
const snap = (n: number) => Math.round(n / step) * step
let visMin = Math.max(backendMin, snap(lo - rawMargin))
let visMax = Math.min(backendMax, snap(hi + rawMargin))
// Ensure the window is at least 4 steps wide so the slider has
// room to move even if all inputs collapse to one value.
if (visMax - visMin < step * 4) {
const mid = (visMax + visMin) / 2
visMin = Math.max(backendMin, snap(mid - step * 2))
visMax = Math.min(backendMax, snap(mid + step * 2))
}
return { min: visMin, max: visMax }
}
// ─── Component ───────────────────────────────────────────────────────────────
export function HealthThresholds() {
@@ -449,10 +478,14 @@ export function HealthThresholds() {
if (!leaf) return null
const key = pathKey(path)
const val = Number(pending[key] ?? leaf.value)
const min = leaf.min
const max = leaf.max
const step = leaf.step || 1
const unit = leaf.unit || ""
const { min, max } = computeVisualRange(
[leaf.value, leaf.recommended],
leaf.min,
leaf.max,
step,
)
const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
const custom = leaf.customised && !(key in pending)
const color = severity === "critical" ? "red" : "amber"
@@ -468,7 +501,7 @@ export function HealthThresholds() {
return (
<div className="px-1 py-3">
<div className="relative h-5 mb-1 select-none">
<div className="relative h-6 sm:h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
style={{ left: `${pct}%` }}
@@ -476,7 +509,7 @@ export function HealthThresholds() {
{val}{unit}
</span>
</div>
<div className="relative h-6">
<div className="relative h-9 sm:h-6">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
<div
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`}
@@ -490,7 +523,7 @@ export function HealthThresholds() {
disabled={!editMode}
value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
title={`Recommended: ${leaf.recommended}${unit}`}
/>
</div>
@@ -529,11 +562,16 @@ export function HealthThresholds() {
const wVal = Number(pending[wKey] ?? wLeaf.value)
const cVal = Number(pending[cKey] ?? cLeaf.value)
// Shared min/max from the backend leaf (both handles use the same
// range; backend validates warning <= critical on save).
const min = Math.min(wLeaf.min, cLeaf.min)
const max = Math.max(wLeaf.max, cLeaf.max)
// Backend validates warning <= critical on save.
const step = Math.max(wLeaf.step, cLeaf.step) || 1
const backendMin = Math.min(wLeaf.min, cLeaf.min)
const backendMax = Math.max(wLeaf.max, cLeaf.max)
const { min, max } = computeVisualRange(
[wLeaf.value, cLeaf.value, wLeaf.recommended, cLeaf.recommended],
backendMin,
backendMax,
step,
)
const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
const wPct = pct(wVal)
const cPct = pct(cVal)
@@ -558,7 +596,7 @@ export function HealthThresholds() {
they ride above the corresponding thumb regardless of the
slider width. Pointer events disabled so they don't steal
clicks from the underlying range inputs. */}
<div className="relative h-5 mb-1 select-none">
<div className="relative h-6 sm:h-5 mb-1 select-none">
<span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
style={{ left: `${wPct}%` }}
@@ -573,12 +611,9 @@ export function HealthThresholds() {
</span>
</div>
{/* Track + handles. Two real <input type="range"> stacked on
top of each other, both pointer-events:none on the bar but
pointer-events:auto on the thumbs (CSS in globals if you
want sleeker thumbs; here we use the default which already
looks decent on every browser). */}
<div className="relative h-6">
{/* Two range inputs stacked. Mobile track box is taller so the
enlarged thumbs (h-7) have room to sit without clipping. */}
<div className="relative h-9 sm:h-6">
{/* Background track: OK zone (muted) running the full width */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
{/* Warn-to-Crit gradient between the two handles */}
@@ -605,7 +640,7 @@ export function HealthThresholds() {
disabled={!editMode}
value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
/>
<input
@@ -616,7 +651,7 @@ export function HealthThresholds() {
disabled={!editMode}
value={cVal}
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
/>
</div>

View File

@@ -2473,71 +2473,144 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
{/* Storage Section */}
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<HardDrive className="h-4 w-4" />
Storage
</h4>
<div className="space-y-3">
{vmDetails.config.rootfs && (
<div key="rootfs">
<div className="text-xs text-muted-foreground mb-1">Root Filesystem</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.rootfs}
{/* Storage Section — human-readable breakdown
per disk plus the raw config string in a
collapsible details block, mirroring the
Network section. */}
{(() => {
// Parse a Proxmox disk config string into
// { storage, volume, path, options }
// Handles both LVM-style volumes
// "local-lvm:vm-101-disk-0,size=6G" and
// passthrough paths "/dev/disk/by-id/...".
const parseDisk = (raw: string) => {
const parts = raw.split(",")
const first = parts[0] || ""
const options: Record<string, string> = {}
parts.slice(1).forEach((p) => {
const eq = p.indexOf("=")
if (eq > 0) {
options[p.slice(0, eq).trim()] = p.slice(eq + 1).trim()
}
})
let storage = "", volume = "", path = ""
if (first.startsWith("/")) {
path = first
} else if (first.includes(":")) {
const [s, v] = first.split(":")
storage = s
volume = v
} else {
volume = first
}
return { storage, volume, path, options }
}
// Convert Proxmox size strings ("6G",
// "3907018584K", "40G", "4M") to a
// consistent GB/TB display.
const humanSize = (s: string): string => {
if (!s) return ""
const m = s.match(/^(\d+(?:\.\d+)?)([KMGT])?$/i)
if (!m) return s
const n = parseFloat(m[1])
const unit = (m[2] || "").toUpperCase()
const bytes =
unit === "K" ? n * 1024 :
unit === "M" ? n * 1024 ** 2 :
unit === "G" ? n * 1024 ** 3 :
unit === "T" ? n * 1024 ** 4 : n
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(2)} TB`
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(bytes < 10 * 1024 ** 3 ? 1 : 0)} GB`
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(0)} MB`
return s
}
const DField = ({ label, value, mono, className }:
{ label: string; value: string; mono?: boolean; className?: string }) => (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className={`text-foreground ${mono ? "font-mono text-xs" : "text-sm"} ${className || ""}`}>{value}</span>
</div>
)
const renderDisk = (label: string, raw: string, keyId: string) => {
const d = parseDisk(raw)
return (
<div key={keyId} className="bg-muted/30 rounded-md p-3 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<HardDrive className="h-4 w-4 text-purple-500 flex-shrink-0" />
<span className="text-sm font-semibold text-foreground">{label}</span>
{d.storage && (
<span className="text-xs text-orange-500 font-mono">
{d.storage}
</span>
)}
{d.options.size && (
<span className="text-xs text-cyan-500 font-mono">
{humanSize(d.options.size)}
</span>
)}
</div>
</div>
)}
{vmDetails.config.scsihw && (
<div key="scsihw">
<div className="text-xs text-muted-foreground mb-1">SCSI Controller</div>
<div className="font-medium text-foreground">{vmDetails.config.scsihw}</div>
</div>
)}
{/* Disk Storage with proper keys */}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
.map((diskKey) => (
<div key={`disk-${selectedVM.vmid}-${diskKey}`}>
<div className="text-xs text-muted-foreground mb-1">
{diskKey.toUpperCase().replace(/(\d+)/, " $1")}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2">
{d.volume && <DField label="Volume" value={d.volume} mono />}
{d.path && <DField label="Path" value={d.path} mono className="break-all" />}
{d.options.ssd === "1" && <DField label="Media" value="SSD" />}
{d.options.discard && <DField label="Discard" value={d.options.discard} />}
{d.options.iothread === "1" && <DField label="IOThread" value="on" />}
{d.options.cache && <DField label="Cache" value={d.options.cache} />}
{d.options.aio && <DField label="AIO" value={d.options.aio} />}
{d.options.backup === "0" && <DField label="Backup" value="excluded" className="text-red-500" />}
{d.options.backup === "1" && <DField label="Backup" value="included" />}
{d.options.replicate === "0" && <DField label="Replicate" value="off" />}
{d.options.efitype && <DField label="EFI type" value={d.options.efitype} />}
{d.options.pre_enrolled_keys && <DField label="Pre-enrolled keys" value={d.options.pre_enrolled_keys} />}
{d.options.serial && <DField label="Serial" value={d.options.serial} mono />}
{d.options.mp && <DField label="Mount point" value={d.options.mp} mono />}
{d.options.acl && <DField label="ACL" value={d.options.acl} />}
</div>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Raw config</summary>
<div className="mt-1 font-mono text-foreground break-all bg-background/50 p-2 rounded">
{raw}
</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config[diskKey]}
</div>
</div>
))}
{vmDetails.config.efidisk0 && (
<div key="efidisk0">
<div className="text-xs text-muted-foreground mb-1">EFI Disk</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.efidisk0}
</div>
</details>
</div>
)}
{vmDetails.config.tpmstate0 && (
<div key="tpmstate0">
<div className="text-xs text-muted-foreground mb-1">TPM State</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config.tpmstate0}
</div>
)
}
return (
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
<HardDrive className="h-4 w-4" />
Storage
</h4>
<div className="space-y-3">
{vmDetails.config.scsihw && (
<div className="flex items-center gap-2 text-sm">
<span className="text-xs uppercase tracking-wide text-muted-foreground">SCSI controller:</span>
<span className="font-mono text-foreground">{vmDetails.config.scsihw}</span>
</div>
)}
{vmDetails.config.rootfs && renderDisk("Root Filesystem", vmDetails.config.rootfs as string, "rootfs")}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/))
.sort()
.map((diskKey) => renderDisk(
diskKey.toUpperCase().replace(/(\d+)/, " $1"),
vmDetails.config[diskKey] as string,
`disk-${selectedVM.vmid}-${diskKey}`,
))}
{vmDetails.config.efidisk0 && renderDisk("EFI Disk", vmDetails.config.efidisk0 as string, "efidisk0")}
{vmDetails.config.tpmstate0 && renderDisk("TPM State", vmDetails.config.tpmstate0 as string, "tpmstate0")}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^mp\d+$/))
.sort()
.map((mpKey) => renderDisk(
`Mount Point ${mpKey.replace("mp", "")}`,
vmDetails.config[mpKey] as string,
`mp-${selectedVM.vmid}-${mpKey}`,
))}
</div>
)}
{/* Mount Points with proper keys */}
{Object.keys(vmDetails.config)
.filter((key) => key.match(/^mp\d+$/))
.map((mpKey) => (
<div key={`mp-${selectedVM.vmid}-${mpKey}`}>
<div className="text-xs text-muted-foreground mb-1">
Mount Point {mpKey.replace("mp", "")}
</div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded">
{vmDetails.config[mpKey]}
</div>
</div>
))}
</div>
</div>
</div>
)
})()}
{/* Network Section */}
<div>

28
menu
View File

@@ -203,6 +203,34 @@ main_menu() {
exec bash "$MAIN_MENU"
}
# `menu -v` / `-h` print info and exit without opening the TUI so
# admins can query the install over SSH (issue #240).
case "${1:-}" in
-v|--version|-V)
local_ver="unknown"
[[ -f "$LOCAL_VERSION_FILE" ]] && local_ver="$(<"$LOCAL_VERSION_FILE")"
printf 'ProxMenux %s\n' "$local_ver"
if is_beta && [[ -f "$BETA_VERSION_FILE" ]]; then
printf 'Beta build: %s\n' "$(<"$BETA_VERSION_FILE")"
fi
exit 0
;;
-h|--help)
cat <<'EOF'
Usage: menu [OPTION]
Interactive menu for Proxmox VE management.
Options:
-v, --version Print installed ProxMenux version and exit.
-h, --help Show this help and exit.
Run without arguments to launch the interactive menu.
EOF
exit 0
;;
esac
load_language
initialize_cache
auto_repair_monitor_unit

View File

@@ -39,7 +39,13 @@ initialize_cache
# ==========================================================
security_menu() {
while true; do
local menu_text
# `local menu_text` alone does NOT reset the variable on the
# second iteration of the while loop — bash keeps the value from
# the previous round, so `menu_text+=` accumulated a duplicate
# "Select an option:" every time the user cancelled a sub-menu
# and came back. Assigning "" on declaration guarantees a clean
# slate each round.
local menu_text=""
menu_text+="\n$(translate 'Select an option:')"
local OPTION