mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
Update 1.2.2.2 beta
This commit is contained in:
@@ -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
|
||||
// 60–100 instead of the backend's 0–100). 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 0–100) 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>
|
||||
|
||||
@@ -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
28
menu
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user