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
+53 -18
View File
@@ -250,6 +250,35 @@ function pathKey(path: string[]): string {
return path.join("/") 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 ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
export function HealthThresholds() { export function HealthThresholds() {
@@ -449,10 +478,14 @@ export function HealthThresholds() {
if (!leaf) return null if (!leaf) return null
const key = pathKey(path) const key = pathKey(path)
const val = Number(pending[key] ?? leaf.value) const val = Number(pending[key] ?? leaf.value)
const min = leaf.min
const max = leaf.max
const step = leaf.step || 1 const step = leaf.step || 1
const unit = leaf.unit || "" 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 pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
const custom = leaf.customised && !(key in pending) const custom = leaf.customised && !(key in pending)
const color = severity === "critical" ? "red" : "amber" const color = severity === "critical" ? "red" : "amber"
@@ -468,7 +501,7 @@ export function HealthThresholds() {
return ( return (
<div className="px-1 py-3"> <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 <span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`} className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
style={{ left: `${pct}%` }} style={{ left: `${pct}%` }}
@@ -476,7 +509,7 @@ export function HealthThresholds() {
{val}{unit} {val}{unit}
</span> </span>
</div> </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 inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
<div <div
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`} 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} disabled={!editMode}
value={val} value={val}
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))} 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}`} title={`Recommended: ${leaf.recommended}${unit}`}
/> />
</div> </div>
@@ -529,11 +562,16 @@ export function HealthThresholds() {
const wVal = Number(pending[wKey] ?? wLeaf.value) const wVal = Number(pending[wKey] ?? wLeaf.value)
const cVal = Number(pending[cKey] ?? cLeaf.value) const cVal = Number(pending[cKey] ?? cLeaf.value)
// Shared min/max from the backend leaf (both handles use the same // Backend validates warning <= critical on save.
// range; backend validates warning <= critical on save).
const min = Math.min(wLeaf.min, cLeaf.min)
const max = Math.max(wLeaf.max, cLeaf.max)
const step = Math.max(wLeaf.step, cLeaf.step) || 1 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 pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
const wPct = pct(wVal) const wPct = pct(wVal)
const cPct = pct(cVal) const cPct = pct(cVal)
@@ -558,7 +596,7 @@ export function HealthThresholds() {
they ride above the corresponding thumb regardless of the they ride above the corresponding thumb regardless of the
slider width. Pointer events disabled so they don't steal slider width. Pointer events disabled so they don't steal
clicks from the underlying range inputs. */} 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 <span
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`} className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
style={{ left: `${wPct}%` }} style={{ left: `${wPct}%` }}
@@ -573,12 +611,9 @@ export function HealthThresholds() {
</span> </span>
</div> </div>
{/* Track + handles. Two real <input type="range"> stacked on {/* Two range inputs stacked. Mobile track box is taller so the
top of each other, both pointer-events:none on the bar but enlarged thumbs (h-7) have room to sit without clipping. */}
pointer-events:auto on the thumbs (CSS in globals if you <div className="relative h-9 sm:h-6">
want sleeker thumbs; here we use the default which already
looks decent on every browser). */}
<div className="relative h-6">
{/* Background track: OK zone (muted) running the full width */} {/* 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" /> <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 */} {/* Warn-to-Crit gradient between the two handles */}
@@ -605,7 +640,7 @@ export function HealthThresholds() {
disabled={!editMode} disabled={!editMode}
value={wVal} value={wVal}
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)} 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})`} title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
/> />
<input <input
@@ -616,7 +651,7 @@ export function HealthThresholds() {
disabled={!editMode} disabled={!editMode}
value={cVal} value={cVal}
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)} 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})`} title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
/> />
</div> </div>
+134 -61
View File
@@ -2473,71 +2473,144 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
</div> </div>
{/* Storage Section */} {/* Storage Section — human-readable breakdown
<div> per disk plus the raw config string in a
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide"> collapsible details block, mirroring the
<HardDrive className="h-4 w-4" /> Network section. */}
Storage {(() => {
</h4> // Parse a Proxmox disk config string into
<div className="space-y-3"> // { storage, volume, path, options }
{vmDetails.config.rootfs && ( // Handles both LVM-style volumes
<div key="rootfs"> // "local-lvm:vm-101-disk-0,size=6G" and
<div className="text-xs text-muted-foreground mb-1">Root Filesystem</div> // passthrough paths "/dev/disk/by-id/...".
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded"> const parseDisk = (raw: string) => {
{vmDetails.config.rootfs} 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>
</div> <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 />}
{vmDetails.config.scsihw && ( {d.path && <DField label="Path" value={d.path} mono className="break-all" />}
<div key="scsihw"> {d.options.ssd === "1" && <DField label="Media" value="SSD" />}
<div className="text-xs text-muted-foreground mb-1">SCSI Controller</div> {d.options.discard && <DField label="Discard" value={d.options.discard} />}
<div className="font-medium text-foreground">{vmDetails.config.scsihw}</div> {d.options.iothread === "1" && <DField label="IOThread" value="on" />}
</div> {d.options.cache && <DField label="Cache" value={d.options.cache} />}
)} {d.options.aio && <DField label="AIO" value={d.options.aio} />}
{/* Disk Storage with proper keys */} {d.options.backup === "0" && <DField label="Backup" value="excluded" className="text-red-500" />}
{Object.keys(vmDetails.config) {d.options.backup === "1" && <DField label="Backup" value="included" />}
.filter((key) => key.match(/^(scsi|sata|ide|virtio)\d+$/)) {d.options.replicate === "0" && <DField label="Replicate" value="off" />}
.map((diskKey) => ( {d.options.efitype && <DField label="EFI type" value={d.options.efitype} />}
<div key={`disk-${selectedVM.vmid}-${diskKey}`}> {d.options.pre_enrolled_keys && <DField label="Pre-enrolled keys" value={d.options.pre_enrolled_keys} />}
<div className="text-xs text-muted-foreground mb-1"> {d.options.serial && <DField label="Serial" value={d.options.serial} mono />}
{diskKey.toUpperCase().replace(/(\d+)/, " $1")} {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>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded"> </details>
{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>
</div> </div>
)} )
{vmDetails.config.tpmstate0 && ( }
<div key="tpmstate0"> return (
<div className="text-xs text-muted-foreground mb-1">TPM State</div> <div>
<div className="font-medium text-foreground text-sm break-all font-mono bg-muted/50 p-2 rounded"> <h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
{vmDetails.config.tpmstate0} <HardDrive className="h-4 w-4" />
</div> 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> </div>
)} </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>
{/* Network Section */} {/* Network Section */}
<div> <div>
+28
View File
@@ -203,6 +203,34 @@ main_menu() {
exec bash "$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 load_language
initialize_cache initialize_cache
auto_repair_monitor_unit auto_repair_monitor_unit
+7 -1
View File
@@ -39,7 +39,13 @@ initialize_cache
# ========================================================== # ==========================================================
security_menu() { security_menu() {
while true; do 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:')" menu_text+="\n$(translate 'Select an option:')"
local OPTION local OPTION