+ {/* Status bar */}
+
+
+
+
+
+
+
Lynis {lynisInfo.version}
+
Security auditing tool installed
+
+
+
+ Installed
+
+
+
+ {/* Summary stats */}
+
+
+
Last Scan
+
+ {lynisInfo.last_scan ? lynisInfo.last_scan.replace("T", " ").substring(0, 16) : "Never"}
+
+
+
+
Hardening Index
+ {(() => {
+ const rawScore = lynisReport?.hardening_index ?? lynisInfo.hardening_index
+ const adjScore = lynisReport?.proxmox_adjusted_score
+ const displayScore = adjScore ?? rawScore
+ const scoreColorClass = displayScore === null || displayScore === undefined ? "text-muted-foreground" :
+ displayScore >= 70 ? "text-green-500" :
+ displayScore >= 50 ? "text-yellow-500" : "text-red-500"
+ return (
+
+
+ {displayScore !== null && displayScore !== undefined ? displayScore : "N/A"}
+
+ {adjScore != null && rawScore != null && adjScore !== rawScore && (
+
+ Lynis: {rawScore} | PVE: {adjScore}
+
+ )}
+
+ )
+ })()}
+
+
+
Warnings
+ {(() => {
+ if (!lynisReport) return
-
+ const total = lynisReport.warnings.length
+ const expected = lynisReport.proxmox_expected_warnings ?? 0
+ const real = total - expected
+ return (
+
+
0 ? "text-red-500" : total > 0 ? "text-yellow-500" : "text-green-500"}`}>
+ {real > 0 ? real : total}
+
+ {expected > 0 && (
+
+ +{expected} PVE expected
+
+ )}
+
+ )
+ })()}
+
+
+
Suggestions
+ {(() => {
+ if (!lynisReport) return
-
+ const total = lynisReport.suggestions.length
+ const expected = lynisReport.proxmox_expected_suggestions ?? 0
+ const real = total - expected
+ return (
+
+
0 ? "text-yellow-500" : "text-green-500"}`}>
+ {real > 0 ? real : total}
+
+ {expected > 0 && (
+
+ +{expected} PVE expected
+
+ )}
+
+ )
+ })()}
+
+
+
+ {/* Hardening bar */}
+ {(() => {
+ const rawScore = lynisReport?.hardening_index ?? lynisInfo.hardening_index
+ const adjScore = lynisReport?.proxmox_adjusted_score
+ if (rawScore === null || rawScore === undefined) return null
+ const displayScore = adjScore ?? rawScore
+ const hasAdjustment = adjScore != null && adjScore !== rawScore
+ return (
+
+
+
+ Security Hardening Score {hasAdjustment && (Proxmox Adjusted) }
+
+ = 70 ? "text-green-500" : displayScore >= 50 ? "text-yellow-500" : "text-red-500"
+ }`}>
+ {displayScore}/100
+
+
+ {hasAdjustment ? (
+
+ {/* Raw score bar (dimmed) */}
+
+ {/* Adjusted score bar */}
+
= 70 ? "bg-green-500" : displayScore >= 50 ? "bg-yellow-500" : "bg-red-500"
+ }`}
+ style={{ width: `${displayScore}%` }}
+ />
+
+ ) : (
+
+
= 70 ? "bg-green-500" : displayScore >= 50 ? "bg-yellow-500" : "bg-red-500"
+ }`}
+ style={{ width: `${displayScore}%` }}
+ />
+
+ )}
+
+ Critical (0-49)
+ Moderate (50-69)
+ Good (70-100)
+
+ {hasAdjustment && (
+
+ Lynis raw score: {rawScore}/100 | {(lynisReport?.proxmox_expected_warnings ?? 0) + (lynisReport?.proxmox_expected_suggestions ?? 0)} findings are expected in Proxmox VE
+
+ )}
+
+ )
+ })()}
+
+ {/* Running indicator */}
+ {lynisAuditRunning && (
+
+
+
+
+
Security audit in progress...
+
This may take 2-5 minutes. Lynis is scanning your system for vulnerabilities, misconfigurations, and hardening opportunities.
+
+
+
+ )}
+
+ {/* Reports list */}
+ {lynisReport && (
+
+
Audit Reports
+
+ {/* Report row - clickable to expand */}
+
+
setLynisShowReport(!lynisShowReport)}
+ className="w-full flex items-center justify-between p-3 bg-muted/20 hover:bg-muted/40 transition-colors text-left"
+ >
+
+
+
+
+ Security Audit - {lynisReport.datetime_start
+ ? lynisReport.datetime_start.replace("T", " ").substring(0, 16)
+ : lynisInfo.last_scan?.replace("T", " ").substring(0, 16) || "Unknown date"}
+
+
+ {lynisReport.hostname || "System"} - {lynisReport.tests_performed} tests - PVE Score: {lynisReport.proxmox_adjusted_score ?? lynisReport.hardening_index ?? "N/A"}/100 - {lynisReport.warnings.length - (lynisReport.proxmox_expected_warnings ?? 0)} warnings - {lynisReport.suggestions.length - (lynisReport.proxmox_expected_suggestions ?? 0)} suggestions
+
+
+
+
+
{
+ e.stopPropagation()
+ const html = generatePrintableReport(lynisReport)
+ // Use Blob URL for Safari-safe preview (avoids document.write issues)
+ const blob = new Blob([html], { type: "text/html" })
+ const url = URL.createObjectURL(blob)
+ const w = window.open(url, "_blank")
+ // Revoke after a delay so it loads first
+ if (w) setTimeout(() => URL.revokeObjectURL(url), 60000)
+ }}
+ className="h-7 gap-1.5 px-2.5 text-xs border-cyan-500/30 text-cyan-500 hover:text-cyan-400 hover:bg-cyan-500/10"
+ title="Print / Save as PDF"
+ >
+
+ PDF
+
+
+ {/* Delete button separated with divider to prevent accidental clicks */}
+
+
{
+ e.stopPropagation()
+ if (confirm("Delete this audit report? The report file will be removed from the server.")) {
+ fetchApi("/api/security/lynis/report", { method: "DELETE" })
+ .then(() => {
+ setLynisReport(null)
+ setLynisShowReport(false)
+ setSuccess("Report deleted")
+ loadSecurityTools()
+ })
+ .catch(() => setError("Failed to delete report"))
+ }
+ }}
+ className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
+ title="Delete report"
+ >
+
+
+
+
+
+ {/* Expanded report details */}
+ {lynisShowReport && (
+
+ {/* System info strip */}
+
+
+
Hostname
+
{lynisReport.hostname || "N/A"}
+
+
+
OS
+
{lynisReport.os_fullname || `${lynisReport.os_name} ${lynisReport.os_version}`.trim() || "N/A"}
+
+
+
Kernel
+
{lynisReport.kernel_version || "N/A"}
+
+
+
Tests
+
{lynisReport.tests_performed}
+
+
+
+ {/* Report tabs - responsive with shorter labels on mobile */}
+
+ {(["overview", "checks", "warnings", "suggestions"] as const).map((tab) => (
+ setLynisActiveTab(tab)}
+ className={`flex-1 min-w-0 px-2 sm:px-3 py-2 text-xs font-medium transition-all flex items-center justify-center gap-1 sm:gap-1.5 border-r last:border-r-0 border-border ${
+ lynisActiveTab === tab
+ ? "bg-cyan-500 text-white"
+ : "bg-muted/20 text-muted-foreground hover:text-foreground hover:bg-muted/40"
+ }`}
+ >
+ {tab === "overview" && }
+ {tab === "checks" && }
+ {tab === "warnings" && }
+ {tab === "suggestions" && }
+
+ {tab === "overview" ? "Overview"
+ : tab === "checks" ? `Checks (${lynisReport.sections?.length || 0})`
+ : tab === "warnings" ? `Warnings (${lynisReport.warnings.length})`
+ : `Suggestions (${lynisReport.suggestions.length})`}
+
+
+ {tab === "overview" ? ""
+ : tab === "checks" ? `(${lynisReport.sections?.length || 0})`
+ : tab === "warnings" ? `(${lynisReport.warnings.length})`
+ : `(${lynisReport.suggestions.length})`}
+
+
+ ))}
+
+
+ {/* Overview tab */}
+ {lynisActiveTab === "overview" && (
+
+
+
+
Packages
+
{lynisReport.installed_packages || "N/A"}
+
+
+
Firewall
+
+ {lynisReport.firewall_active ? "Active" : "Inactive"}
+
+
+
+
Malware Scanner
+
+ {lynisReport.malware_scanner ? "Installed" : "Not Found"}
+
+
+
+
+ {/* Security checklist */}
+
+
Quick Status
+ {(() => {
+ const adjScore = lynisReport.proxmox_adjusted_score ?? lynisReport.hardening_index ?? 0
+ const realWarnings = lynisReport.warnings.length - (lynisReport.proxmox_expected_warnings ?? 0)
+ return [
+ {
+ label: "Firewall",
+ ok: lynisReport.firewall_active,
+ passText: "Active",
+ failText: "Inactive",
+ },
+ {
+ label: "Malware Scanner",
+ ok: lynisReport.malware_scanner,
+ passText: "Installed",
+ failText: "Not Installed",
+ isWarning: true,
+ },
+ {
+ label: "Warnings",
+ ok: realWarnings <= 0,
+ passText: lynisReport.warnings.length === 0 ? "None" : `${lynisReport.warnings.length} (all PVE expected)`,
+ failText: `${realWarnings} actionable` + (lynisReport.proxmox_expected_warnings ? ` + ${lynisReport.proxmox_expected_warnings} PVE` : ""),
+ isWarning: realWarnings > 0 && realWarnings <= 5,
+ },
+ {
+ label: "Hardening Score (PVE)",
+ ok: adjScore >= 70,
+ passText: `${adjScore}/100`,
+ failText: `${adjScore}/100 (< 70)`,
+ isWarning: adjScore >= 50,
+ },
+ ].map((item) => {
+ const color = item.ok ? "green" : item.isWarning ? "yellow" : "red"
+ return (
+
+
+
{item.label}
+
+ {item.ok ? item.passText : item.failText}
+
+
+ )})
+ })()}
+
+
+ )}
+
+ {/* Checks tab */}
+ {lynisActiveTab === "checks" && (
+
+ {(!lynisReport.sections || lynisReport.sections.length === 0) ? (
+
+ No check details available. Run an audit to generate detailed results.
+
+ ) : (
+
+ {lynisReport.sections.map((section, sIdx) => (
+
+
+ {sIdx + 1}
+ {section.name}
+ {section.checks.length} checks
+
+
+ {section.checks.map((check, cIdx) => {
+ const st = check.status.toUpperCase()
+ const isOk = ["OK", "FOUND", "DONE", "ENABLED", "ACTIVE", "YES", "HARDENED", "PROTECTED", "NONE", "NOT FOUND", "NOT RUNNING", "NOT ACTIVE", "NOT ENABLED", "DEFAULT", "NO"].includes(st)
+ const isWarn = ["WARNING", "UNSAFE", "WEAK", "DIFFERENT", "DISABLED"].includes(st)
+ const isSugg = ["SUGGESTION", "PARTIALLY HARDENED", "MEDIUM", "NON DEFAULT"].includes(st)
+ const dotColor = isWarn ? "bg-red-500" : isSugg ? "bg-yellow-500" : isOk ? "bg-green-500" : "bg-muted-foreground"
+ const textColor = isWarn ? "text-red-500" : isSugg ? "text-yellow-500" : isOk ? "text-green-500" : "text-muted-foreground"
+ return (
+
+
+
{check.name}
+ {check.detail &&
{check.detail} }
+
{check.status}
+
+ )
+ })}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Warnings tab */}
+ {lynisActiveTab === "warnings" && (
+
+ {lynisReport.warnings.length === 0 ? (
+
+ No warnings found. Your system is well configured.
+
+ ) : (
+
+ {lynisReport.warnings.map((w, idx) => (
+
+
+
+
+
+ {w.test_id}
+ {w.proxmox_expected && (
+ PVE Expected
+ )}
+ {!w.proxmox_expected && w.proxmox_severity === "low" && (
+ Low Risk
+ )}
+ {!w.proxmox_expected && !w.proxmox_severity && w.severity && (
+ {w.severity}
+ )}
+
+
{w.description}
+ {w.proxmox_context && (
+
+ Proxmox: {w.proxmox_context}
+
+ )}
+ {w.solution && (
+
+ Solution: {w.solution}
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Suggestions tab */}
+ {lynisActiveTab === "suggestions" && (
+
+ {lynisReport.suggestions.length === 0 ? (
+
+ No suggestions. System is fully hardened.
+
+ ) : (
+
+ {lynisReport.suggestions.map((s, idx) => (
+
+
+
+
+
+ {s.test_id}
+ {s.proxmox_expected && (
+ PVE Expected
+ )}
+ {!s.proxmox_expected && s.proxmox_severity === "low" && (
+ Low Priority
+ )}
+
+
{s.description}
+ {s.proxmox_context && (
+
+ Proxmox: {s.proxmox_context}
+
+ )}
+ {s.solution && (
+
+ Solution: {s.solution}
+
+ )}
+ {s.details && (
+
{s.details}
+ )}
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )}
+
+
+ )}
+
+ {/* Run audit button - at the bottom */}
+
+ {lynisAuditRunning ? (
+ <>
+
+ Running Audit...
+ >
+ ) : (
+ <>
+
+ Run Security Audit
+ >
+ )}
+
+
+ )}
+
+
+
+ {/* Script Terminal Modals */}
+
{
+ setShowFail2banInstaller(false)
+ loadSecurityTools()
+ }}
+ scriptPath="/usr/local/share/proxmenux/scripts/security/fail2ban_installer.sh"
+ scriptName="fail2ban_installer"
+ params={{ EXECUTION_MODE: "web" }}
+ title="Fail2Ban Installation"
+ description="Installing and configuring Fail2Ban for SSH and Proxmox protection..."
+ />
+ {
+ setShowLynisInstaller(false)
+ loadSecurityTools()
+ }}
+ scriptPath="/usr/local/share/proxmenux/scripts/security/lynis_installer.sh"
+ scriptName="lynis_installer"
+ params={{ EXECUTION_MODE: "web" }}
+ title="Lynis Installation"
+ description="Installing Lynis security auditing tool from GitHub..."
+ />
+
+ {/* Uninstall Confirmation Dialogs */}
+ {showFail2banUninstallConfirm && (
+
+
+
+
+
+
Uninstall Fail2Ban?
+
This action cannot be undone
+
+
+
+ This will completely remove Fail2Ban and all its configuration, including:
+
+
+ SSH protection jail
+ Proxmox web interface protection
+ ProxMenux Monitor protection
+ All custom jail configurations
+ Auth logger services
+
+
+
setShowFail2banUninstallConfirm(false)}
+ >
+ Cancel
+
+
+ {uninstallingFail2ban ? (
+ <>
+
+ Uninstalling...
+ >
+ ) : (
+ <>
+
+ Uninstall
+ >
+ )}
+
+
+
+
+ )}
+
+ {showLynisUninstallConfirm && (
+
+
+
+
+
+
Uninstall Lynis?
+
This action cannot be undone
+
+
+
+ This will completely remove Lynis and all audit data, including:
+
+
+ Lynis installation (/opt/lynis)
+ Wrapper script (/usr/local/bin/lynis)
+ All audit reports and logs
+
+
+
setShowLynisUninstallConfirm(false)}
+ >
+ Cancel
+
+
+ {uninstallingLynis ? (
+ <>
+
+ Uninstalling...
+ >
+ ) : (
+ <>
+
+ Uninstall
+ >
+ )}
+
+
+
+
+ )}
+
+ setShow2FASetup(false)}
+ onSuccess={() => {
+ setSuccess("2FA enabled successfully!")
+ checkAuthStatus()
+ }}
+ />
+
+ )
+}
diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx
index f5cc39b1..f5760fdc 100644
--- a/AppImage/components/settings.tsx
+++ b/AppImage/components/settings.tsx
@@ -1,86 +1,274 @@
"use client"
import { useState, useEffect } from "react"
-import { Button } from "./ui/button"
-import { Input } from "./ui/input"
-import { Label } from "./ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
-import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler } from 'lucide-react'
-import { APP_VERSION } from "./release-notes-modal"
-import { getApiUrl, fetchApi } from "../lib/api-config"
-import { TwoFactorSetup } from "./two-factor-setup"
+import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy } from "lucide-react"
+import { NotificationSettings } from "./notification-settings"
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
+ 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 [authEnabled, setAuthEnabled] = useState(false)
- const [totpEnabled, setTotpEnabled] = useState(false)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState("")
- const [success, setSuccess] = useState("")
-
- // Setup form state
- const [showSetupForm, setShowSetupForm] = useState(false)
- const [username, setUsername] = useState("")
- const [password, setPassword] = useState("")
- const [confirmPassword, setConfirmPassword] = useState("")
-
- // Change password form state
- const [showChangePassword, setShowChangePassword] = useState(false)
- const [currentPassword, setCurrentPassword] = useState("")
- const [newPassword, setNewPassword] = useState("")
- const [confirmNewPassword, setConfirmNewPassword] = useState("")
-
- const [show2FASetup, setShow2FASetup] = useState(false)
- const [show2FADisable, setShow2FADisable] = useState(false)
- const [disable2FAPassword, setDisable2FAPassword] = useState("")
-
const [proxmenuxTools, setProxmenuxTools] = useState([])
const [loadingTools, setLoadingTools] = useState(true)
- const [expandedVersions, setExpandedVersions] = useState>({
- [APP_VERSION]: true, // Current version expanded by default
- })
-
- // API Token state management
- const [showApiTokenSection, setShowApiTokenSection] = useState(false)
- const [apiToken, setApiToken] = useState("")
- const [apiTokenVisible, setApiTokenVisible] = useState(false)
- const [tokenPassword, setTokenPassword] = useState("")
- const [tokenTotpCode, setTokenTotpCode] = useState("")
- const [generatingToken, setGeneratingToken] = useState(false)
- const [tokenCopied, setTokenCopied] = useState(false)
-
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
+ // Code viewer modal state
+ const [codeModal, setCodeModal] = useState<{
+ open: boolean
+ loading: boolean
+ toolName: string
+ version: string
+ functionName: string
+ source: string
+ script: string
+ error: string
+ deprecated: boolean
+ }>({ open: false, loading: false, toolName: '', version: '', 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)
useEffect(() => {
- checkAuthStatus()
- loadProxmenuxTools()
- getUnitsSettings() // Load units settings on mount
+ loadProxmenuxTools()
+ getUnitsSettings()
+ loadHealthSettings()
+ loadRemoteStorages()
+ loadNetworkInterfaces()
}, [])
- const checkAuthStatus = async () => {
- try {
- const response = await fetch(getApiUrl("/api/auth/status"))
- const data = await response.json()
- setAuthEnabled(data.auth_enabled || false)
- setTotpEnabled(data.totp_enabled || false) // Get 2FA status
- } catch (err) {
- console.error("Failed to check auth status:", err)
- }
- }
-
const loadProxmenuxTools = async () => {
try {
- const response = await fetch(getApiUrl("/api/proxmenux/installed-tools"))
- const data = await response.json()
-
+ const data = await fetchApi("/api/proxmenux/installed-tools")
if (data.success) {
setProxmenuxTools(data.installed_tools || [])
}
@@ -91,277 +279,67 @@ export function Settings() {
}
}
- const handleEnableAuth = async () => {
- setError("")
- setSuccess("")
-
- if (!username || !password) {
- setError("Please fill in all fields")
- return
- }
-
- if (password !== confirmPassword) {
- setError("Passwords do not match")
- return
- }
-
- if (password.length < 6) {
- setError("Password must be at least 6 characters")
- return
- }
-
- setLoading(true)
-
+ const viewToolSource = async (tool: ProxMenuxTool) => {
+ setCodeModal({ open: true, loading: true, toolName: tool.name, version: tool.version || '1.0', functionName: '', source: '', script: '', error: '', deprecated: !!tool.deprecated })
try {
- const response = await fetch(getApiUrl("/api/auth/setup"), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- username,
- password,
- enable_auth: true,
- }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.error || "Failed to enable authentication")
+ 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' }))
}
-
- // Save token
- localStorage.setItem("proxmenux-auth-token", data.token)
- localStorage.setItem("proxmenux-auth-setup-complete", "true")
-
- setSuccess("Authentication enabled successfully!")
- setAuthEnabled(true)
- setShowSetupForm(false)
- setUsername("")
- setPassword("")
- setConfirmPassword("")
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to enable authentication")
- } finally {
- setLoading(false)
+ } catch {
+ setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
}
}
- const handleDisableAuth = async () => {
- if (
- !confirm(
- "Are you sure you want to disable authentication? This will remove password protection from your dashboard.",
- )
- ) {
- return
- }
-
- setLoading(true)
- setError("")
- setSuccess("")
+ 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 {
- const token = localStorage.getItem("proxmenux-auth-token")
- const response = await fetch(getApiUrl("/api/auth/disable"), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token}`,
- },
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.message || "Failed to disable authentication")
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text)
+ ok = true
}
-
- localStorage.removeItem("proxmenux-auth-token")
- localStorage.removeItem("proxmenux-auth-setup-complete")
-
- setSuccess("Authentication disabled successfully! Reloading...")
-
- setTimeout(() => {
- window.location.reload()
- }, 1000)
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to disable authentication. Please try again.")
- } finally {
- setLoading(false)
- }
- }
-
- const handleChangePassword = async () => {
- setError("")
- setSuccess("")
-
- if (!currentPassword || !newPassword) {
- setError("Please fill in all fields")
- return
+ } catch {
+ // fall through
}
- if (newPassword !== confirmNewPassword) {
- setError("New passwords do not match")
- return
- }
-
- if (newPassword.length < 6) {
- setError("Password must be at least 6 characters")
- return
- }
-
- setLoading(true)
-
- try {
- const response = await fetch(getApiUrl("/api/auth/change-password"), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${localStorage.getItem("proxmenux-auth-token")}`,
- },
- body: JSON.stringify({
- current_password: currentPassword,
- new_password: newPassword,
- }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.error || "Failed to change password")
+ 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
}
-
- // Update token if provided
- if (data.token) {
- localStorage.setItem("proxmenux-auth-token", data.token)
- }
-
- setSuccess("Password changed successfully!")
- setShowChangePassword(false)
- setCurrentPassword("")
- setNewPassword("")
- setConfirmNewPassword("")
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to change password")
- } finally {
- setLoading(false)
- }
- }
-
- const handleDisable2FA = async () => {
- setError("")
- setSuccess("")
-
- if (!disable2FAPassword) {
- setError("Please enter your password")
- return
}
- setLoading(true)
-
- try {
- const token = localStorage.getItem("proxmenux-auth-token")
- const response = await fetch(getApiUrl("/api/auth/totp/disable"), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token}`,
- },
- body: JSON.stringify({ password: disable2FAPassword }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.message || "Failed to disable 2FA")
- }
-
- setSuccess("2FA disabled successfully!")
- setTotpEnabled(false)
- setShow2FADisable(false)
- setDisable2FAPassword("")
- checkAuthStatus()
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to disable 2FA")
- } finally {
- setLoading(false)
+ if (ok) {
+ setCodeCopied(true)
+ setTimeout(() => setCodeCopied(false), 2000)
}
}
- const handleLogout = () => {
- localStorage.removeItem("proxmenux-auth-token")
- localStorage.removeItem("proxmenux-auth-setup-complete")
- window.location.reload()
- }
-
- const handleGenerateApiToken = async () => {
- setError("")
- setSuccess("")
-
- if (!tokenPassword) {
- setError("Please enter your password")
- return
- }
-
- if (totpEnabled && !tokenTotpCode) {
- setError("Please enter your 2FA code")
- return
- }
-
- setGeneratingToken(true)
-
- try {
- const data = await fetchApi("/api/auth/generate-api-token", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- password: tokenPassword,
- totp_token: totpEnabled ? tokenTotpCode : undefined,
- }),
- })
-
- if (!data.success) {
- setError(data.message || data.error || "Failed to generate API token")
- return
- }
-
- if (!data.token) {
- setError("No token received from server")
- return
- }
-
- setApiToken(data.token)
- setSuccess("API token generated successfully! Make sure to copy it now as you won't be able to see it again.")
- setTokenPassword("")
- setTokenTotpCode("")
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to generate API token. Please try again.")
- } finally {
- setGeneratingToken(false)
- }
- }
-
- const copyApiToken = () => {
- navigator.clipboard.writeText(apiToken)
- setTokenCopied(true)
- setTimeout(() => setTokenCopied(false), 2000)
- }
-
- const toggleVersion = (version: string) => {
- setExpandedVersions((prev) => ({
- ...prev,
- [version]: !prev[version],
- }))
- }
-
const changeNetworkUnit = (unit: string) => {
const networkUnit = unit as "Bytes" | "Bits"
localStorage.setItem("proxmenux-network-unit", networkUnit)
setNetworkUnitSettings(networkUnit)
-
- // Dispatch custom event to notify other components
+
window.dispatchEvent(new CustomEvent("networkUnitChanged", { detail: networkUnit }))
-
- // Also dispatch storage event for backward compatibility
+
window.dispatchEvent(new StorageEvent("storage", {
key: "proxmenux-network-unit",
newValue: networkUnit,
@@ -375,307 +353,214 @@ export function Settings() {
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 security and preferences
+
Manage your dashboard preferences
- {/* Authentication Settings */}
-
-
-
-
- Authentication
-
- Protect your dashboard with username and password authentication
-
-
- {error && (
-
- )}
-
- {success && (
-
- )}
-
-
-
-
-
-
-
-
Authentication Status
-
- {authEnabled ? "Password protection is enabled" : "No password protection"}
-
-
-
-
- {authEnabled ? "Enabled" : "Disabled"}
-
-
-
- {!authEnabled && !showSetupForm && (
-
-
-
-
- Enable authentication to protect your dashboard when accessing from non-private networks.
-
-
-
setShowSetupForm(true)} className="w-full bg-blue-500 hover:bg-blue-600">
-
- Enable Authentication
-
-
- )}
-
- {!authEnabled && showSetupForm && (
-
-
Setup Authentication
-
-
-
Username
-
-
- setUsername(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
Password
-
-
- setPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
Confirm Password
-
-
- setConfirmPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
- {loading ? "Enabling..." : "Enable"}
-
- setShowSetupForm(false)} variant="outline" className="flex-1" disabled={loading}>
- Cancel
-
-
-
- )}
-
- {authEnabled && (
-
-
-
- Logout
-
-
- {!showChangePassword && (
-
setShowChangePassword(true)} variant="outline" className="w-full">
-
- Change Password
-
- )}
-
- {showChangePassword && (
-
-
Change Password
-
-
-
Current Password
-
-
- setCurrentPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
New Password
-
-
- setNewPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
Confirm New Password
-
-
- setConfirmNewPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
- {loading ? "Changing..." : "Change Password"}
-
- setShowChangePassword(false)}
- variant="outline"
- className="flex-1"
- disabled={loading}
- >
- Cancel
-
-
-
- )}
-
- {!totpEnabled && (
-
-
-
-
-
Two-Factor Authentication (2FA)
-
- Add an extra layer of security by requiring a code from your authenticator app in addition to
- your password.
-
-
-
-
-
setShow2FASetup(true)} variant="outline" className="w-full">
-
- Enable Two-Factor Authentication
-
-
- )}
-
- {totpEnabled && (
-
-
-
- {!show2FADisable && (
-
setShow2FADisable(true)} variant="outline" className="w-full">
-
- Disable 2FA
-
- )}
-
- {show2FADisable && (
-
-
Disable Two-Factor Authentication
-
Enter your password to confirm
-
-
-
Password
-
-
- setDisable2FAPassword(e.target.value)}
- className="pl-10"
- disabled={loading}
- />
-
-
-
-
-
- {loading ? "Disabling..." : "Disable 2FA"}
-
- {
- setShow2FADisable(false)
- setDisable2FAPassword("")
- setError("")
- }}
- variant="outline"
- className="flex-1"
- disabled={loading}
- >
- Cancel
-
-
-
- )}
-
- )}
-
-
- Disable Authentication
-
-
- )}
-
-
-
{/* Network Units Settings */}
@@ -707,198 +592,439 @@ export function Settings() {
- {/* API Access Tokens */}
- {authEnabled && (
-
-
+ {/* Health Monitor Settings */}
+
+
+
-
- API Access Tokens
+
+ Health Monitor
-
- Generate long-lived API tokens for external integrations like Homepage and Home Assistant
-
-
-
- {error && (
-
- )}
-
- {success && (
-
- )}
-
-
-
-
-
-
About API Tokens
-
- Tokens are valid for 1 year
- Use them to access APIs from external services
- Include in Authorization header: Bearer YOUR_TOKEN
- See README.md for complete integration examples
-
-
-
-
-
- {!showApiTokenSection && !apiToken && (
- setShowApiTokenSection(true)} className="w-full bg-purple-500 hover:bg-purple-600">
-
- Generate New API Token
-
- )}
-
- {showApiTokenSection && !apiToken && (
-
-
Generate API Token
-
- Enter your credentials to generate a new long-lived API token
-
-
-
-
Password
-
-
- setTokenPassword(e.target.value)}
- className="pl-10"
- disabled={generatingToken}
- />
-
-
-
- {totpEnabled && (
-
-
2FA Code
-
-
- setTokenTotpCode(e.target.value)}
- className="pl-10"
- maxLength={6}
- disabled={generatingToken}
- />
-
-
+ {!loadingHealth && (
+
+ {savedAllHealth && (
+
+
+ Saved
+
)}
-
-
-
+
+ Cancel
+
+
+ {savingAllHealth ? (
+
+ ) : (
+
+ )}
+ Save
+
+ >
+ ) : (
+ setHealthEditMode(true)}
>
- {generatingToken ? "Generating..." : "Generate Token"}
-
- {
- setShowApiTokenSection(false)
- setTokenPassword("")
- setTokenTotpCode("")
- setError("")
- }}
- variant="outline"
- className="flex-1"
- disabled={generatingToken}
- >
- Cancel
-
-
+
+ Edit
+
+ )}
)}
-
- {apiToken && (
-
-
-
-
Your API Token
-
-
-
-
-
-
- ⚠️ Important: Save this token now!
-
-
- You won't be able to see it again. Store it securely.
-
-
-
-
-
-
Token
-
-
-
- setApiTokenVisible(!apiTokenVisible)}
- className="h-7 w-7 p-0"
- >
- {apiTokenVisible ? : }
-
-
-
-
+
+
+ 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
+ handleCustomConfirm(cat.key)}
+ >
+ OK
+
+ {
+ setCustomValues(prev => {
+ const next = { ...prev }
+ delete next[cat.key]
+ return next
+ })
+ setPendingChanges(prev => {
+ const next = { ...prev }
+ delete next[cat.key]
+ return next
+ })
+ }}
+ >
+ X
+
+
+ ) : (
+
handleSuppressionChange(cat.key, v)}
+ disabled={!healthEditMode}
+ >
+
+
+
+
+ {SUPPRESSION_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* 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.
+
+
+ )}
-
- {tokenCopied && (
-
-
- Copied to clipboard!
-
- )}
-
-
-
-
How to use this token:
-
-
# Add to request headers:
-
Authorization: Bearer YOUR_TOKEN_HERE
-
-
- See the README documentation for complete integration examples with Homepage and Home Assistant.
-
-
-
- {
- setApiToken("")
- setShowApiTokenSection(false)
- }}
- variant="outline"
- className="w-full"
- >
- Done
-
+ )
+ })}
- )}
-
-
- )}
+
+ {/* 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 isOffline = storage.status === 'error' || storage.total === 0
+
+ return (
+
+
+
+
+
+ {storage.name}
+
+ {storage.type}
+
+
+ {isOffline && (
+
Offline or unavailable
+ )}
+
+
+
+
+ {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.
+
+
+
+ )}
+
+
+
+ {/* Notification Settings */}
+
{/* ProxMenux Optimizations */}
@@ -927,29 +1053,104 @@ export function Settings() {
{proxmenuxTools.length} active
- {proxmenuxTools.map((tool) => (
-
- ))}
+ {proxmenuxTools.map((tool) => {
+ const clickable = !!tool.has_source
+ const isDeprecated = !!tool.deprecated
+ return (
+
viewToolSource(tool) : undefined}
+ className={`flex items-center justify-between gap-2 p-3 bg-muted/50 rounded-lg border border-border transition-colors ${clickable ? 'hover:bg-muted hover:border-orange-500/40 cursor-pointer' : ''}`}
+ title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined}
+ >
+
+
+
{tool.name}
+ {isDeprecated && (
+
+ legacy
+
+ )}
+
+
v{tool.version || '1.0'}
+
+ )
+ })}
)}
-
setShow2FASetup(false)}
- onSuccess={() => {
- setSuccess("2FA enabled successfully!")
- checkAuthStatus()
- }}
- />
+ {/* 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} }
+ {codeModal.version && v{codeModal.version} }
+
+
+
+
+ {codeModal.source && (
+
+ {codeCopied ? : }
+ {codeCopied ? 'Copied' : 'Copy'}
+
+ )}
+ setCodeModal(prev => ({ ...prev, open: false }))}
+ className="p-1.5 rounded-md hover:bg-muted transition-colors"
+ >
+
+
+
+
+ {/* Body */}
+
+ {codeModal.loading ? (
+
+ ) : codeModal.error ? (
+
+ ) : (
+
${highlightBash(codeModal.source)}` }}
+ />
+ )}
+
+
+
+ )}
)
}
diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx
index 2567173b..f3cee017 100644
--- a/AppImage/components/storage-overview.tsx
+++ b/AppImage/components/storage-overview.tsx
@@ -2,10 +2,11 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive } from "lucide-react"
+import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
import { fetchApi } from "../lib/api-config"
interface DiskInfo {
@@ -34,6 +35,33 @@ interface DiskInfo {
wear_leveling_count?: number // SSD: Wear Leveling Count
total_lbas_written?: number // SSD/NVMe: Total LBAs Written (GB)
ssd_life_left?: number // SSD: SSD Life Left percentage
+ io_errors?: {
+ count: number
+ severity: string
+ sample: string
+ reason: string
+ error_type?: string // 'io' | 'filesystem'
+ }
+ observations_count?: number
+ connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
+ removable?: boolean
+ is_system_disk?: boolean
+ system_usage?: string[]
+}
+
+interface DiskObservation {
+ id: number
+ error_type: string
+ error_signature: string
+ first_occurrence: string
+ last_occurrence: string
+ occurrence_count: number
+ raw_message: string
+ severity: string
+ dismissed: boolean
+ device_name: string
+ serial: string
+ model: string
}
interface ZFSPool {
@@ -91,6 +119,17 @@ export function StorageOverview() {
const [loading, setLoading] = useState(true)
const [selectedDisk, setSelectedDisk] = useState(null)
const [detailsOpen, setDetailsOpen] = useState(false)
+ const [diskObservations, setDiskObservations] = useState([])
+ const [loadingObservations, setLoadingObservations] = useState(false)
+ const [activeModalTab, setActiveModalTab] = useState<"overview" | "smart" | "history" | "schedule">("overview")
+ const [smartJsonData, setSmartJsonData] = useState<{
+ has_data: boolean
+ data?: Record
+ timestamp?: string
+ test_type?: string
+ history?: Array<{ filename: string; timestamp: string; test_type: string; date_readable: string }>
+ } | null>(null)
+ const [loadingSmartJson, setLoadingSmartJson] = useState(false)
const fetchStorageData = async () => {
try {
@@ -110,7 +149,7 @@ export function StorageOverview() {
useEffect(() => {
fetchStorageData()
- const interval = setInterval(fetchStorageData, 60000)
+ const interval = setInterval(fetchStorageData, 30000)
return () => clearInterval(interval)
}, [])
@@ -234,11 +273,68 @@ export function StorageOverview() {
return badgeStyles[diskType]
}
- const handleDiskClick = (disk: DiskInfo) => {
+ const handleDiskClick = async (disk: DiskInfo) => {
setSelectedDisk(disk)
setDetailsOpen(true)
+ setDiskObservations([])
+ setSmartJsonData(null)
+
+ // Fetch observations and SMART JSON data in parallel
+ setLoadingObservations(true)
+ setLoadingSmartJson(true)
+
+ // Fetch observations
+ const fetchObservations = async () => {
+ try {
+ const params = new URLSearchParams()
+ if (disk.name) params.set('device', disk.name)
+ if (disk.serial && disk.serial !== 'Unknown') params.set('serial', disk.serial)
+ const data = await fetchApi<{ observations: DiskObservation[] }>(`/api/storage/observations?${params.toString()}`)
+ setDiskObservations(data.observations || [])
+ } catch {
+ setDiskObservations([])
+ } finally {
+ setLoadingObservations(false)
+ }
+ }
+
+ // Fetch SMART JSON data from real test if available
+ const fetchSmartJson = async () => {
+ try {
+ const data = await fetchApi<{
+ has_data: boolean
+ data?: Record
+ timestamp?: string
+ test_type?: string
+ }>(`/api/storage/smart/${disk.name}/latest`)
+ setSmartJsonData(data)
+ } catch {
+ setSmartJsonData({ has_data: false })
+ } finally {
+ setLoadingSmartJson(false)
+ }
+ }
+
+ // Run both in parallel
+ await Promise.all([fetchObservations(), fetchSmartJson()])
}
+ const formatObsDate = (iso: string) => {
+ if (!iso) return 'N/A'
+ try {
+ const d = new Date(iso)
+ const day = d.getDate().toString().padStart(2, '0')
+ const month = (d.getMonth() + 1).toString().padStart(2, '0')
+ const year = d.getFullYear()
+ const hours = d.getHours().toString().padStart(2, '0')
+ const mins = d.getMinutes().toString().padStart(2, '0')
+ return `${day}/${month}/${year} ${hours}:${mins}`
+ } catch { return iso }
+ }
+
+ const obsTypeLabel = (t: string) =>
+ ({ smart_error: 'SMART Error', io_error: 'I/O Error', filesystem_error: 'Filesystem Error', zfs_pool_error: 'ZFS Pool Error', connection_error: 'Connection Error' }[t] || t)
+
const getStorageTypeBadge = (type: string) => {
const typeColors: Record = {
pbs: "bg-purple-500/10 text-purple-500 border-purple-500/20",
@@ -368,21 +464,26 @@ export function StorageOverview() {
const getDiskTypesBreakdown = () => {
if (!storageData || !storageData.disks) {
- return { nvme: 0, ssd: 0, hdd: 0 }
+ return { nvme: 0, ssd: 0, hdd: 0, usb: 0 }
}
let nvme = 0
let ssd = 0
let hdd = 0
+ let usb = 0
storageData.disks.forEach((disk) => {
+ if (disk.connection_type === 'usb') {
+ usb++
+ return
+ }
const diskType = getDiskType(disk.name, disk.rotation_rate)
if (diskType === "NVMe") nvme++
else if (diskType === "SSD") ssd++
else if (diskType === "HDD") hdd++
})
- return { nvme, ssd, hdd }
+ return { nvme, ssd, hdd, usb }
}
const getWearProgressColor = (wearPercent: number): string => {
@@ -476,8 +577,13 @@ export function StorageOverview() {
if (loading) {
return (
-
-
Loading storage information...
+
+
+
Loading storage data...
+
Scanning disks, partitions and storage pools
)
}
@@ -565,6 +671,12 @@ export function StorageOverview() {
{diskTypesBreakdown.hdd} HDD
>
)}
+ {diskTypesBreakdown.usb > 0 && (
+ <>
+ {(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0 || diskTypesBreakdown.hdd > 0) && ", "}
+
{diskTypesBreakdown.usb} USB
+ >
+ )}
{diskHealthBreakdown.normal} normal
@@ -599,11 +711,20 @@ export function StorageOverview() {
{proxmoxStorage.storage
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
.sort((a, b) => a.name.localeCompare(b.name))
- .map((storage) => (
+ .map((storage) => {
+ // Check if storage is excluded from monitoring
+ const isExcluded = storage.excluded === true
+ const hasError = storage.status === "error" && !isExcluded
+
+ return (
@@ -612,27 +733,40 @@ export function StorageOverview() {
{storage.name}
{storage.type}
+ {isExcluded && (
+
+ excluded
+
+ )}
{storage.type}
{storage.name}
- {getStatusIcon(storage.status)}
+ {isExcluded ? (
+
+ excluded
+
+ ) : (
+ getStatusIcon(storage.status)
+ )}
{/* Desktop: Badge active + Porcentaje */}
- {storage.status}
+ {isExcluded ? "not monitored" : storage.status}
{storage.percent}%
@@ -675,7 +809,8 @@ export function StorageOverview() {
- ))}
+ )
+ })}