"use client" import { useState, useEffect } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle } from "lucide-react" import { NotificationSettings } from "./notification-settings" import { HealthThresholds } from "./health-thresholds" import { ScriptTerminalModal } from "./script-terminal-modal" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Switch } from "./ui/switch" import { Input } from "./ui/input" import { Badge } from "./ui/badge" import { getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" // GitHub Dark color palette for bash syntax highlighting const BASH_KEYWORDS = new Set([ 'if','then','else','elif','fi','for','while','until','do','done','case','esac', 'function','return','local','readonly','export','declare','typeset','unset', 'source','alias','exit','break','continue','in','select','time','trap', ]) const BASH_BUILTINS = new Set([ 'echo','printf','read','cd','pwd','ls','cat','grep','sed','awk','cut','sort','uniq','tee','wc', 'head','tail','find','xargs','chmod','chown','chgrp','mkdir','rmdir','rm','cp','mv','ln','touch', 'ps','kill','killall','pkill','pgrep','top','htop','df','du','free','uptime','uname','hostname', 'systemctl','journalctl','service','apt','apt-get','dpkg','dnf','yum','zypper','pacman', 'curl','wget','ssh','scp','rsync','tar','gzip','gunzip','bzip2','zip','unzip', 'mount','umount','lsblk','blkid','fdisk','parted','mkfs','fsck','swapon','swapoff', 'ip','ifconfig','iptables','netstat','ss','ping','traceroute','dig','nslookup','nc', 'sudo','su','whoami','id','groups','passwd','useradd','userdel','usermod','groupadd', 'test','true','false','sleep','wait','eval','exec','command','type','which','hash', 'set','getopts','shift','let','expr','jq','sed','grep','awk','tr', 'modprobe','lsmod','rmmod','insmod','dmesg','sysctl','ulimit','nohup','disown','bg','fg', 'zpool','zfs','qm','pct','pvesh','pvesm','pvenode','pveam','pveversion','vzdump', 'smartctl','nvme','ipmitool','sensors','upsc','dkms','modinfo','lspci','lsusb','lscpu', ]) function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>') } function highlightBash(code: string): string { // Token-based highlighter — processes line by line to avoid cross-line state issues const lines = code.split('\n') const out: string[] = [] for (const line of lines) { let i = 0 let result = '' while (i < line.length) { const ch = line[i] // Comments (# to end of line, but not inside strings — simple heuristic) if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) { result += `${escapeHtml(line.slice(i))}` i = line.length continue } // Strings: double-quoted (may contain $variables) if (ch === '"') { let j = i + 1 let content = '' while (j < line.length && line[j] !== '"') { if (line[j] === '\\' && j + 1 < line.length) { content += line[j] + line[j + 1] j += 2 } else { content += line[j] j++ } } const str = '"' + content + (line[j] === '"' ? '"' : '') // Highlight $vars inside strings const strHtml = escapeHtml(str).replace( /(\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@#?*$!-])/g, '$1' ) result += `${strHtml}` i = j + 1 continue } // Strings: single-quoted (literal, no interpolation) if (ch === "'") { let j = i + 1 while (j < line.length && line[j] !== "'") j++ const str = line.slice(i, j + 1) result += `${escapeHtml(str)}` i = j + 1 continue } // Variables outside strings if (ch === '$') { const rest = line.slice(i) let m = rest.match(/^\$\{[^}]+\}/) if (!m) m = rest.match(/^\$[A-Za-z_][A-Za-z0-9_]*/) if (!m) m = rest.match(/^\$[0-9@#?*$!-]/) if (m) { result += `${escapeHtml(m[0])}` i += m[0].length continue } } // Numbers if (/[0-9]/.test(ch) && (i === 0 || /[\s=(\[,:;+\-*/]/.test(line[i - 1]))) { const rest = line.slice(i) const m = rest.match(/^[0-9]+/) if (m) { result += `${m[0]}` i += m[0].length continue } } // Identifiers — check if keyword, builtin, or function definition if (/[A-Za-z_]/.test(ch)) { const rest = line.slice(i) const m = rest.match(/^[A-Za-z_][A-Za-z0-9_-]*/) if (m) { const word = m[0] const after = line.slice(i + word.length) if (BASH_KEYWORDS.has(word)) { result += `${word}` } else if (/^\s*\(\)\s*\{?/.test(after)) { // function definition: name() { ... } result += `${word}` } else if (BASH_BUILTINS.has(word) && (i === 0 || /[\s|;&(]/.test(line[i - 1]))) { result += `${word}` } else { result += escapeHtml(word) } i += word.length continue } } // Operators and special chars if (/[|&;<>(){}[\]=!+*\/%~^]/.test(ch)) { result += `${escapeHtml(ch)}` i++ continue } // Default: escape and append result += escapeHtml(ch) i++ } out.push(result) } return out.join('\n') } interface SuppressionCategory { key: string label: string category: string icon: string hours: number } const SUPPRESSION_OPTIONS = [ { value: "24", label: "24 hours" }, { value: "72", label: "3 days" }, { value: "168", label: "1 week" }, { value: "720", label: "1 month" }, { value: "8760", label: "1 year" }, { value: "custom", label: "Custom" }, { value: "-1", label: "Permanent" }, ] const CATEGORY_ICONS: Record = { cpu: Cpu, memory: MemoryStick, storage: HardDrive, disk: CircleDot, network: Network, vms: Server, services: Settings2, logs: FileText, updates: RefreshCw, security: Shield, } interface ProxMenuxTool { key: string name: string enabled: boolean version?: string // Sprint 12B: post-install function update fields. The version above is // what the user has installed; available_version is what the on-disk // post-install script declares. has_update is set when the latter is // higher than the former. update_source_certain is false for legacy // tools that lack a recorded source — the UI must let the user pick // auto vs custom before re-running. `function` is the bash function // name the wrapper script should invoke for the chosen source. available_version?: string description?: string source?: string // "auto" | "custom" | "" function?: string function_auto?: string function_custom?: string has_update?: boolean update_source_certain?: boolean has_source?: boolean deprecated?: boolean } interface RemoteStorage { name: string type: string status: string total: number used: number available: number percent: number exclude_health: boolean exclude_notifications: boolean excluded_at?: string reason?: string } interface NetworkInterface { name: string type: string is_up: boolean speed: number ip_address: string | null exclude_health: boolean exclude_notifications: boolean excluded_at?: string reason?: string } export function Settings() { const [proxmenuxTools, setProxmenuxTools] = useState([]) const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0) const [loadingTools, setLoadingTools] = useState(true) // Sprint 12B: multi-select modal state. Tracks which tools the user // has marked for batch update + the open/closed state of the dialog. const [updateModalOpen, setUpdateModalOpen] = useState(false) const [selectedUpdates, setSelectedUpdates] = useState>(new Set()) // Sprint 12B: script terminal modal — running one or many post-install // function updates. `params` is what gets handed to flask_script_runner // (becomes env vars for update_post_install_function.sh). const [updateTerminal, setUpdateTerminal] = useState<{ open: boolean title: string description: string params: Record } | null>(null) const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes") const [loadingUnitSettings, setLoadingUnitSettings] = useState(true) // Code viewer modal state. `version` is the version the user has // installed (read from installed_tools.json); `availableVersion` is // what the on-disk script declares — they differ when an update is // pending. Sprint 12B v2 tweak: the header now shows both so the user // can see at a glance what they have and what they'd get. const [codeModal, setCodeModal] = useState<{ open: boolean loading: boolean toolName: string version: string availableVersion: string functionName: string source: string script: string error: string deprecated: boolean }>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false }) const [codeCopied, setCodeCopied] = useState(false) // Health Monitor suppression settings const [suppressionCategories, setSuppressionCategories] = useState([]) const [loadingHealth, setLoadingHealth] = useState(true) const [healthEditMode, setHealthEditMode] = useState(false) const [savingAllHealth, setSavingAllHealth] = useState(false) const [savedAllHealth, setSavedAllHealth] = useState(false) const [pendingChanges, setPendingChanges] = useState>({}) const [customValues, setCustomValues] = useState>({}) // Remote Storage Exclusions const [remoteStorages, setRemoteStorages] = useState([]) const [loadingStorages, setLoadingStorages] = useState(true) const [savingStorage, setSavingStorage] = useState(null) // Network Interface Exclusions const [networkInterfaces, setNetworkInterfaces] = useState([]) const [loadingInterfaces, setLoadingInterfaces] = useState(true) const [savingInterface, setSavingInterface] = useState(null) // Sprint 13 / issue #195: snippets storage selector. The bash helper // resolves it on first GPU passthrough and saves to config.json; this // card surfaces the same setting so the user can see/change it from // the Monitor without touching JSON or running bash interactively. const [snippetsStorage, setSnippetsStorage] = useState("") const [snippetsCandidates, setSnippetsCandidates] = useState>([]) const [snippetsSaving, setSnippetsSaving] = useState(false) const loadSnippetsStorage = async () => { try { const data = await fetchApi("/api/proxmenux/snippets-storage") if (data.success) { setSnippetsStorage(data.selected || "") setSnippetsCandidates(data.candidates || []) } } catch (err) { console.error("Failed to load snippets storage candidates:", err) } } const saveSnippetsStorage = async (storage: string) => { if (!storage || storage === snippetsStorage) return setSnippetsSaving(true) try { const data = await fetchApi("/api/proxmenux/snippets-storage", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ storage }), }) if (data.success) { setSnippetsStorage(storage) } } catch (err) { console.error("Failed to save snippets storage:", err) } finally { setSnippetsSaving(false) } } useEffect(() => { loadProxmenuxTools() getUnitsSettings() loadHealthSettings() loadRemoteStorages() loadNetworkInterfaces() loadSnippetsStorage() }, []) const loadProxmenuxTools = async () => { try { const data = await fetchApi("/api/proxmenux/installed-tools") if (data.success) { setProxmenuxTools(data.installed_tools || []) // Sprint 12B: backend computes the count, no need to derive it // from has_update on every render. setUpdatesAvailableCount(data.updates_available_count || 0) } } catch (err) { console.error("Failed to load ProxMenux tools:", err) } finally { setLoadingTools(false) } } // Sprint 12B: launch the script terminal for one or many post-install // function updates. `entries` is a list of (source, function, key) // triples joined into the FUNCTIONS_BATCH env var the wrapper script // understands. After the terminal closes we reload the tools list so // the freshly-applied versions are reflected in the cards. const runPostInstallUpdates = (entries: Array<{ source: string; function: string; key: string; name: string }>) => { if (entries.length === 0) return const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n") const title = entries.length === 1 ? `Update: ${entries[0].name}` : `Update ${entries.length} optimizations` const description = entries.length === 1 ? `Re-running ${entries[0].function} from the ${entries[0].source} flow.` : `Re-running ${entries.length} post-install functions in sequence.` setUpdateTerminal({ open: true, title, description, params: { EXECUTION_MODE: "web", FUNCTIONS_BATCH: batch, }, }) } const closeUpdateTerminal = async () => { setUpdateTerminal(null) // Sprint 12B v2: force the server-side rescan FIRST, then refetch // the tools list. The previous order (fetch + scan in parallel) // raced — the fetch returned the stale cache before the scan had a // chance to update it, so the badge and the purple cards stuck // around until the user hit refresh. Backend's _ensure_fresh_cache // also auto-rescans on file mtime change, but we keep the explicit // POST here as a belt-and-braces signal that an update just landed. try { await fetchApi("/api/updates/post-install/scan", { method: "POST" }) } catch { // Auto-refresh on the next read path will still pick up the // change via _ensure_fresh_cache — this catch is just to keep // the close flow non-blocking on transient errors. } loadProxmenuxTools() } // Sprint 12B v2: click on a tool's update icon → run the update // straight away. If the tool's source is recorded (modern entries) we // re-run that flow; otherwise (legacy bool entries from before Sprint // 12A) we default to `auto`. Per user feedback the previous "pick // auto/custom" picker was confusing — the system already knows the // available version, and updating doesn't need to ask which flavour // to install in. The user can always re-install via the // customizable post-install flow if they want different parameters. const handleSingleToolUpdate = (tool: ProxMenuxTool) => { if (!tool.has_update) return const source = tool.source || "auto" runPostInstallUpdates([{ source, function: deriveFunctionName(tool, source), key: tool.key, name: tool.name, }]) } // Backend exposes both function_auto and function_custom per tool so // that legacy bool entries (where the user picks the source at update // time) can route to the correct function in the chosen flow. // When the source is recorded, `function` is already correct. const deriveFunctionName = (tool: ProxMenuxTool, source: string): string => { if (source === "auto") return tool.function_auto || tool.function || "" if (source === "custom") return tool.function_custom || tool.function || "" return tool.function || "" } const viewToolSource = async (tool: ProxMenuxTool) => { setCodeModal({ open: true, loading: true, toolName: tool.name, version: tool.version || '1.0', availableVersion: tool.available_version || tool.version || '1.0', functionName: '', source: '', script: '', error: '', deprecated: !!tool.deprecated, }) try { const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`) if (data.success) { setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated })) } else { setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' })) } } catch { setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' })) } } const copySourceCode = async () => { const text = codeModal.source let ok = false // Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects, // so we catch and fall through to the textarea fallback. try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) ok = true } } catch { // fall through } if (!ok) { try { const ta = document.createElement("textarea") ta.value = text ta.style.position = "fixed" ta.style.left = "-9999px" ta.style.top = "-9999px" ta.style.opacity = "0" ta.readOnly = true document.body.appendChild(ta) ta.focus() ta.select() ok = document.execCommand("copy") document.body.removeChild(ta) } catch { ok = false } } if (ok) { setCodeCopied(true) setTimeout(() => setCodeCopied(false), 2000) } } const changeNetworkUnit = (unit: string) => { const networkUnit = unit as "Bytes" | "Bits" localStorage.setItem("proxmenux-network-unit", networkUnit) setNetworkUnitSettings(networkUnit) window.dispatchEvent(new CustomEvent("networkUnitChanged", { detail: networkUnit })) window.dispatchEvent(new StorageEvent("storage", { key: "proxmenux-network-unit", newValue: networkUnit, url: window.location.href })) } const getUnitsSettings = () => { const networkUnit = getNetworkUnit() setNetworkUnitSettings(networkUnit) setLoadingUnitSettings(false) } const loadHealthSettings = async () => { try { const data = await fetchApi("/api/health/settings") if (data.categories) { setSuppressionCategories(data.categories) } } catch (err) { console.error("Failed to load health settings:", err) } finally { setLoadingHealth(false) } } const loadRemoteStorages = async () => { try { const data = await fetchApi("/api/health/remote-storages") if (data.storages) { setRemoteStorages(data.storages) } } catch (err) { console.error("Failed to load remote storages:", err) } finally { setLoadingStorages(false) } } const handleStorageExclusionChange = async (storageName: string, storageType: string, excludeHealth: boolean, excludeNotifications: boolean) => { setSavingStorage(storageName) try { // If both are false, remove the exclusion if (!excludeHealth && !excludeNotifications) { await fetchApi(`/api/health/storage-exclusions/${encodeURIComponent(storageName)}`, { method: "DELETE" }) } else { await fetchApi("/api/health/storage-exclusions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ storage_name: storageName, storage_type: storageType, exclude_health: excludeHealth, exclude_notifications: excludeNotifications }) }) } // Update local state setRemoteStorages(prev => prev.map(s => s.name === storageName ? { ...s, exclude_health: excludeHealth, exclude_notifications: excludeNotifications } : s )) } catch (err) { console.error("Failed to update storage exclusion:", err) } finally { setSavingStorage(null) } } const loadNetworkInterfaces = async () => { try { const data = await fetchApi("/api/health/interfaces") if (data.interfaces) { setNetworkInterfaces(data.interfaces) } } catch (err) { console.error("Failed to load network interfaces:", err) } finally { setLoadingInterfaces(false) } } const handleInterfaceExclusionChange = async (interfaceName: string, interfaceType: string, excludeHealth: boolean, excludeNotifications: boolean) => { setSavingInterface(interfaceName) try { // If both are false, remove the exclusion if (!excludeHealth && !excludeNotifications) { await fetchApi(`/api/health/interface-exclusions/${encodeURIComponent(interfaceName)}`, { method: "DELETE" }) } else { await fetchApi("/api/health/interface-exclusions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ interface_name: interfaceName, interface_type: interfaceType, exclude_health: excludeHealth, exclude_notifications: excludeNotifications }) }) } // Reload interfaces to get updated state await loadNetworkInterfaces() } catch (err) { console.error("Failed to update interface exclusion:", err) } finally { setSavingInterface(null) } } const getSelectValue = (hours: number, key: string): string => { if (hours === -1) return "-1" const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours)) if (preset && preset.value !== "custom") return String(hours) return "custom" } const getEffectiveHours = (cat: SuppressionCategory): number => { if (cat.key in pendingChanges) return pendingChanges[cat.key] return cat.hours } const handleSuppressionChange = (settingKey: string, value: string) => { if (value === "custom") { const current = suppressionCategories.find(c => c.key === settingKey) const effectiveHours = current ? getEffectiveHours(current) : 48 setCustomValues(prev => ({ ...prev, [settingKey]: String(effectiveHours > 0 ? effectiveHours : 48) })) // Mark as custom mode in pending setPendingChanges(prev => ({ ...prev, [settingKey]: -2 })) return } const hours = parseInt(value, 10) if (isNaN(hours)) return setPendingChanges(prev => ({ ...prev, [settingKey]: hours })) // Clear custom input if switching away setCustomValues(prev => { const next = { ...prev } delete next[settingKey] return next }) } const handleCustomConfirm = (settingKey: string) => { const raw = customValues[settingKey] const hours = parseInt(raw, 10) if (isNaN(hours) || hours < 1) return setPendingChanges(prev => ({ ...prev, [settingKey]: hours })) setCustomValues(prev => { const next = { ...prev } delete next[settingKey] return next }) } const handleCancelEdit = () => { setHealthEditMode(false) setPendingChanges({}) setCustomValues({}) } const handleSaveAllHealth = async () => { // Merge pending changes into a payload: only changed categories const payload: Record = {} for (const cat of suppressionCategories) { if (cat.key in pendingChanges && pendingChanges[cat.key] !== -2) { payload[cat.key] = String(pendingChanges[cat.key]) } } if (Object.keys(payload).length === 0) { setHealthEditMode(false) setPendingChanges({}) return } setSavingAllHealth(true) try { await fetchApi("/api/health/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) // Update local state with saved values setSuppressionCategories(prev => prev.map(c => { if (c.key in pendingChanges && pendingChanges[c.key] !== -2) { return { ...c, hours: pendingChanges[c.key] } } return c }) ) setPendingChanges({}) setCustomValues({}) setHealthEditMode(false) setSavedAllHealth(true) setTimeout(() => setSavedAllHealth(false), 3000) } catch (err) { console.error("Failed to save health settings:", err) } finally { setSavingAllHealth(false) } } const hasPendingChanges = Object.keys(pendingChanges).some( k => pendingChanges[k] !== -2 ) return (

Settings

Manage your dashboard preferences

{/* Network Units Settings */}
Network Units
Change how network traffic is displayed
{loadingUnitSettings ? (
) : (
Network Unit Display
)} {/* Health Monitor Settings */}
Health Monitor
{!loadingHealth && (
{savedAllHealth && ( Saved )} {healthEditMode ? ( <> ) : ( )}
)}
Configure how long dismissed alerts stay suppressed for each category. Changes apply immediately to both existing and future dismissed alerts.
{loadingHealth ? (
) : (
{/* Header */}
Category Suppression Duration
{/* Per-category rows */}
{suppressionCategories.map((cat) => { const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse const effectiveHours = getEffectiveHours(cat) const isCustomMode = effectiveHours === -2 || (cat.key in customValues) const isPermanent = effectiveHours === -1 const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2 const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key) return (
{cat.label} {hasChanged && healthEditMode && ( )}
{isCustomMode && healthEditMode ? (
setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))} placeholder="Hours" /> h
) : ( )}
{/* Notice for Permanent */} {isPermanent && healthEditMode && (

Alerts for {cat.label} will be permanently suppressed when dismissed. {cat.category === "temperature" && ( Critical CPU temperature alerts will still trigger for hardware safety. )}

)} {/* Notice for long duration (> 1 month) */} {isLong && healthEditMode && (

Long suppression period. Dismissed alerts for this category will not reappear for an extended time.

)}
) })}
{/* Info footer */}

These settings apply when you dismiss a warning from the Health Monitor. Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.

)} {/* Remote Storage Exclusions */}
Remote Storage Exclusions
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications. Use this for storages that are intentionally offline or have limited API access.
{loadingStorages ? (
) : remoteStorages.length === 0 ? (

No remote storages detected

PBS, NFS, CIFS, and other remote storages will appear here when configured

) : (
{/* Header */}
Storage Health Alerts
{/* Storage rows - scrollable container */}
{remoteStorages.map((storage) => { const isExcluded = storage.exclude_health || storage.exclude_notifications const isSaving = savingStorage === storage.name const isNamespaceRestricted = storage.status === 'namespace_restricted' const isOffline = !isNamespaceRestricted && (storage.status === 'error' || storage.total === 0) return (
{storage.name} {storage.type}
{isOffline && (

Offline or unavailable

)} {isNamespaceRestricted && (

Reachable; datastore size hidden by ACL

)}
{isSaving ? ( ) : ( { handleStorageExclusionChange( storage.name, storage.type, !checked, storage.exclude_notifications ) }} className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" /> )}
{isSaving ? ( ) : ( { handleStorageExclusionChange( storage.name, storage.type, storage.exclude_health, !checked ) }} className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" /> )}
) })}
{/* Info footer */}

Health: When OFF, the storage won't trigger warnings/critical alerts in the Health Monitor.
Alerts: When OFF, no notifications will be sent for this storage.

)} {/* Network Interface Exclusions */}
Network Interface Exclusions
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications. Use this for interfaces that are intentionally disabled or unused.
{loadingInterfaces ? (
) : networkInterfaces.length === 0 ? (

No network interfaces detected

) : (
{/* Header */}
Interface Health Alerts
{/* Interface rows - scrollable container */}
{networkInterfaces.map((iface) => { const isExcluded = iface.exclude_health || iface.exclude_notifications const isSaving = savingInterface === iface.name const isDown = !iface.is_up return (
{iface.name} {iface.type} {isDown && !isExcluded && ( DOWN )} {isExcluded && ( Excluded )}
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
{/* Health toggle */}
{isSaving ? ( ) : ( { handleInterfaceExclusionChange( iface.name, iface.type, !checked, iface.exclude_notifications ) }} className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" /> )}
{/* Notifications toggle */}
{isSaving ? ( ) : ( { handleInterfaceExclusionChange( iface.name, iface.type, iface.exclude_health, !checked ) }} className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border" /> )}
) })}
{/* Info footer */}

Health: When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor.
Alerts: When OFF, no notifications will be sent for this interface.

)} {/* Health Monitor Thresholds — placed above Notifications because the values configured here drive what triggers the notifications below. */} {/* Notification Settings */} {/* Issue #195: snippets storage selector. Only renders when more than one storage advertises content=snippets — on a typical standalone host with just `local` there's nothing to choose, so showing an empty selector would be noise. */} {snippetsCandidates.length > 1 && (
Snippets storage
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs). Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes — local is node-specific and breaks migration.
{snippetsSaving && ( Saving… )}

Existing VMs/LXCs already configured with the previous storage keep working. Only new GPU passthrough operations (or running "sync hookscripts" on the host) will use the new selection.

)} {/* ProxMenux Optimizations */}
ProxMenux Optimizations
System optimizations and utilities installed via ProxMenux
{loadingTools ? (
) : proxmenuxTools.length === 0 ? (

No ProxMenux optimizations installed yet

Run ProxMenux to configure system optimizations

) : (
Installed Tools
{proxmenuxTools.length} active {/* Sprint 12B: count badge that doubles as the trigger for the multi-select update modal. Only shown when at least one tool has an available update. */} {updatesAvailableCount > 0 && ( )}
{proxmenuxTools.map((tool) => { const clickable = !!tool.has_source const isDeprecated = !!tool.deprecated // Sprint 12B: the card turns purple-tinted when an // update is available — replaces the normal muted // styling so the user sees at a glance which tools // need attention. Click on the body still opens the // source viewer; the small ArrowUpCircle on the right // is the dedicated update trigger. const hasUpdate = !!tool.has_update const baseClasses = hasUpdate ? 'border-purple-500/40 bg-purple-500/10 hover:bg-purple-500/20 hover:border-purple-500/60' : 'bg-muted/50 border-border hover:bg-muted hover:border-orange-500/40' return (
viewToolSource(tool) : undefined} className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`} title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined} >
{tool.name} {isDeprecated && ( legacy )}
{hasUpdate ? ( <> v{tool.version || '1.0'} → v{tool.available_version || '?'} ) : ( v{tool.version || '1.0'} )}
) })}
)} {/* Code Viewer Modal */} {codeModal.open && (
setCodeModal(prev => ({ ...prev, open: false }))}>
e.stopPropagation()} > {/* Header */}

{codeModal.toolName}

{codeModal.deprecated && ( legacy )}

{codeModal.functionName && {codeModal.functionName}()} {codeModal.script && — {codeModal.script}} {/* Sprint 12B v2: when an update is pending the user sees `v1.0 → v1.1` so the source viewer matches the badge in the card. When no update, just the single installed version. */} {codeModal.version && codeModal.availableVersion && codeModal.availableVersion !== codeModal.version ? ( v{codeModal.version} → v{codeModal.availableVersion} ) : codeModal.version ? ( v{codeModal.version} ) : null}

{codeModal.source && ( )}
{/* Body */}
{codeModal.loading ? (
) : codeModal.error ? (

{codeModal.error}

) : (
${highlightBash(codeModal.source)}` }}
                />
              )}
            
)} {/* Sprint 12B: multi-select Update modal — opened from the "X updates" badge in the Optimizations card header. The user ticks the tools they want to update, hits Update Selected, and the wrapper script runs them all in one terminal session. */} {updateModalOpen && (
setUpdateModalOpen(false)}>
e.stopPropagation()} >

Available updates

{updatesAvailableCount} {updatesAvailableCount === 1 ? 'optimization' : 'optimizations'} can be updated to a newer version.

{/* Sprint 12B v2: every row is selectable. Legacy bool entries (no recorded source) default to the auto flow on update — the previous "pick source first" path required an extra click for what is in practice always the same answer. */} {proxmenuxTools.filter(t => t.has_update).map(tool => { const isSelected = selectedUpdates.has(tool.key) return ( ) })}
{selectedUpdates.size} of {updatesAvailableCount} selected
)} {/* Sprint 12B: terminal that runs the update_post_install_function.sh wrapper. The wrapper sources the chosen flow script and invokes one or many functions in sequence (FUNCTIONS_BATCH). On close we refresh the tools list so the new versions show up. */} {updateTerminal?.open && ( )}
) }