diff --git a/AppImage/ProxMenux-1.2.0.AppImage b/AppImage/ProxMenux-1.2.0.AppImage new file mode 100755 index 00000000..acf6807b Binary files /dev/null and b/AppImage/ProxMenux-1.2.0.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 284958e2..e70f2a98 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -19c54fc98fe5492dded3367e70138d019efba6d6339f3f92f1333ccb2ab78ffb ProxMenux-1.0.2.AppImage +7d3df15c65411b4429e72305e9c7460d09b67c6d5bd2a50c05dd88a928fc4f94 ProxMenux-1.2.0.AppImage diff --git a/AppImage/README.md b/AppImage/README.md index f90c0a9d..28ba074b 100644 --- a/AppImage/README.md +++ b/AppImage/README.md @@ -730,6 +730,23 @@ entities: ![Home Assistant Integration Example](AppImage/public/images/docs/homeassistant-integration.png) +--- + +## License + +This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**. + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Adapt — remix, transform, and build upon the material + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made +- NonCommercial — You may not use the material for commercial purposes + +For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/). + + --- diff --git a/AppImage/app/globals.css b/AppImage/app/globals.css index 27a00f2c..50b26d5c 100644 --- a/AppImage/app/globals.css +++ b/AppImage/app/globals.css @@ -163,3 +163,15 @@ .xterm-rows { margin: 0 !important; } + +/* ===================== */ +/* Progress Animations */ +/* ===================== */ +@keyframes indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} diff --git a/AppImage/app/layout.tsx b/AppImage/app/layout.tsx index 0fcdcb1f..1f13d3e9 100644 --- a/AppImage/app/layout.tsx +++ b/AppImage/app/layout.tsx @@ -1,5 +1,5 @@ import type React from "react" -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import { GeistSans } from "geist/font/sans" import { GeistMono } from "geist/font/mono" import { ThemeProvider } from "../components/theme-provider" @@ -20,7 +20,13 @@ export const metadata: Metadata = { shortcut: "/favicon.ico", apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }], }, - viewport: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no", +} + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, themeColor: [ { media: "(prefers-color-scheme: light)", color: "#ffffff" }, { media: "(prefers-color-scheme: dark)", color: "#2b2f36" }, diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index c4f37365..826117f7 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -29,10 +29,19 @@ export default function Home() { const response = await fetch(getApiUrl("/api/auth/status"), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) + + // Check if response is valid JSON before parsing + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const contentType = response.headers.get("content-type") + if (!contentType || !contentType.includes("application/json")) { + throw new Error("Response is not JSON") + } + const data = await response.json() - console.log("[v0] Auth status:", data) - const authenticated = data.auth_enabled ? data.authenticated : true setAuthStatus({ @@ -41,8 +50,8 @@ export default function Home() { authConfigured: data.auth_configured, authenticated, }) - } catch (error) { - console.error("[v0] Failed to check auth status:", error) + } catch { + // API not available - assume no auth configured (silent fail, no console error) setAuthStatus({ loading: false, authEnabled: false, @@ -63,9 +72,13 @@ export default function Home() { if (authStatus.loading) { return (
-
-
-

Loading...

+
+
+
+
+
+
Loading...
+

Connecting to ProxMenux Monitor

) diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index 8cec24d2..ed336c8f 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -27,18 +27,26 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const checkOnboardingStatus = async () => { try { const response = await fetch(getApiUrl("/api/auth/status")) + + // Check if response is valid JSON before parsing + if (!response.ok) { + // API not available - don't show modal in preview + return + } + + const contentType = response.headers.get("content-type") + if (!contentType || !contentType.includes("application/json")) { + return + } + const data = await response.json() - console.log("[v0] Auth status for modal check:", data) - // Show modal if auth is not configured and not declined if (!data.auth_configured) { setTimeout(() => setOpen(true), 500) } - } catch (error) { - console.error("[v0] Failed to check auth status:", error) - // Fail-safe: show modal if we can't check status - setTimeout(() => setOpen(true), 500) + } catch { + // API not available (preview environment) - don't show modal } } diff --git a/AppImage/components/gpu-switch-mode-indicator.tsx b/AppImage/components/gpu-switch-mode-indicator.tsx new file mode 100644 index 00000000..d268a9c4 --- /dev/null +++ b/AppImage/components/gpu-switch-mode-indicator.tsx @@ -0,0 +1,255 @@ +"use client" + +import { cn } from "@/lib/utils" + +interface GpuSwitchModeIndicatorProps { + mode: "lxc" | "vm" | "unknown" + isEditing?: boolean + pendingMode?: "lxc" | "vm" | null + onToggle?: (e: React.MouseEvent) => void + className?: string +} + +export function GpuSwitchModeIndicator({ + mode, + isEditing = false, + pendingMode = null, + onToggle, + className, +}: GpuSwitchModeIndicatorProps) { + const displayMode = pendingMode ?? mode + const isLxcActive = displayMode === "lxc" + const isVmActive = displayMode === "vm" + const hasChanged = pendingMode !== null && pendingMode !== mode + + // Colors + const activeColor = isLxcActive ? "#3b82f6" : isVmActive ? "#a855f7" : "#6b7280" + const inactiveColor = "#374151" // gray-700 for dark theme + const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor + const vmColor = isVmActive ? "#a855f7" : inactiveColor + + const handleClick = (e: React.MouseEvent) => { + // Only stop propagation and handle toggle when in editing mode + if (isEditing) { + e.stopPropagation() + if (onToggle) { + onToggle(e) + } + } + // When not editing, let the click propagate to the card to open the modal + } + + return ( +
+ {/* Large SVG Diagram */} + + {/* GPU Chip - Large with "GPU" text */} + + {/* Main chip body */} + + {/* Chip pins - top */} + + + + {/* Chip pins - bottom */} + + + + {/* GPU text */} + + GPU + + + + {/* Connection line from GPU to switch */} + + + {/* Central Switch Node - Large circle with inner dot */} + + + + {/* LXC Branch Line - going up-right */} + + + {/* VM Branch Line - going down-right */} + + + {/* LXC Container Icon - Server/Stack icon */} + + {/* Container box */} + + {/* Container layers/lines */} + + + {/* Status dots */} + + + + + + {/* LXC Label */} + + LXC + + + {/* VM Monitor Icon */} + + {/* Monitor screen */} + + {/* Screen inner/shine */} + + {/* Monitor stand */} + + {/* Monitor base */} + + + + {/* VM Label */} + + VM + + + + {/* Status Text - Large like GPU name */} +
+ + {isLxcActive + ? "Ready for LXC containers" + : isVmActive + ? "Ready for VM passthrough" + : "Mode unknown"} + + + {isLxcActive + ? "Native driver active" + : isVmActive + ? "VFIO-PCI driver active" + : "No driver detected"} + + {hasChanged && ( + + Change pending... + + )} +
+
+ ) +} diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 1062028f..88c2e26a 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -4,20 +4,7 @@ import { Card } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { - Cpu, - HardDrive, - Thermometer, - Zap, - Loader2, - CpuIcon, - Cpu as Gpu, - Network, - MemoryStick, - PowerIcon, - FanIcon, - Battery, -} from "lucide-react" +import { Cpu, HardDrive, Thermometer, Zap, Loader2, CpuIcon, Cpu as Gpu, Network, MemoryStick, PowerIcon, FanIcon, Battery, Usb, BrainCircuit, AlertCircle } from "lucide-react" import { Download } from "lucide-react" import { Button } from "@/components/ui/button" import useSWR from "swr" @@ -27,10 +14,14 @@ import { type GPU, type PCIDevice, type StorageDevice, + type CoralTPU, + type UsbDevice, fetcher as swrFetcher, } from "../types/hardware" import { fetchApi } from "@/lib/api-config" import { ScriptTerminalModal } from "./script-terminal-modal" +import { GpuSwitchModeIndicator } from "./gpu-switch-mode-indicator" +import { Settings2, CheckCircle2 } from "lucide-react" const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -113,18 +104,64 @@ const formatClock = (clockString: string | number): string => { const getDeviceTypeColor = (type: string): string => { const lowerType = type.toLowerCase() + + // UPS / battery — amber: warm orange-yellow, distinct from the orange used + // for Storage and avoids the "warning" connotation of pure yellow. + if (lowerType === "ups" || lowerType.includes("battery")) { + return "bg-amber-500/10 text-amber-500 border-amber-500/20" + } + + // Storage family — orange (Mass Storage USB class + PCI storage controllers) if (lowerType.includes("storage") || lowerType.includes("sata") || lowerType.includes("raid")) { return "bg-orange-500/10 text-orange-500 border-orange-500/20" } + + // Printer — rose, unmistakable + if (lowerType.includes("printer")) { + return "bg-rose-500/10 text-rose-500 border-rose-500/20" + } + + // Audio family — teal (Audio, Audio/Video); placed before video so that + // combined "Audio/Video" class labels read as audio-family. + if (lowerType.includes("audio")) { + return "bg-teal-500/10 text-teal-500 border-teal-500/20" + } + + // Graphics / Video / Imaging — green (cameras, webcams, displays, GPUs). + if ( + lowerType.includes("graphics") || + lowerType.includes("vga") || + lowerType.includes("display") || + lowerType.includes("video") || + lowerType.includes("imaging") + ) { + return "bg-green-500/10 text-green-500 border-green-500/20" + } + + // Network family — blue (Ethernet / Wi-Fi PCI controllers, USB Communications, + // CDC Data, Wireless Controllers like Bluetooth dongles). + if ( + lowerType.includes("network") || + lowerType.includes("ethernet") || + lowerType.includes("communications") || + lowerType.includes("wireless") || + lowerType === "cdc data" + ) { + return "bg-blue-500/10 text-blue-500 border-blue-500/20" + } + + // HID — purple: keyboards, mice, game controllers. + if (lowerType === "hid") { + return "bg-purple-500/10 text-purple-500 border-purple-500/20" + } + + // USB host controllers (PCI-level) keep the existing purple identity. if (lowerType.includes("usb")) { return "bg-purple-500/10 text-purple-500 border-purple-500/20" } - if (lowerType.includes("network") || lowerType.includes("ethernet")) { - return "bg-blue-500/10 text-blue-500 border-blue-500/20" - } - if (lowerType.includes("graphics") || lowerType.includes("vga") || lowerType.includes("display")) { - return "bg-green-500/10 text-green-500 border-green-500/20" - } + + // Smart Card, Billboard, Diagnostic, Hub, Physical, Content Security, + // Personal Healthcare, Miscellaneous, Application/Vendor Specific, unknown. return "bg-gray-500/10 text-gray-500 border-gray-500/20" } @@ -173,43 +210,46 @@ const groupAndSortTemperatures = (temperatures: any[]) => { } export default function Hardware() { - // Static data - load once without refresh + // Static data - loaded once on mount. Static fields (CPU, motherboard, memory + // modules, PCI, disks, GPU list) don't change at runtime, so no auto-refresh. + // `mutateStatic` is triggered explicitly after GPU switch-mode changes. const { data: staticHardwareData, error: staticError, isLoading: staticLoading, + mutate: mutateStatic, } = useSWR("/api/hardware", swrFetcher, { revalidateOnFocus: false, revalidateOnReconnect: false, - refreshInterval: 0, // No auto-refresh for static data + refreshInterval: 0, }) - // Dynamic data - refresh every 5 seconds for temperatures, fans, power, ups + // Live data - only temperatures, fans, power, UPS. Polled every 5s. + // Backend /api/hardware/live uses cached ipmitool output (10s) so this is cheap. const { data: dynamicHardwareData, error: dynamicError, - isLoading: dynamicLoading, - } = useSWR("/api/hardware", swrFetcher, { - refreshInterval: 7000, + } = useSWR("/api/hardware/live", swrFetcher, { + refreshInterval: 5000, + revalidateOnFocus: true, + revalidateOnReconnect: true, + dedupingInterval: 2000, }) - // Merge static and dynamic data, preferring static for CPU/memory/PCI/disks + // Merge: static fields from initial load, live fields from the 5s poll. + // coral_tpus and usb_devices live in the dynamic payload so that the + // "Install Drivers" button disappears immediately after install_coral.sh + // finishes, without requiring a page reload. const hardwareData = staticHardwareData ? { - ...dynamicHardwareData, - // Keep static data from initial load - cpu: staticHardwareData.cpu, - motherboard: staticHardwareData.motherboard, - memory_modules: staticHardwareData.memory_modules, - pci_devices: staticHardwareData.pci_devices, - storage_devices: staticHardwareData.storage_devices, - gpus: staticHardwareData.gpus, - // Use dynamic data for these - temperatures: dynamicHardwareData?.temperatures, - fans: dynamicHardwareData?.fans, - power_meter: dynamicHardwareData?.power_meter, - power_supplies: dynamicHardwareData?.power_supplies, - ups: dynamicHardwareData?.ups, + ...staticHardwareData, + temperatures: dynamicHardwareData?.temperatures ?? staticHardwareData.temperatures, + fans: dynamicHardwareData?.fans ?? staticHardwareData.fans, + power_meter: dynamicHardwareData?.power_meter ?? staticHardwareData.power_meter, + power_supplies: dynamicHardwareData?.power_supplies ?? staticHardwareData.power_supplies, + ups: dynamicHardwareData?.ups ?? staticHardwareData.ups, + coral_tpus: dynamicHardwareData?.coral_tpus ?? staticHardwareData.coral_tpus, + usb_devices: dynamicHardwareData?.usb_devices ?? staticHardwareData.usb_devices, } : undefined @@ -241,27 +281,115 @@ export default function Hardware() { const [selectedUPS, setSelectedUPS] = useState(null) const [showNvidiaInstaller, setShowNvidiaInstaller] = useState(false) const [installingNvidiaDriver, setInstallingNvidiaDriver] = useState(false) + const [showAmdInstaller, setShowAmdInstaller] = useState(false) + const [showIntelInstaller, setShowIntelInstaller] = useState(false) + const [showCoralInstaller, setShowCoralInstaller] = useState(false) + const [selectedCoral, setSelectedCoral] = useState(null) + const [selectedUsbDevice, setSelectedUsbDevice] = useState(null) + + // GPU Switch Mode states + const [editingSwitchModeGpu, setEditingSwitchModeGpu] = useState(null) // GPU slot being edited + const [pendingSwitchModes, setPendingSwitchModes] = useState>({}) + const [showSwitchModeModal, setShowSwitchModeModal] = useState(false) + const [switchModeParams, setSwitchModeParams] = useState<{ gpuSlot: string; targetMode: "lxc" | "vm" } | null>(null) - const fetcher = async (url: string) => { - const data = await fetchApi(url) - return data + // Determine GPU mode based on driver (vfio-pci = VM, native driver = LXC) + const getGpuSwitchMode = (gpu: GPU): "lxc" | "vm" | "unknown" => { + const driver = gpu.pci_driver?.toLowerCase() || "" + const kernelModule = gpu.pci_kernel_module?.toLowerCase() || "" + + // Check driver first + if (driver === "vfio-pci") return "vm" + if (driver === "nvidia" || driver === "amdgpu" || driver === "radeon" || driver === "i915" || driver === "xe" || driver === "nouveau" || driver === "mgag200") return "lxc" + if (driver && driver !== "none" && driver !== "") return "lxc" + + // Fallback to kernel module if no driver + if (kernelModule.includes("vfio")) return "vm" + if (kernelModule.includes("nvidia") || kernelModule.includes("amdgpu") || kernelModule.includes("radeon") || kernelModule.includes("i915") || kernelModule.includes("xe") || kernelModule.includes("nouveau") || kernelModule.includes("mgag200")) return "lxc" + if (kernelModule && kernelModule !== "none" && kernelModule !== "") return "lxc" + + return "unknown" } - const { - data: hardwareDataSWR, - error: swrError, - isLoading: swrLoading, - mutate: mutateHardware, - } = useSWR("/api/hardware", fetcher, { - refreshInterval: 30000, - revalidateOnFocus: false, - }) + const handleSwitchModeEdit = (gpuSlot: string, e: React.MouseEvent) => { + e.stopPropagation() // Prevent opening GPU modal + setEditingSwitchModeGpu(gpuSlot) + } + + const handleSwitchModeToggle = (gpu: GPU, e?: React.MouseEvent) => { + const slot = gpu.slot + const currentMode = getGpuSwitchMode(gpu) + const pendingMode = pendingSwitchModes[slot] + + // Toggle between modes + if (pendingMode) { + // Already has pending - toggle it + const newMode = pendingMode === "lxc" ? "vm" : "lxc" + if (newMode === currentMode) { + // Back to original - remove pending + const newPending = { ...pendingSwitchModes } + delete newPending[slot] + setPendingSwitchModes(newPending) + } else { + setPendingSwitchModes({ ...pendingSwitchModes, [slot]: newMode }) + } + } else { + // No pending - set opposite of current + const newMode = currentMode === "lxc" ? "vm" : "lxc" + setPendingSwitchModes({ ...pendingSwitchModes, [slot]: newMode }) + } + } + + const handleSwitchModeSave = (gpuSlot: string, e: React.MouseEvent) => { + e.stopPropagation() + const pendingMode = pendingSwitchModes[gpuSlot] + const gpu = hardwareData?.gpus?.find(g => g.slot === gpuSlot) + const currentMode = gpu ? getGpuSwitchMode(gpu) : "unknown" + + if (pendingMode && pendingMode !== currentMode && gpu) { + // Mode has changed - save params and launch the script + setSwitchModeParams({ + gpuSlot: gpu.slot, + targetMode: pendingMode + }) + setShowSwitchModeModal(true) + } + setEditingSwitchModeGpu(null) + } + + const handleSwitchModeCancel = (gpuSlot: string, e: React.MouseEvent) => { + e.stopPropagation() + // Remove pending change for this GPU + const newPending = { ...pendingSwitchModes } + delete newPending[gpuSlot] + setPendingSwitchModes(newPending) + setEditingSwitchModeGpu(null) + } + + const handleSwitchModeModalClose = () => { + setShowSwitchModeModal(false) + // Clear params and pending changes after script runs + setSwitchModeParams(null) + setPendingSwitchModes({}) + // Refresh hardware data + mutateStatic() + } const handleInstallNvidiaDriver = () => { console.log("[v0] Opening NVIDIA installer terminal") setShowNvidiaInstaller(true) } + const handleInstallAmdTools = () => { + console.log("[v0] Opening AMD GPU tools installer terminal") + setShowAmdInstaller(true) + } + + const handleInstallIntelTools = () => { + console.log("[v0] Opening Intel GPU tools installer terminal") + setShowIntelInstaller(true) + } + useEffect(() => { if (!selectedGPU) return @@ -302,14 +430,14 @@ export default function Hardware() { } const findPCIDeviceForGPU = (gpu: GPU): PCIDevice | null => { - if (!hardwareDataSWR?.pci_devices || !gpu.slot) return null + if (!hardwareData?.pci_devices || !gpu.slot) return null // Try to find exact match first (e.g., "00:02.0") - let pciDevice = hardwareDataSWR.pci_devices.find((d) => d.slot === gpu.slot) + let pciDevice = hardwareData.pci_devices.find((d) => d.slot === gpu.slot) // If not found, try to match by partial slot (e.g., "00" matches "00:02.0") if (!pciDevice && gpu.slot.length <= 2) { - pciDevice = hardwareDataSWR.pci_devices.find( + pciDevice = hardwareData.pci_devices.find( (d) => d.slot.startsWith(gpu.slot + ":") && (d.type.toLowerCase().includes("vga") || @@ -328,12 +456,15 @@ export default function Hardware() { return realtimeGPUData.has_monitoring_tool === true } - if (swrLoading) { + if (isLoading) { return ( -
-
-
Loading hardware data...
+
+
+
+
+
Loading hardware data...
+

Detecting CPU, GPU, storage and PCI devices

) } @@ -341,7 +472,7 @@ export default function Hardware() { return (
{/* System Information - CPU & Motherboard */} - {(hardwareDataSWR?.cpu || hardwareDataSWR?.motherboard) && ( + {(hardwareData?.cpu || hardwareData?.motherboard) && (
@@ -350,44 +481,44 @@ export default function Hardware() {
{/* CPU Info */} - {hardwareDataSWR?.cpu && Object.keys(hardwareDataSWR.cpu).length > 0 && ( + {hardwareData?.cpu && Object.keys(hardwareData.cpu).length > 0 && (

CPU

- {hardwareDataSWR.cpu.model && ( + {hardwareData.cpu.model && (
Model - {hardwareDataSWR.cpu.model} + {hardwareData.cpu.model}
)} - {hardwareDataSWR.cpu.cores_per_socket && hardwareDataSWR.cpu.sockets && ( + {hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && (
Cores - {hardwareDataSWR.cpu.sockets} × {hardwareDataSWR.cpu.cores_per_socket} ={" "} - {hardwareDataSWR.cpu.sockets * hardwareDataSWR.cpu.cores_per_socket} cores + {hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "} + {hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket} cores
)} - {hardwareDataSWR.cpu.total_threads && ( + {hardwareData.cpu.total_threads && (
Threads - {hardwareDataSWR.cpu.total_threads} + {hardwareData.cpu.total_threads}
)} - {hardwareDataSWR.cpu.l3_cache && ( + {hardwareData.cpu.l3_cache && (
L3 Cache - {hardwareDataSWR.cpu.l3_cache} + {hardwareData.cpu.l3_cache}
)} - {hardwareDataSWR.cpu.virtualization && ( + {hardwareData.cpu.virtualization && (
Virtualization - {hardwareDataSWR.cpu.virtualization} + {hardwareData.cpu.virtualization}
)}
@@ -395,41 +526,41 @@ export default function Hardware() { )} {/* Motherboard Info */} - {hardwareDataSWR?.motherboard && Object.keys(hardwareDataSWR.motherboard).length > 0 && ( + {hardwareData?.motherboard && Object.keys(hardwareData.motherboard).length > 0 && (

Motherboard

- {hardwareDataSWR.motherboard.manufacturer && ( + {hardwareData.motherboard.manufacturer && (
Manufacturer - {hardwareDataSWR.motherboard.manufacturer} + {hardwareData.motherboard.manufacturer}
)} - {hardwareDataSWR.motherboard.model && ( + {hardwareData.motherboard.model && (
Model - {hardwareDataSWR.motherboard.model} + {hardwareData.motherboard.model}
)} - {hardwareDataSWR.motherboard.bios?.vendor && ( + {hardwareData.motherboard.bios?.vendor && (
BIOS - {hardwareDataSWR.motherboard.bios.vendor} + {hardwareData.motherboard.bios.vendor}
)} - {hardwareDataSWR.motherboard.bios?.version && ( + {hardwareData.motherboard.bios?.version && (
Version - {hardwareDataSWR.motherboard.bios.version} + {hardwareData.motherboard.bios.version}
)} - {hardwareDataSWR.motherboard.bios?.date && ( + {hardwareData.motherboard.bios?.date && (
Date - {hardwareDataSWR.motherboard.bios.date} + {hardwareData.motherboard.bios.date}
)}
@@ -440,18 +571,18 @@ export default function Hardware() { )} {/* Memory Modules */} - {hardwareDataSWR?.memory_modules && hardwareDataSWR.memory_modules.length > 0 && ( + {hardwareData?.memory_modules && hardwareData.memory_modules.length > 0 && (

Memory Modules

- {hardwareDataSWR.memory_modules.length} installed + {hardwareData.memory_modules.length} installed
- {hardwareDataSWR.memory_modules.map((module, index) => ( + {hardwareData.memory_modules.map((module, index) => (
{module.slot}
@@ -467,10 +598,21 @@ export default function Hardware() { {module.type}
)} - {module.speed && ( + {(module.configured_speed || module.max_speed) && (
Speed - {module.speed} + + {module.configured_speed && module.max_speed && module.configured_speed !== module.max_speed ? ( + + + {module.configured_speed} + + (max: {module.max_speed}) + + ) : ( + {module.configured_speed || module.max_speed} + )} +
)} {module.manufacturer && ( @@ -487,29 +629,29 @@ export default function Hardware() { )} {/* Thermal Monitoring */} - {hardwareDataSWR?.temperatures && hardwareDataSWR.temperatures.length > 0 && ( + {hardwareData?.temperatures && hardwareData.temperatures.length > 0 && (

Thermal Monitoring

- {hardwareDataSWR.temperatures.length} sensors + {hardwareData.temperatures.length} sensors
{/* CPU Sensors */} - {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length > 0 && ( + {groupAndSortTemperatures(hardwareData.temperatures).CPU.length > 0 && (

CPU

- {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.length} + {groupAndSortTemperatures(hardwareData.temperatures).CPU.length}
- {groupAndSortTemperatures(hardwareDataSWR.temperatures).CPU.map((temp, index) => { + {groupAndSortTemperatures(hardwareData.temperatures).CPU.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -540,21 +682,21 @@ export default function Hardware() { )} {/* GPU Sensors */} - {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length > 0 && ( + {groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 0 && (
1 ? "md:col-span-2" : ""} + className={groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 1 ? "md:col-span-2" : ""} >

GPU

- {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.length} + {groupAndSortTemperatures(hardwareData.temperatures).GPU.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).GPU.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareDataSWR.temperatures).GPU.map((temp, index) => { + {groupAndSortTemperatures(hardwareData.temperatures).GPU.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -585,23 +727,23 @@ export default function Hardware() { )} {/* NVME Sensors */} - {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length > 0 && ( + {groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && (
1 ? "md:col-span-2" : "" + groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 1 ? "md:col-span-2" : "" } >

NVME

- {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.length} + {groupAndSortTemperatures(hardwareData.temperatures).NVME.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareDataSWR.temperatures).NVME.map((temp, index) => { + {groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -632,21 +774,21 @@ export default function Hardware() { )} {/* PCI Sensors */} - {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length > 0 && ( + {groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && (
1 ? "md:col-span-2" : ""} + className={groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 1 ? "md:col-span-2" : ""} >

PCI

- {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.length} + {groupAndSortTemperatures(hardwareData.temperatures).PCI.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareDataSWR.temperatures).PCI.map((temp, index) => { + {groupAndSortTemperatures(hardwareData.temperatures).PCI.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -677,23 +819,23 @@ export default function Hardware() { )} {/* OTHER Sensors */} - {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length > 0 && ( + {groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 0 && (
1 ? "md:col-span-2" : "" + groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 1 ? "md:col-span-2" : "" } >

OTHER

- {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.length} + {groupAndSortTemperatures(hardwareData.temperatures).OTHER.length}
1 ? "md:grid-cols-2" : ""}`} + className={`grid gap-4 ${groupAndSortTemperatures(hardwareData.temperatures).OTHER.length > 1 ? "md:grid-cols-2" : ""}`} > - {groupAndSortTemperatures(hardwareDataSWR.temperatures).OTHER.map((temp, index) => { + {groupAndSortTemperatures(hardwareData.temperatures).OTHER.map((temp, index) => { const percentage = temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 const isHot = temp.current > (temp.high || 80) @@ -727,27 +869,36 @@ export default function Hardware() { )} {/* GPU Information - Enhanced with on-demand data fetching */} - {hardwareDataSWR?.gpus && hardwareDataSWR.gpus.length > 0 && ( + {hardwareData?.gpus && hardwareData.gpus.length > 0 && (

Graphics Cards

- {hardwareDataSWR.gpus.length} GPU{hardwareDataSWR.gpus.length > 1 ? "s" : ""} + {hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""}
- {hardwareDataSWR.gpus.map((gpu, index) => { + {hardwareData.gpus.map((gpu, index) => { const pciDevice = findPCIDeviceForGPU(gpu) const fullSlot = pciDevice?.slot || gpu.slot - return ( -
handleGPUClick(gpu)} - className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-4 transition-colors" - > +return ( +
{ + // Don't open modal if we're editing this GPU's switch mode + if (editingSwitchModeGpu !== fullSlot) { + handleGPUClick(gpu) + } + }} + className={`rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card p-4 transition-colors ${ + editingSwitchModeGpu === fullSlot + ? "cursor-default" + : "cursor-pointer sm:hover:bg-white/5" + }`} + >
{gpu.name} {gpu.vendor} @@ -780,6 +931,59 @@ export default function Hardware() {
)}
+ +{/* GPU Switch Mode Indicator */} + {getGpuSwitchMode(gpu) !== "unknown" && ( +
+
+ + Switch Mode + +
+ {editingSwitchModeGpu === fullSlot ? ( + <> + + + + ) : ( + + )} +
+
+ handleSwitchModeToggle(gpu, e)} + /> +
+ )}
) })} @@ -1029,7 +1233,7 @@ export default function Hardware() { {engineName} {utilizationNum.toFixed(1)}%
- +
) })} @@ -1082,6 +1286,22 @@ export default function Hardware() {
)} + ) : (findPCIDeviceForGPU(selectedGPU)?.driver === 'vfio-pci' || selectedGPU.pci_driver === 'vfio-pci') ? ( +
+
+
+ + + +
+
+

GPU in Switch Mode VM

+

+ This GPU is assigned to a virtual machine via VFIO passthrough. Real-time monitoring is not available from the host because the GPU is controlled by the VM. +

+
+
+
) : (
@@ -1110,6 +1330,28 @@ export default function Hardware() { )} + {(selectedGPU.vendor.toLowerCase().includes("amd") || selectedGPU.vendor.toLowerCase().includes("ati")) && ( + + )} + {selectedGPU.vendor.toLowerCase().includes("intel") && ( + + )}
@@ -1120,8 +1362,282 @@ export default function Hardware() { + {/* Coral TPU / AI Accelerators — only rendered when at least one device is detected. + Unlike GPUs, Coral exposes no temperature/utilization/power counters, so the + modal shows identity + driver state + an Install CTA when drivers are missing. */} + {hardwareData?.coral_tpus && hardwareData.coral_tpus.length > 0 && ( + +
+ +

Coral TPU / AI Accelerators

+ + {hardwareData.coral_tpus.length} device{hardwareData.coral_tpus.length > 1 ? "s" : ""} + +
+ +
+ {hardwareData.coral_tpus.map((coral, index) => ( +
setSelectedCoral(coral)} + className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-4 transition-colors" + > +
+ + {coral.name} + + + {coral.type === "usb" ? "USB" : "PCIe"} + +
+ +
+ {coral.form_factor && ( +
+ {coral.form_factor} + {coral.interface_speed && · {coral.interface_speed}} +
+ )} +
+ {coral.type === "pcie" ? coral.slot : coral.bus_device} +
+
+ +
+ {coral.drivers_ready ? ( + <> + + Drivers ready + + ) : ( + <> + + Drivers not installed + + )} +
+
+ ))} +
+ + {/* Primary CTA at the section level when ANY of the detected Coral devices + is missing drivers — avoids a per-card button repetition. */} + {hardwareData.coral_tpus.some((c) => !c.drivers_ready) && ( +
+
+ +
+

Install Coral TPU drivers

+

+ One or more detected Coral devices need drivers. A server reboot is required after installation. +

+
+
+ +
+ )} +
+ )} + + {/* Coral TPU detail modal */} + !open && setSelectedCoral(null)}> + + + {selectedCoral?.name} + Coral TPU Device Information + + + {selectedCoral && ( +
+
+ Connection + + {selectedCoral.type === "usb" ? "USB" : "PCIe / M.2"} + +
+ + {selectedCoral.form_factor && ( +
+ Form Factor + {selectedCoral.form_factor} +
+ )} + + {selectedCoral.interface_speed && ( +
+ Link + {selectedCoral.interface_speed} +
+ )} + +
+ + {selectedCoral.type === "usb" ? "Bus:Device" : "PCI Slot"} + + + {selectedCoral.type === "usb" ? selectedCoral.bus_device : selectedCoral.slot} + +
+ +
+ Vendor / Product ID + + {selectedCoral.vendor_id}:{selectedCoral.device_id} + +
+ +
+ Vendor + {selectedCoral.vendor} +
+ + {selectedCoral.type === "pcie" && selectedCoral.kernel_driver && ( +
+ Kernel Driver + + {selectedCoral.kernel_driver} + +
+ )} + + {selectedCoral.kernel_modules && ( +
+ Kernel Modules +
+ + gasket {selectedCoral.kernel_modules.gasket ? "✓" : "✗"} + + + apex {selectedCoral.kernel_modules.apex ? "✓" : "✗"} + +
+
+ )} + + {selectedCoral.device_nodes && selectedCoral.device_nodes.length > 0 && ( +
+ Device Nodes + + {selectedCoral.device_nodes.join(", ")} + +
+ )} + + {selectedCoral.type === "usb" && ( +
+ Runtime State + + {selectedCoral.programmed ? "Programmed (runtime loaded)" : "Unprogrammed (runtime not loaded)"} + +
+ )} + +
+ Edge TPU Runtime + + {selectedCoral.edgetpu_runtime || not installed} + +
+ + {typeof selectedCoral.temperature === "number" && (() => { + const trips = selectedCoral.temperature_trips + // Dynamic thresholds when the driver exposes trip points. + // Otherwise fall back to conservative hardcoded limits. + // trips are reported warn→critical, so [N-1] is critical (red) + // and [N-2] is the throttle/warn level (amber). + const redAt = trips && trips.length >= 1 ? trips[trips.length - 1] : 85 + const amberAt = + trips && trips.length >= 2 + ? trips[trips.length - 2] + : trips && trips.length === 1 + ? redAt - 10 + : 75 + const color = + selectedCoral.temperature >= redAt + ? "text-red-500" + : selectedCoral.temperature >= amberAt + ? "text-amber-500" + : "text-green-500" + return ( +
+ Temperature +
+ + {selectedCoral.temperature.toFixed(1)} °C + + {trips && trips.length > 0 && ( +
+ Thresholds: {trips.map((t) => `${t.toFixed(0)}°C`).join(" · ")} +
+ )} +
+
+ ) + })()} + + {selectedCoral.thermal_warnings && selectedCoral.thermal_warnings.length > 0 && ( +
+ Hardware Warnings +
+ {selectedCoral.thermal_warnings.map((w) => ( +
+ + {w.name} + {typeof w.threshold_c === "number" && ` @ ${w.threshold_c.toFixed(0)}°C`} + + + {w.enabled ? "enabled" : "disabled"} + +
+ ))} +
+
+ )} + + {!selectedCoral.drivers_ready && ( + + )} +
+ )} +
+
+ {/* Power Consumption */} - {hardwareDataSWR?.power_meter && ( + {hardwareData?.power_meter && (
@@ -1131,13 +1647,13 @@ export default function Hardware() {
-

{hardwareDataSWR.power_meter.name}

- {hardwareDataSWR.power_meter.adapter && ( -

{hardwareDataSWR.power_meter.adapter}

+

{hardwareData.power_meter.name}

+ {hardwareData.power_meter.adapter && ( +

{hardwareData.power_meter.adapter}

)}
-

{hardwareDataSWR.power_meter.watts.toFixed(1)} W

+

{hardwareData.power_meter.watts.toFixed(1)} W

Current Draw

@@ -1146,18 +1662,18 @@ export default function Hardware() { )} {/* Power Supplies */} - {hardwareDataSWR?.power_supplies && hardwareDataSWR.power_supplies.length > 0 && ( + {hardwareData?.power_supplies && hardwareData.power_supplies.length > 0 && (

Power Supplies

- {hardwareDataSWR.power_supplies.length} PSUs + {hardwareData.power_supplies.length} PSUs
- {hardwareDataSWR.power_supplies.map((psu, index) => ( + {hardwareData.power_supplies.map((psu, index) => (
{psu.name} @@ -1174,18 +1690,18 @@ export default function Hardware() { )} {/* Fans */} - {hardwareDataSWR?.fans && hardwareDataSWR.fans.length > 0 && ( + {hardwareData?.fans && hardwareData.fans.length > 0 && (

System Fans

- {hardwareDataSWR.fans.length} fans + {hardwareData.fans.length} fans
- {hardwareDataSWR.fans.map((fan, index) => { + {hardwareData.fans.map((fan, index) => { const isPercentage = fan.unit === "percent" || fan.unit === "%" const percentage = isPercentage ? fan.speed : Math.min((fan.speed / 5000) * 100, 100) @@ -1209,18 +1725,18 @@ export default function Hardware() { )} {/* UPS */} - {hardwareDataSWR?.ups && Array.isArray(hardwareDataSWR.ups) && hardwareDataSWR.ups.length > 0 && ( + {hardwareData?.ups && Array.isArray(hardwareData.ups) && hardwareData.ups.length > 0 && (

UPS Status

- {hardwareDataSWR.ups.length} UPS + {hardwareData.ups.length} UPS
- {hardwareDataSWR.ups.map((ups: any, index: number) => { + {hardwareData.ups.map((ups: any, index: number) => { const batteryCharge = ups.battery_charge_raw || Number.parseFloat(ups.battery_charge?.replace("%", "") || "0") const loadPercent = ups.load_percent_raw || Number.parseFloat(ups.load_percent?.replace("%", "") || "0") @@ -1491,18 +2007,18 @@ export default function Hardware() { {/* PCI Devices - Changed to modal */} - {hardwareDataSWR?.pci_devices && hardwareDataSWR.pci_devices.length > 0 && ( + {hardwareData?.pci_devices && hardwareData.pci_devices.length > 0 && (

PCI Devices

- {hardwareDataSWR.pci_devices.length} devices + {hardwareData.pci_devices.length} devices
- {hardwareDataSWR.pci_devices.map((device, index) => ( + {hardwareData.pci_devices.map((device, index) => (
setSelectedPCIDevice(device)} @@ -1548,6 +2064,13 @@ export default function Hardware() { {selectedPCIDevice.device}
+ {selectedPCIDevice.sdevice && ( +
+ Product Name + {selectedPCIDevice.sdevice} +
+ )} +
Vendor {selectedPCIDevice.vendor} @@ -1577,19 +2100,19 @@ export default function Hardware() { {/* Network Summary - Clickable */} - {hardwareDataSWR?.pci_devices && - hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && ( + {hardwareData?.pci_devices && + hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && (

Network Summary

- {hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces + {hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces
- {hardwareDataSWR.pci_devices + {hardwareData.pci_devices .filter((d) => d.type.toLowerCase().includes("network")) .map((device, index) => (
))}
-

Click on an interface for detailed information

)} @@ -1669,14 +2191,14 @@ export default function Hardware() { {/* Storage Summary - Clickable */} - {hardwareDataSWR?.storage_devices && hardwareDataSWR.storage_devices.length > 0 && ( + {hardwareData?.storage_devices && hardwareData.storage_devices.length > 0 && (

Storage Summary

{ - hardwareDataSWR.storage_devices.filter( + hardwareData.storage_devices.filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), ).length @@ -1686,7 +2208,7 @@ export default function Hardware() {
- {hardwareDataSWR.storage_devices + {hardwareData.storage_devices .filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), ) @@ -1811,7 +2333,6 @@ export default function Hardware() { ) })}
-

Click on a device for detailed hardware information

)} @@ -2022,6 +2543,125 @@ export default function Hardware() { + {/* USB Devices — everything physically plugged into the host's USB ports. + Root hubs (vendor 1d6b) are already filtered out by the backend. The + section is hidden on headless servers that have nothing attached. */} + {hardwareData?.usb_devices && hardwareData.usb_devices.length > 0 && ( + +
+ +

USB Devices

+ + {hardwareData.usb_devices.length} device{hardwareData.usb_devices.length > 1 ? "s" : ""} + +
+ +
+ {hardwareData.usb_devices.map((usb, index) => ( +
setSelectedUsbDevice(usb)} + className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors" + > +
+ + {usb.name} + + + {usb.class_label} + +
+
+ {usb.speed_label &&
{usb.speed_label}
} +
+ {usb.bus_device} · {usb.vendor_id}:{usb.product_id} +
+ {usb.driver && ( +
Driver: {usb.driver}
+ )} +
+
+ ))} +
+
+ )} + + {/* USB Device detail modal — mirrors the PCI Device modal for consistency. */} + !open && setSelectedUsbDevice(null)}> + + + {selectedUsbDevice?.name} + USB Device Information + + + {selectedUsbDevice && ( +
+
+ Class + + {selectedUsbDevice.class_label} + +
+ +
+ Bus:Device + {selectedUsbDevice.bus_device} +
+ +
+ Device Name + {selectedUsbDevice.name} +
+ + {selectedUsbDevice.vendor && ( +
+ Vendor + {selectedUsbDevice.vendor} +
+ )} + +
+ Vendor / Product ID + + {selectedUsbDevice.vendor_id}:{selectedUsbDevice.product_id} + +
+ + {selectedUsbDevice.speed_label && ( +
+ Speed + + {selectedUsbDevice.speed_label} + {selectedUsbDevice.speed_mbps > 0 && ( + ({selectedUsbDevice.speed_mbps} Mbps) + )} + +
+ )} + +
+ Class Code + 0x{selectedUsbDevice.class_code} +
+ + {selectedUsbDevice.driver && ( +
+ Driver + {selectedUsbDevice.driver} +
+ )} + + {selectedUsbDevice.serial && ( +
+ Serial + {selectedUsbDevice.serial} +
+ )} +
+ )} +
+
+ {/* NVIDIA Installation Monitor */} {/* { setNvidiaSessionId(null) - mutateHardware() + mutateStatic() }} onComplete={(success) => { console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed") if (success) { - mutateHardware() + mutateStatic() } }} /> */} @@ -2042,7 +2682,7 @@ export default function Hardware() { open={showNvidiaInstaller} onClose={() => { setShowNvidiaInstaller(false) - mutateHardware() + mutateStatic() }} scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/nvidia_installer.sh" scriptName="nvidia_installer" @@ -2052,6 +2692,64 @@ export default function Hardware() { title="NVIDIA Driver Installation" description="Installing NVIDIA proprietary drivers for GPU monitoring..." /> -
+ { + setShowAmdInstaller(false) + mutateStatic() + }} + scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/amd_gpu_tools.sh" + scriptName="amd_gpu_tools" + params={{ + EXECUTION_MODE: "web", + }} +title="AMD GPU Tools Installation" + description="Installing amdgpu_top for AMD GPU monitoring..." + /> + { + setShowIntelInstaller(false) + mutateStatic() + }} + scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/intel_gpu_tools.sh" + scriptName="intel_gpu_tools" + params={{ + EXECUTION_MODE: "web", + }} + title="Intel GPU Tools Installation" + description="Installing intel-gpu-tools for Intel GPU monitoring..." + /> + { + setShowCoralInstaller(false) + mutateStatic() + }} + scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/install_coral.sh" + scriptName="install_coral" + params={{ + EXECUTION_MODE: "web", + }} + title="Coral TPU Driver Installation" + description="Installing gasket + apex kernel modules and Edge TPU runtime..." + /> + + {/* GPU Switch Mode Modal */} + {switchModeParams && ( + + )} +
) } diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index d1968e84..26daa7f6 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -2,7 +2,8 @@ import type React from "react" -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" +import { getAuthToken } from "@/lib/api-config" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -11,6 +12,7 @@ import { CheckCircle2, AlertTriangle, XCircle, + Info, Activity, Cpu, MemoryStick, @@ -23,16 +25,42 @@ import { RefreshCw, Shield, X, + Clock, + BellOff, + ChevronRight, + Settings2, + HelpCircle, } from "lucide-react" interface CategoryCheck { status: string reason?: string details?: any + checks?: Record dismissable?: boolean [key: string]: any } + interface DismissedError { + error_key: string + category: string + severity: string + reason: string + dismissed: boolean + permanent?: boolean + suppression_remaining_hours: number + suppression_hours?: number + resolved_at: string + } + + interface CustomSuppression { + key: string + label: string + category: string + icon: string + hours: number + } + interface HealthDetails { overall: string summary: string @@ -51,6 +79,14 @@ interface HealthDetails { timestamp: string } + interface FullHealthData { + health: HealthDetails + active_errors: any[] + dismissed: DismissedError[] + custom_suppressions: CustomSuppression[] + timestamp: string + } + interface HealthStatusModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -58,65 +94,174 @@ interface HealthStatusModalProps { } const CATEGORIES = [ - { key: "cpu", label: "CPU Usage & Temperature", Icon: Cpu }, - { key: "memory", label: "Memory & Swap", Icon: MemoryStick }, - { key: "storage", label: "Storage Mounts & Space", Icon: HardDrive }, - { key: "disks", label: "Disk I/O & Errors", Icon: Disc }, - { key: "network", label: "Network Interfaces", Icon: Network }, - { key: "vms", label: "VMs & Containers", Icon: Box }, - { key: "services", label: "PVE Services", Icon: Settings }, - { key: "logs", label: "System Logs", Icon: FileText }, - { key: "updates", label: "System Updates", Icon: RefreshCw }, - { key: "security", label: "Security & Certificates", Icon: Shield }, + { key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu }, + { key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick }, + { key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive }, + { key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc }, + { key: "network", category: "network", label: "Network Interfaces", Icon: Network }, + { key: "vms", category: "vms", label: "VMs & Containers", Icon: Box }, + { key: "services", category: "pve_services", label: "PVE Services", Icon: Settings }, + { key: "logs", category: "logs", label: "System Logs", Icon: FileText }, + { key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw }, + { key: "security", category: "security", label: "Security & Certificates", Icon: Shield }, ] export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { const [loading, setLoading] = useState(true) const [healthData, setHealthData] = useState(null) + const [dismissedItems, setDismissedItems] = useState([]) + const [customSuppressions, setCustomSuppressions] = useState([]) const [error, setError] = useState(null) + const [dismissingKey, setDismissingKey] = useState(null) + const [expandedCategories, setExpandedCategories] = useState>(new Set()) - useEffect(() => { - if (open) { - fetchHealthDetails() - } - }, [open]) - - const fetchHealthDetails = async () => { + const fetchHealthDetails = useCallback(async () => { setLoading(true) setError(null) try { - const response = await fetch(getApiUrl("/api/health/details")) - if (!response.ok) { - throw new Error("Failed to fetch health details") + let newOverallStatus = "OK" + + // Use the new combined endpoint for fewer round-trips + const token = getAuthToken() + const authHeaders: Record = {} + if (token) { + authHeaders["Authorization"] = `Bearer ${token}` } - const data = await response.json() - console.log("[v0] Health data received:", data) - setHealthData(data) + const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders }) + let infoCount = 0 + + if (!response.ok) { + // Fallback to legacy endpoint + const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) + if (!legacyResponse.ok) throw new Error("Failed to fetch health details") + const data = await legacyResponse.json() + setHealthData(data) + setDismissedItems([]) + setCustomSuppressions([]) + newOverallStatus = data?.overall || "OK" + + // Count INFO categories from legacy data + if (data?.details) { + CATEGORIES.forEach(({ key }) => { + const cat = data.details[key as keyof typeof data.details] + if (cat && cat.status?.toUpperCase() === "INFO") { + infoCount++ + } + }) + } + } else { + const fullData: FullHealthData = await response.json() + setHealthData(fullData.health) + setDismissedItems(fullData.dismissed || []) + setCustomSuppressions(fullData.custom_suppressions || []) + newOverallStatus = fullData.health?.overall || "OK" + + // Get categories that have dismissed items (these become INFO) + const customCats = new Set((fullData.custom_suppressions || []).map((cs: { category: string }) => cs.category)) + const filteredDismissed = (fullData.dismissed || []).filter((item: { category: string }) => !customCats.has(item.category)) + const categoriesWithDismissed = new Set() + filteredDismissed.forEach((item: { category: string }) => { + const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) + if (catMeta) { + categoriesWithDismissed.add(catMeta.key) + } + }) + + // Count effective INFO categories (original INFO + OK categories with dismissed) + if (fullData.health?.details) { + CATEGORIES.forEach(({ key }) => { + const cat = fullData.health.details[key as keyof typeof fullData.health.details] + if (cat) { + const originalStatus = cat.status?.toUpperCase() + // Count as INFO if: originally INFO OR (originally OK and has dismissed items) + if (originalStatus === "INFO" || (originalStatus === "OK" && categoriesWithDismissed.has(key))) { + infoCount++ + } + } + }) + } + } + + const totalInfoCount = infoCount + + // Emit event with the FRESH data from the response, not the stale state const event = new CustomEvent("healthStatusUpdated", { - detail: { status: data.overall }, + detail: { status: newOverallStatus, infoCount: totalInfoCount }, }) window.dispatchEvent(event) } catch (err) { - console.error("[v0] Error fetching health data:", err) setError(err instanceof Error ? err.message : "Unknown error") } finally { setLoading(false) } + }, [getApiUrl]) + + // Tick counter to force re-render every 30s so "X minutes ago" stays current + const [, setTick] = useState(0) + + useEffect(() => { + if (!open) return + const tickInterval = setInterval(() => setTick(t => t + 1), 30000) + return () => clearInterval(tickInterval) + }, [open]) + + useEffect(() => { + if (open) { + fetchHealthDetails() + // Auto-refresh every 5 minutes while modal is open + const refreshInterval = setInterval(fetchHealthDetails, 300000) + return () => clearInterval(refreshInterval) + } + }, [open, fetchHealthDetails]) + + // Auto-expand non-OK categories when data loads + useEffect(() => { + if (healthData?.details) { + const nonOkCategories = new Set() + CATEGORIES.forEach(({ key }) => { + const cat = healthData.details[key as keyof typeof healthData.details] + if (cat && cat.status?.toUpperCase() !== "OK") { + // Updates section: only auto-expand on WARNING+, not INFO + if (key === "updates" && cat.status?.toUpperCase() === "INFO") { + return + } + nonOkCategories.add(key) + } + }) + setExpandedCategories(nonOkCategories) + } + }, [healthData]) + + const toggleCategory = (key: string) => { + setExpandedCategories(prev => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) } - const getStatusIcon = (status: string) => { + const getStatusIcon = (status: string, size: "sm" | "md" = "md") => { const statusUpper = status?.toUpperCase() + const cls = size === "sm" ? "h-4 w-4" : "h-5 w-5" switch (statusUpper) { case "OK": - return + return + case "INFO": + return case "WARNING": - return + return case "CRITICAL": - return + return + case "UNKNOWN": + return default: - return + return } } @@ -125,45 +270,76 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu switch (statusUpper) { case "OK": return OK + case "INFO": + return Info case "WARNING": return Warning case "CRITICAL": return Critical + case "UNKNOWN": + return UNKNOWN default: return Unknown } } - const getHealthStats = () => { - if (!healthData?.details) { - return { total: 0, healthy: 0, warnings: 0, critical: 0 } + // Get categories that have dismissed items (to show as INFO) + const getCategoriesWithDismissed = () => { + const customCats = new Set(customSuppressions.map(cs => cs.category)) + const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category)) + const categoriesWithDismissed = new Set() + filteredDismissed.forEach(item => { + // Map dismissed category to our CATEGORIES keys + const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) + if (catMeta) { + categoriesWithDismissed.add(catMeta.key) + } + }) + return categoriesWithDismissed + } + + const categoriesWithDismissed = getCategoriesWithDismissed() + + // Get effective status for a category (considers dismissed items) + const getEffectiveStatus = (key: string, originalStatus: string) => { + // If category has dismissed items and original status is OK, show as INFO + if (categoriesWithDismissed.has(key) && originalStatus?.toUpperCase() === "OK") { + return "INFO" } + return originalStatus?.toUpperCase() || "UNKNOWN" + } + + const getHealthStats = () => { + if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 } let healthy = 0 + let info = 0 let warnings = 0 let critical = 0 + let unknown = 0 CATEGORIES.forEach(({ key }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] if (categoryData) { - const status = categoryData.status?.toUpperCase() - if (status === "OK") healthy++ - else if (status === "WARNING") warnings++ - else if (status === "CRITICAL") critical++ + const effectiveStatus = getEffectiveStatus(key, categoryData.status) + if (effectiveStatus === "OK") healthy++ + else if (effectiveStatus === "INFO") info++ + else if (effectiveStatus === "WARNING") warnings++ + else if (effectiveStatus === "CRITICAL") critical++ + else if (effectiveStatus === "UNKNOWN") unknown++ } }) - return { total: CATEGORIES.length, healthy, warnings, critical } + return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown } } const stats = getHealthStats() const handleCategoryClick = (categoryKey: string, status: string) => { - if (status === "OK") return // No navegar si está OK + if (status === "OK" || status === "INFO") return - onOpenChange(false) // Cerrar el modal + onOpenChange(false) - // Mapear categorías a tabs const categoryToTab: Record = { storage: "storage", disks: "storage", @@ -176,55 +352,222 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const targetTab = categoryToTab[categoryKey] if (targetTab) { - // Disparar evento para cambiar tab const event = new CustomEvent("changeTab", { detail: { tab: targetTab } }) window.dispatchEvent(event) } } const handleAcknowledge = async (errorKey: string, e: React.MouseEvent) => { - e.stopPropagation() // Prevent navigation - - console.log("[v0] Dismissing error:", errorKey) + e.stopPropagation() + setDismissingKey(errorKey) try { - const response = await fetch(getApiUrl("/api/health/acknowledge"), { + const url = getApiUrl("/api/health/acknowledge") + const token = getAuthToken() + const headers: Record = { "Content-Type": "application/json" } + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + + const response = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ error_key: errorKey }), }) + const responseData = await response.json().catch(() => ({})) + if (!response.ok) { - const errorData = await response.json() - console.error("[v0] Acknowledge failed:", errorData) - throw new Error(errorData.error || "Failed to acknowledge error") + throw new Error(responseData.error || `Failed to dismiss error (${response.status})`) } - const result = await response.json() - console.log("[v0] Acknowledge success:", result) - - // Refresh health data - await fetchHealthDetails() + // Optimistically update local state to avoid slow re-fetch + // Add the dismissed item to the local list immediately + if (responseData.result || responseData.success) { + const dismissedItem = { + error_key: errorKey, + category: responseData.result?.category || responseData.category || '', + severity: responseData.result?.original_severity || 'WARNING', + reason: 'Dismissed by user', + dismissed: true, + acknowledged_at: new Date().toISOString() + } + setDismissedItems(prev => [...prev, dismissedItem]) + } + + // Fetch fresh data in background (non-blocking) + fetchHealthDetails().catch(() => {}) } catch (err) { - console.error("[v0] Error acknowledging:", err) - alert("Failed to dismiss error. Please try again.") + console.error("Error dismissing:", err) + } finally { + setDismissingKey(null) } } + const getTimeSinceCheck = () => { + if (!healthData?.timestamp) return null + const checkTime = new Date(healthData.timestamp) + const now = new Date() + const diffMs = now.getTime() - checkTime.getTime() + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return "just now" + if (diffMin === 1) return "1 minute ago" + if (diffMin < 60) return `${diffMin} minutes ago` + const diffHours = Math.floor(diffMin / 60) + return `${diffHours}h ${diffMin % 60}m ago` + } + + const getCategoryRowStyle = (status: string) => { + const s = status?.toUpperCase() + if (s === "CRITICAL") return "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer" + if (s === "WARNING") return "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer" + if (s === "UNKNOWN") return "bg-amber-500/5 border-amber-500/20 hover:bg-amber-500/10 cursor-pointer" + if (s === "INFO") return "bg-blue-500/5 border-blue-500/20 hover:bg-blue-500/10" + return "bg-card border-border hover:bg-muted/30" + } + + const getOutlineBadgeStyle = (status: string) => { + const s = status?.toUpperCase() + if (s === "OK") return "border-green-500 text-green-500 bg-transparent" + if (s === "INFO") return "border-blue-500 text-blue-500 bg-blue-500/5" + if (s === "WARNING") return "border-yellow-500 text-yellow-500 bg-yellow-500/5" + if (s === "CRITICAL") return "border-red-500 text-red-500 bg-red-500/5" + if (s === "UNKNOWN") return "border-amber-400 text-amber-400 bg-amber-500/5" + return "" + } + + const formatCheckLabel = (key: string): string => { + const labels: Record = { + // CPU + cpu_usage: "CPU Usage", + cpu_temperature: "Temperature", + // Memory + ram_usage: "RAM Usage", + swap_usage: "Swap Usage", + // Disk I/O + root_filesystem: "Root Filesystem", + smart_health: "SMART Health", + io_errors: "I/O Errors", + zfs_pools: "ZFS Pools", + lvm_volumes: "LVM Volumes", + lvm_check: "LVM Status", + // Network + connectivity: "Connectivity", + // VMs & CTs + qmp_communication: "QMP Communication", + container_startup: "Container Startup", + vm_startup: "VM Startup", + oom_killer: "OOM Killer", + // Services + cluster_mode: "Cluster Mode", + // Logs (prefixed with log_) + log_error_cascade: "Error Cascade", + log_error_spike: "Error Spike", + log_persistent_errors: "Persistent Errors", + log_critical_errors: "Critical Errors", + // Updates + pve_version: "Proxmox VE Version", + security_updates: "Security Updates", + system_age: "System Age", + pending_updates: "Pending Updates", + kernel_pve: "Kernel / PVE", + // Security + uptime: "Uptime", + certificates: "Certificates", + login_attempts: "Login Attempts", + fail2ban: "Fail2Ban", + // Storage (Proxmox) + proxmox_storages: "Proxmox Storages", + } + if (labels[key]) return labels[key] + // Convert snake_case or camelCase to Title Case + return key + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\b\w/g, (c) => c.toUpperCase()) + } + + const renderChecks = ( + checks: Record, + categoryKey: string + ) => { + if (!checks || Object.keys(checks).length === 0) return null + + return ( +
+ {Object.entries(checks) + .filter(([, checkData]) => checkData.installed !== false) + .map(([checkKey, checkData]) => { + const isDismissable = checkData.dismissable === true + const checkStatus = checkData.status?.toUpperCase() || "OK" + + return ( +
+
+ {getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")} + {formatCheckLabel(checkKey)} + {checkData.detail} + {checkData.dismissed && ( + + Dismissed + + )} +
+
+ {(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && ( + + )} +
+
+ ) + })} +
+ ) + } + + + return ( - +
- - - System Health Status - {healthData &&
{getStatusBadge(healthData.overall)}
} + + + System Health Status + {healthData &&
{getStatusBadge(healthData.overall)}
}
- Detailed health checks for all system components + + Detailed health checks for all system components + {getTimeSinceCheck() && ( + + + {getTimeSinceCheck()} + + )} +
{loading && ( @@ -243,116 +586,249 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {healthData && !loading && (
{/* Overall Stats Summary */} -
+
0 ? "grid-cols-5" : "grid-cols-4"}`}>
-
{stats.total}
-
Total Checks
+
{stats.total}
+
Total
-
{stats.healthy}
-
Healthy
+
{stats.healthy}
+
Healthy
+
+ {stats.info > 0 && ( +
+
{stats.info}
+
Info
+
+ )} +
+
{stats.warnings}
+
Warn
-
{stats.warnings}
-
Warnings
+
{stats.critical}
+
Critical
+ {stats.unknown > 0 && (
-
{stats.critical}
-
Critical
+
{stats.unknown}
+
Unknown
+ )}
{healthData.summary && healthData.summary !== "All systems operational" && ( -
- {healthData.summary} +
+

{healthData.summary}

)} + {/* Category List */}
{CATEGORIES.map(({ key, label, Icon }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] - const status = categoryData?.status || "UNKNOWN" + const originalStatus = categoryData?.status || "UNKNOWN" + const status = getEffectiveStatus(key, originalStatus) const reason = categoryData?.reason - const details = categoryData?.details + const checks = categoryData?.checks + const isExpanded = expandedCategories.has(key) + const hasChecks = checks && Object.keys(checks).length > 0 return (
handleCategoryClick(key, status)} - className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${ - status === "OK" - ? "bg-card border-border hover:bg-muted/30" - : status === "WARNING" - ? "bg-yellow-500/5 border-yellow-500/20 hover:bg-yellow-500/10 cursor-pointer" - : status === "CRITICAL" - ? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10 cursor-pointer" - : "bg-muted/30 hover:bg-muted/50" - }`} + className={`rounded-lg border transition-colors overflow-hidden ${getCategoryRowStyle(status)}`} > -
- - {getStatusIcon(status)} -
-
-
-

{label}

- + {/* Clickable header row */} +
toggleCategory(key)} + > +
+ + {getStatusIcon(status)} +
+
+
+

{label}

+ {hasChecks && ( + + ({Object.values(checks).filter(c => c.installed !== false).length}) + + )} +
+ {reason && !isExpanded && ( +

{reason}

+ )} +
+
+ {status} +
- {reason &&

{reason}

} - {details && typeof details === "object" && ( -
- {Object.entries(details).map(([detailKey, detailValue]: [string, any]) => { - if (typeof detailValue === "object" && detailValue !== null) { - const isDismissable = detailValue.dismissable !== false - - return ( -
-
- {detailKey}: - {detailValue.reason && ( - {detailValue.reason} - )} -
- {(status === "WARNING" || status === "CRITICAL") && isDismissable && ( - - )} -
- ) - } - return null - })} -
- )}
+ + {/* Expandable checks section */} + {isExpanded && ( +
+ {reason && ( +
+

{reason}

+ {/* Show dismiss button for UNKNOWN status at category level when dismissable */} + {status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && ( + + )} +
+ )} + {hasChecks ? ( + renderChecks(checks, key) + ) : ( +
+ + No issues detected +
+ )} +
+ )}
) })}
+ {/* Dismissed Items Section -- hide items whose category has custom suppression */} + {(() => { + const customCats = new Set(customSuppressions.map(cs => cs.category)) + const filteredDismissed = dismissedItems.filter(item => !customCats.has(item.category)) + if (filteredDismissed.length === 0) return null + return ( +
+
+ + Dismissed Items ({filteredDismissed.length}) +
+ {filteredDismissed.map((item) => { + const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) + const CatIcon = catMeta?.Icon || BellOff + const catLabel = catMeta?.label || item.category + const isPermanent = item.permanent || item.suppression_remaining_hours === -1 + + return ( +
+
+ +
+
+
+
+

{catLabel}

+

{item.reason}

+
+
+ {isPermanent ? ( + + Permanent + + ) : ( + + Dismissed + + )} + + was {item.severity} + +
+
+

+ + {isPermanent + ? "Permanently suppressed" + : `Suppressed for ${ + item.suppression_remaining_hours < 24 + ? `${Math.round(item.suppression_remaining_hours)}h` + : item.suppression_remaining_hours < 720 + ? `${Math.round(item.suppression_remaining_hours / 24)} days` + : `${Math.round(item.suppression_remaining_hours / 720)} month(s)` + } more` + } +

+
+
+ ) + })} +
+ ) + })()} + + {/* Custom Suppression Settings Summary */} + {customSuppressions.length > 0 && ( +
+
+ + Custom Suppression Settings +
+
+
+ {customSuppressions.map((cs) => { + const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label) + const CatIcon = catMeta?.Icon || Settings2 + const durationLabel = cs.hours === -1 + ? "Permanent" + : cs.hours >= 8760 + ? `${Math.floor(cs.hours / 8760)} year(s)` + : cs.hours >= 720 + ? `${Math.floor(cs.hours / 720)} month(s)` + : cs.hours >= 168 + ? `${Math.floor(cs.hours / 168)} week(s)` + : cs.hours >= 72 + ? `${Math.floor(cs.hours / 24)} days` + : `${cs.hours}h` + + return ( +
+
+ + {cs.label} +
+ + {durationLabel} + +
+ ) + })} +
+

+ Alerts in these categories are auto-suppressed when detected. +

+
+
+ )} + {healthData.timestamp && (
Last updated: {new Date(healthData.timestamp).toLocaleString()} diff --git a/AppImage/components/latency-detail-modal.tsx b/AppImage/components/latency-detail-modal.tsx new file mode 100644 index 00000000..968f6fae --- /dev/null +++ b/AppImage/components/latency-detail-modal.tsx @@ -0,0 +1,1144 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { Button } from "./ui/button" +import { Badge } from "./ui/badge" +import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, Square } from "lucide-react" +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts" +import { useIsMobile } from "../hooks/use-mobile" +import { fetchApi } from "@/lib/api-config" + +const TIMEFRAME_OPTIONS = [ + { value: "hour", label: "1 Hour" }, + { value: "6hour", label: "6 Hours" }, + { value: "day", label: "24 Hours" }, + { value: "3day", label: "3 Days" }, + { value: "week", label: "7 Days" }, +] + +const TARGET_OPTIONS = [ + { value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false }, + { value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true }, + { value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true }, + ] + +// Realtime test configuration +const REALTIME_TEST_DURATION = 120 // 2 minutes in seconds +const REALTIME_TEST_INTERVAL = 5 // 5 seconds between tests + +interface LatencyHistoryPoint { + timestamp: number + value: number + min?: number + max?: number + packet_loss?: number +} + +interface LatencyStats { + min: number + max: number + avg: number + current: number +} + +interface RealtimeResult { + target: string + target_ip: string + latency_avg: number | null + latency_min: number | null + latency_max: number | null + packet_loss: number + status: string + timestamp?: number +} + +interface LatencyDetailModalProps { + open: boolean + onOpenChange: (open: boolean) => void + currentLatency?: number +} + +const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + const entry = payload[0] + const data = entry?.payload + const packetLoss = data?.packet_loss + const hasMinMax = data?.min !== undefined && data?.max !== undefined && data?.min !== data?.max + + return ( +
+

{label}

+
+ {hasMinMax ? ( + // Show min/avg/max when downsampled data is available + <> +
+
+ Min: + {data.min} ms +
+
+
+ Avg: + {data.value} ms +
+
+
+ Max: + {data.max} ms +
+ + ) : ( + // Simple latency display for single data points +
+
+ Latency: + {entry.value} ms +
+ )} + {packetLoss !== undefined && packetLoss > 0 && ( +
+
+ Pkt Loss: + {packetLoss}% +
+ )} +
+
+ ) + } + return null +} + +const getStatusColor = (latency: number) => { + if (latency >= 200) return "#ef4444" + if (latency >= 100) return "#f59e0b" + return "#22c55e" +} + +const getStatusInfo = (latency: number | null) => { + if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } + if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } + return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" } +} + +const getStatusText = (latency: number | null): string => { + if (latency === null || latency === 0) return "N/A" + if (latency < 50) return "Excellent" + if (latency < 100) return "Good" + if (latency < 200) return "Fair" + return "Poor" +} + +interface ReportData { + target: string + targetLabel: string + isRealtime: boolean + stats: LatencyStats + realtimeResults: RealtimeResult[] + data: LatencyHistoryPoint[] + timeframe: string + testDuration?: number +} + +const generateLatencyReport = (report: ReportData) => { + const now = new Date().toLocaleString() + const logoUrl = `${window.location.origin}/images/proxmenux-logo.png` + + // Calculate stats for realtime results - all values are individual ping measurements in latency_avg + const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!) + const realtimeStats = validRealtimeValues.length > 0 ? { + min: Math.min(...validRealtimeValues), + max: Math.max(...validRealtimeValues), + avg: validRealtimeValues.reduce((acc, v) => acc + v, 0) / validRealtimeValues.length, + current: validRealtimeValues[validRealtimeValues.length - 1] ?? null, + avgPacketLoss: report.realtimeResults.reduce((acc, r) => acc + (r.packet_loss || 0), 0) / report.realtimeResults.length, + } : null + + const statusText = report.isRealtime + ? getStatusText(realtimeStats?.current ?? null) + : getStatusText(report.stats.current) + + // Colors matching Lynis report + const statusColorMap: Record = { + "Excellent": "#16a34a", + "Good": "#16a34a", + "Fair": "#ca8a04", + "Poor": "#dc2626", + "N/A": "#64748b" + } + const statusColor = statusColorMap[statusText] || "#64748b" + + const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe + + // Build test results table for realtime mode - each row is now an individual ping measurement + const realtimeTableRows = report.realtimeResults.map((r, i) => ` + 0 ? ' class="warn"' : ''}> + ${i + 1} + ${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })} + ${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'} + 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}% + ${getStatusText(r.latency_avg)} + + `).join('') + + // Build history summary for gateway mode + const historyStats = report.data.length > 0 ? { + samples: report.data.length, + avgPacketLoss: (report.data.reduce((acc, d) => acc + (d.packet_loss || 0), 0) / report.data.length).toFixed(2), + startTime: new Date(report.data[0].timestamp * 1000).toLocaleString(), + endTime: new Date(report.data[report.data.length - 1].timestamp * 1000).toLocaleString(), + } : null + + // Build history table rows for gateway mode (last 20 records) + const historyTableRows = report.data.slice(-20).map((d, i) => ` + 0 ? ' class="warn"' : ''}> + ${i + 1} + ${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + ${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'} + 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}% + ${getStatusText(d.value)} + + `).join('') + + // Generate chart SVG - data already expanded for realtime + const chartData = report.isRealtime + ? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!) + : report.data.map(d => d.value || 0) + + let chartSvg = '

Not enough data points for chart

' + if (chartData.length >= 2) { + const rawMin = Math.min(...chartData) + const rawMax = Math.max(...chartData) + // Ensure a minimum range of 10ms or 20% of the average to avoid flat lines + const avgVal = chartData.reduce((a, b) => a + b, 0) / chartData.length + const minRange = Math.max(10, avgVal * 0.2) + const range = Math.max(rawMax - rawMin, minRange) + // Center the data if range was expanded + const midPoint = (rawMin + rawMax) / 2 + const minVal = midPoint - range / 2 + const maxVal = midPoint + range / 2 + + const width = 700 + const height = 120 + const padding = 40 + const chartHeight = height - padding * 2 + const chartWidth = width - padding * 2 + + const points = chartData.map((val, i) => { + const x = padding + (i / (chartData.length - 1)) * chartWidth + const y = padding + chartHeight - ((val - minVal) / range) * chartHeight + return `${x},${y}` + }).join(' ') + + const areaPoints = `${padding},${height - padding} ${points} ${width - padding},${height - padding}` + + chartSvg = ` + + + + + + + + + + + ${Math.round(maxVal)}ms + ${Math.round((minVal + maxVal) / 2)}ms + ${Math.round(minVal)}ms + + + ${chartData.length} samples + + ` + } + + const html = ` + + + + +Network Latency Report - ${report.targetLabel} + + + + + +
+
+
+
ProxMenux Network Latency Report
+
Review the report, then print or save as PDF
+
+
+ +
+ + +
+
+ ProxMenux +
+

Network Latency Report

+

ProxMenux Monitor - Network Performance Analysis

+
+
+
+
Date: ${now}
+
Target: ${report.targetLabel}
+
Mode: ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}
+
ID: PMXL-${Date.now().toString(36).toUpperCase()}
+
+
+ + +
+
1. Executive Summary
+
+
+ + + + + + + + + + + + + 0 + 300+ + +
+ ${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg} + ms +
+
${statusText}
+
+
+

Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}

+

+ ${report.isRealtime + ? `Real-time latency test to ${report.targetLabel} with ${report.realtimeResults.length} samples collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}. + Average latency: ${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms. + ${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.` : 'No packet loss detected.'}` + : `Historical latency analysis to Gateway over ${timeframeLabel.toLowerCase()}. + ${report.data.length} samples analyzed. + Average latency: ${report.stats.avg} ms.` + } +

+
+
+ Minimum + ${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms +
+
+ Average + ${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms +
+
+ Maximum + ${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms +
+
+
+
+
+ + +
+
2. Latency Statistics
+
+
+
${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current} ms
+
Current
+
+
+
${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms
+
Minimum
+
+
+
${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms
+
Average
+
+
+
${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms
+
Maximum
+
+
+
+
+
Sample Count
+
${report.isRealtime ? report.realtimeResults.length : report.data.length}
+
+
+
Packet Loss (Avg)
+
+ ${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}% +
+
+
+
Test Period
+
+ ${report.isRealtime + ? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time') + : timeframeLabel} +
+
+
+
+ + +
+
3. Latency Graph
+
+ ${chartSvg} +
+
+ + +
+
4. Performance Thresholds
+
+
+

Excellent (< 50ms): Optimal for real-time applications, gaming, and video calls.

+
+
+
+

Good (50-100ms): Acceptable for most applications with minimal impact.

+
+
+
+

Fair (100-200ms): Noticeable delay. May affect VoIP and interactive applications.

+
+
+
+

Poor (> 200ms): Significant latency. Investigation recommended.

+
+
+ + ${report.isRealtime && report.realtimeResults.length > 0 ? ` + +
+
5. Detailed Test Results
+ + + + + + + + + + + + ${realtimeTableRows} + +
#TimeLatencyPacket LossStatus
+
+` : ''} + +${!report.isRealtime && report.data.length > 0 ? ` + +
+
5. Latency History (Last ${Math.min(20, report.data.length)} Records)
+ + + + + + + + + + + + ${historyTableRows} + +
#TimeLatencyPacket LossStatus
+
+` : ''} + + +
+
${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology
+
+
+
Test Method
+
ICMP Echo Request (Ping)
+
+
+
Samples per Test
+
3 consecutive pings
+
+
+
Target
+
${report.targetLabel}
+
+
+
Target IP
+
${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}
+
+
+
+

Performance Assessment

+

${ + statusText === 'Excellent' ? 'Network latency is excellent. No action required.' : + statusText === 'Good' ? 'Network latency is within acceptable parameters.' : + statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' : + statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' : + 'Unable to determine network status.' + }

+
+
+ + + + + +` + + // Use Blob URL for Safari-safe preview + const blob = new Blob([html], { type: "text/html" }) + const url = URL.createObjectURL(blob) + window.open(url, "_blank") +} + +export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) { + const [timeframe, setTimeframe] = useState("hour") + const [target, setTarget] = useState("gateway") + const [data, setData] = useState([]) + const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 }) + const [loading, setLoading] = useState(true) + const [realtimeResults, setRealtimeResults] = useState([]) + const [realtimeTesting, setRealtimeTesting] = useState(false) + const [testProgress, setTestProgress] = useState(0) // 0-100 percentage + const [testStartTime, setTestStartTime] = useState(null) + const testIntervalRef = useRef(null) + const isMobile = useIsMobile() + + const isRealtime = TARGET_OPTIONS.find(t => t.value === target)?.realtime ?? false + + // Cleanup on unmount or close + useEffect(() => { + if (!open) { + stopRealtimeTest() + } + return () => { + stopRealtimeTest() + } + }, [open]) + + // Fetch history for gateway + useEffect(() => { + if (open && target === "gateway") { + fetchHistory() + } + }, [open, timeframe, target]) + + // Auto-start test when switching to realtime target + useEffect(() => { + if (open && isRealtime) { + // Clear previous results and start new test + setRealtimeResults([]) + startRealtimeTest() + } else { + stopRealtimeTest() + } + }, [open, target]) + + const fetchHistory = async () => { + setLoading(true) + try { + const result = await fetchApi<{ data: LatencyHistoryPoint[]; stats: LatencyStats; target: string }>( + `/api/network/latency/history?target=${target}&timeframe=${timeframe}` + ) + if (result && result.data) { + setData(result.data) + setStats(result.stats) + } + } catch (err) { + // Silently fail + } finally { + setLoading(false) + } + } + + const runSingleTest = useCallback(async () => { + try { + const result = await fetchApi(`/api/network/latency/current?target=${target}`) + if (result) { + const baseTime = Date.now() + // Expand each ping result into 3 individual samples (min, avg, max) with slightly different timestamps + // This ensures the graph shows all actual measured values, not just averages + const samples: RealtimeResult[] = [] + + if (result.latency_min !== null) { + samples.push({ + ...result, + latency_avg: result.latency_min, + timestamp: baseTime - 200, // Slightly earlier + }) + } + if (result.latency_avg !== null && result.latency_avg !== result.latency_min && result.latency_avg !== result.latency_max) { + samples.push({ + ...result, + latency_avg: result.latency_avg, + timestamp: baseTime, + }) + } + if (result.latency_max !== null) { + samples.push({ + ...result, + latency_avg: result.latency_max, + timestamp: baseTime + 200, // Slightly later + }) + } + + // Fallback if no valid samples + if (samples.length === 0 && result.latency_avg !== null) { + samples.push({ ...result, timestamp: baseTime }) + } + + setRealtimeResults(prev => [...prev, ...samples]) + } + } catch (err) { + // Silently fail + } + }, [target]) + + const startRealtimeTest = useCallback(() => { + if (realtimeTesting) return + + setRealtimeTesting(true) + setTestProgress(0) + setTestStartTime(Date.now()) + + // Run first test immediately + runSingleTest() + + // Set up interval for subsequent tests + const totalTests = REALTIME_TEST_DURATION / REALTIME_TEST_INTERVAL + let testCount = 1 + + testIntervalRef.current = setInterval(() => { + testCount++ + const progress = Math.min(100, (testCount / totalTests) * 100) + setTestProgress(progress) + + runSingleTest() + + // Stop after duration + if (testCount >= totalTests) { + stopRealtimeTest() + } + }, REALTIME_TEST_INTERVAL * 1000) + }, [realtimeTesting, runSingleTest]) + + const stopRealtimeTest = useCallback(() => { + if (testIntervalRef.current) { + clearInterval(testIntervalRef.current) + testIntervalRef.current = null + } + setRealtimeTesting(false) + setTestProgress(100) + }, []) + + const restartRealtimeTest = useCallback(() => { + // Don't clear results - add to existing data + startRealtimeTest() + }, [startRealtimeTest]) + + // Format chart data + const chartData = data.map(point => ({ + ...point, + time: new Date(point.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + })) + + // Data already expanded to individual ping values - just format for chart + const realtimeChartData = realtimeResults + .filter(r => r.latency_avg !== null) + .map(r => ({ + time: new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }), + value: r.latency_avg, + packet_loss: r.packet_loss + })) + + // Calculate realtime stats - all values are now individual ping measurements stored in latency_avg + const validValues = realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!) + const realtimeStats = validValues.length > 0 ? { + current: validValues[validValues.length - 1], + min: Math.min(...validValues), + max: Math.max(...validValues), + avg: validValues.reduce((acc, v) => acc + v, 0) / validValues.length, + packetLoss: realtimeResults[realtimeResults.length - 1]?.packet_loss ?? 0, + } : null + + const displayStats = isRealtime ? { + current: realtimeStats?.current ?? 0, + min: realtimeStats?.min ?? 0, + max: realtimeStats?.max ?? 0, + avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10, + } : stats + + const statusInfo = getStatusInfo(displayStats.current) + + // Calculate test duration for report based on first and last result timestamps + const testDuration = realtimeResults.length >= 2 + ? Math.round(((realtimeResults[realtimeResults.length - 1].timestamp || Date.now()) - (realtimeResults[0].timestamp || Date.now())) / 1000) + : realtimeResults.length === 1 + ? 5 // Single sample = 5 seconds (one test) + : 0 + + return ( + + + + + + Network Latency + + +
+ + {!isRealtime && ( + + )} + {isRealtime && ( + realtimeTesting ? ( + + ) : ( + + ) + )} + +
+ + {/* Progress bar for realtime test */} + {isRealtime && realtimeTesting && ( +
+
+ Testing... {Math.round(testProgress)}% + {Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining +
+
+
+
+
+ )} + + {/* Stats Cards - Compact single row */} +
+
+ Current + + {displayStats.current || '-'} + + ms +
+
+ + Min + {displayStats.min || '-'} + ms +
+
+ + Avg + {displayStats.avg || '-'} + ms +
+
+ + Max + {displayStats.max || '-'} + ms +
+
+ + {/* Status Badge */} +
+ + {statusInfo.status} + + {isRealtime && ( + + {realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected + {realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''} + + )} +
+ + {/* Chart */} +
+ {isRealtime ? ( + realtimeChartData.length > 0 ? ( + + + + + + + + + + + `${Number(v).toFixed(1)}ms`} + /> + } /> + + + + ) : ( +
+ +

+ {realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'} +

+
+ ) + ) : loading ? ( +
+ +
+ ) : chartData.length > 0 ? ( + + + + + + + + + + + `${Number(v).toFixed(1)}ms`} + /> + } /> + {/* For longer timeframes (6h+), show max values to preserve spikes. + For 1 hour view, show avg values since there's no downsampling */} + + + + ) : ( +
+ +

No latency data available for this period

+

Data is collected every 60 seconds

+
+ )} +
+ + {/* Info for realtime mode */} + {isRealtime && ( +
+

+ Real-time Mode: Tests run for 2 minutes with readings every 5 seconds. + Click "Test Again" to add more samples. All data is included in the report. +

+
+ )} + +
+ ) +} diff --git a/AppImage/components/login.tsx b/AppImage/components/login.tsx index d1de5d79..f027a7ad 100644 --- a/AppImage/components/login.tsx +++ b/AppImage/components/login.tsx @@ -7,7 +7,7 @@ import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Checkbox } from "./ui/checkbox" -import { Lock, User, AlertCircle, Server, Shield } from "lucide-react" +import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react" import { getApiUrl } from "../lib/api-config" import Image from "next/image" @@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) { const [totpCode, setTotpCode] = useState("") const [requiresTotp, setRequiresTotp] = useState(false) const [rememberMe, setRememberMe] = useState(false) + const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState("") const [loading, setLoading] = useState(false) @@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) { setPassword(e.target.value)} - className="pl-10 text-base" + className="pl-10 pr-10 text-base" disabled={loading} autoComplete="current-password" /> +
@@ -237,7 +251,7 @@ export function Login({ onLogin }: LoginProps) {
-

ProxMenux Monitor v1.0.2

+

ProxMenux Monitor v1.2.0

) diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx new file mode 100644 index 00000000..7c2c387f --- /dev/null +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -0,0 +1,857 @@ +"use client" + +import type React from "react" +import { useState, useEffect, useRef, useCallback } from "react" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Activity, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + CornerDownLeft, + GripHorizontal, + ChevronDown, + Search, + Send, + Lightbulb, + Terminal, + Trash2, + X, +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu" +import { DialogHeader, DialogDescription } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTitle as SearchDialogTitle } from "@/components/ui/dialog" +import "xterm/css/xterm.css" +import { API_PORT, fetchApi } from "@/lib/api-config" + +interface LxcTerminalModalProps { + open: boolean + onClose: () => void + vmid: number + vmName: string +} + +interface CheatSheetResult { + command: string + description: string + examples: string[] +} + +const proxmoxCommands = [ + { cmd: "ls -la", desc: "List all files with details" }, + { cmd: "cd /path/to/dir", desc: "Change directory" }, + { cmd: "cat filename", desc: "Display file contents" }, + { cmd: "grep 'pattern' file", desc: "Search for pattern in file" }, + { cmd: "find . -name 'file'", desc: "Find files by name" }, + { cmd: "df -h", desc: "Show disk usage" }, + { cmd: "du -sh *", desc: "Show directory sizes" }, + { cmd: "free -h", desc: "Show memory usage" }, + { cmd: "top", desc: "Show running processes" }, + { cmd: "ps aux | grep process", desc: "Find running process" }, + { cmd: "systemctl status service", desc: "Check service status" }, + { cmd: "systemctl restart service", desc: "Restart a service" }, + { cmd: "apt update && apt upgrade", desc: "Update packages" }, + { cmd: "apt install package", desc: "Install package" }, + { cmd: "tail -f /var/log/syslog", desc: "Follow log file" }, + { cmd: "chmod 755 file", desc: "Change file permissions" }, + { cmd: "chown user:group file", desc: "Change file owner" }, + { cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" }, + { cmd: "docker ps", desc: "List running containers" }, + { cmd: "docker images", desc: "List Docker images" }, + { cmd: "ip addr show", desc: "Show IP addresses" }, + { cmd: "ping host", desc: "Test network connectivity" }, + { cmd: "curl -I url", desc: "Get HTTP headers" }, + { cmd: "history", desc: "Show command history" }, + { cmd: "clear", desc: "Clear terminal screen" }, +] + +function getWebSocketUrl(): string { + if (typeof window === "undefined") { + return "ws://localhost:8008/ws/terminal" + } + + const { protocol, hostname, port } = window.location + const isStandardPort = port === "" || port === "80" || port === "443" + const wsProtocol = protocol === "https:" ? "wss:" : "ws:" + + if (isStandardPort) { + return `${wsProtocol}//${hostname}/ws/terminal` + } else { + return `${wsProtocol}//${hostname}:${API_PORT}/ws/terminal` + } +} + +export function LxcTerminalModal({ + open: isOpen, + onClose, + vmid, + vmName, +}: LxcTerminalModalProps) { + const termRef = useRef(null) + const wsRef = useRef(null) + const fitAddonRef = useRef(null) + const terminalContainerRef = useRef(null) + const pingIntervalRef = useRef | null>(null) + + const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting") + const [isMobile, setIsMobile] = useState(false) + const [isTablet, setIsTablet] = useState(false) + const isInsideLxcRef = useRef(false) + const outputBufferRef = useRef("") + + const [modalHeight, setModalHeight] = useState(500) + const [isResizing, setIsResizing] = useState(false) + const resizeBarRef = useRef(null) + const modalHeightRef = useRef(500) + + // Search state + const [searchModalOpen, setSearchModalOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [filteredCommands, setFilteredCommands] = useState>(proxmoxCommands) + const [isSearching, setIsSearching] = useState(false) + const [searchResults, setSearchResults] = useState([]) + const [useOnline, setUseOnline] = useState(true) + + + + // Detect mobile/tablet + useEffect(() => { + const checkDevice = () => { + const width = window.innerWidth + setIsMobile(width < 640) + setIsTablet(width >= 640 && width < 1024) + } + checkDevice() + window.addEventListener("resize", checkDevice) + return () => window.removeEventListener("resize", checkDevice) + }, []) + + // Cleanup on close + useEffect(() => { + if (!isOpen) { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + pingIntervalRef.current = null + } + if (wsRef.current) { + wsRef.current.close() + wsRef.current = null + } + if (termRef.current) { + termRef.current.dispose() + termRef.current = null + } + setConnectionStatus("connecting") + isInsideLxcRef.current = false + outputBufferRef.current = "" + } + }, [isOpen]) + + // Initialize terminal + useEffect(() => { + if (!isOpen) return + + // Small delay to ensure Dialog content is rendered + const initTimeout = setTimeout(() => { + if (!terminalContainerRef.current) return + initTerminal() + }, 100) + + const initTerminal = async () => { + const [TerminalClass, FitAddonClass] = await Promise.all([ + import("xterm").then((mod) => mod.Terminal), + import("xterm-addon-fit").then((mod) => mod.FitAddon), + ]) + + const fontSize = window.innerWidth < 768 ? 12 : 16 + + const term = new TerminalClass({ + rendererType: "dom", + fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace', + fontSize: fontSize, + lineHeight: 1, + cursorBlink: true, + scrollback: 2000, + disableStdin: false, + customGlyphs: true, + fontWeight: "500", + fontWeightBold: "700", + theme: { + background: "#000000", + foreground: "#ffffff", + cursor: "#ffffff", + cursorAccent: "#000000", + black: "#2e3436", + red: "#cc0000", + green: "#4e9a06", + yellow: "#c4a000", + blue: "#3465a4", + magenta: "#75507b", + cyan: "#06989a", + white: "#d3d7cf", + brightBlack: "#555753", + brightRed: "#ef2929", + brightGreen: "#8ae234", + brightYellow: "#fce94f", + brightBlue: "#729fcf", + brightMagenta: "#ad7fa8", + brightCyan: "#34e2e2", + brightWhite: "#eeeeec", + }, + }) + + const fitAddon = new FitAddonClass() + term.loadAddon(fitAddon) + + if (terminalContainerRef.current) { + term.open(terminalContainerRef.current) + fitAddon.fit() + } + + termRef.current = term + fitAddonRef.current = fitAddon + + // Connect WebSocket to host terminal + const wsUrl = getWebSocketUrl() + const ws = new WebSocket(wsUrl) + wsRef.current = ws + +// Reset state for new connection + isInsideLxcRef.current = false + outputBufferRef.current = "" + + ws.onopen = () => { + setConnectionStatus("online") + + // Start heartbeat ping + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } else { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + } + }, 25000) + + // Sync terminal size + fitAddon.fit() + ws.send(JSON.stringify({ + type: "resize", + cols: term.cols, + rows: term.rows, + })) + + // Auto-execute pct enter after connection is ready + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`pct enter ${vmid}\r`) + } + }, 300) + } + + ws.onerror = () => { + setConnectionStatus("offline") + term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m") + } + + ws.onclose = () => { + setConnectionStatus("offline") + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m") + } + + term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } + }) + + ws.onmessage = (event) => { + // Filter out pong responses + if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { + return + } + + // Helper to strip ANSI escape codes for pattern matching + const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + + // Buffer output until we detect we're inside the LXC + // pct enter always enters directly without login prompt when run as root + if (!isInsideLxcRef.current) { + outputBufferRef.current += event.data + + const buffer = outputBufferRef.current + const cleanBuffer = stripAnsi(buffer) + + // Look for pct enter command followed by a new prompt + const pctEnterMatch = cleanBuffer.match(/pct enter (\d+)\r?\n/) + + if (pctEnterMatch) { + const afterPctEnter = cleanBuffer.substring(cleanBuffer.indexOf(pctEnterMatch[0]) + pctEnterMatch[0].length) + + // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd") + const hostPromptMatch = cleanBuffer.match(/@([a-zA-Z0-9_-]+).*pct enter/) + const hostName = hostPromptMatch ? hostPromptMatch[1] : null + + // Look for a new prompt after pct enter that ends with # or $ + // This works for both bash (user@host:~#) and ash/Alpine ([user@host /]#) + const promptMatch = afterPctEnter.match(/[@\[]([a-zA-Z0-9_-]+)[^\r\n]*[#$]\s*$/) + + if (promptMatch) { + const lxcHostname = promptMatch[1] + + // If we found a prompt with a DIFFERENT hostname than the Proxmox host, + // we're inside the LXC container + if (!hostName || lxcHostname !== hostName) { + isInsideLxcRef.current = true + + // Find the original prompt with ANSI codes to display it properly + const afterPctEnterWithAnsi = buffer.substring(buffer.indexOf('pct enter') + pctEnterMatch[0].length) + + // Write the LXC prompt (last line with # or $) + const lastPromptMatch = afterPctEnterWithAnsi.match(/[^\r\n]*[#$]\s*$/) + if (lastPromptMatch) { + term.write(lastPromptMatch[0]) + } + + // Detect if this is Alpine/ash shell by checking prompt format + // Alpine uses: [root@hostname ~]# or [root@hostname /]# + // Other distros use: root@hostname:/# or root@hostname:~# + const isAlpine = afterPctEnter.match(/\[[^\]]+@[^\]]+\s+[^\]]*\][#$]/) + + if (isAlpine) { + // Send an extra Enter ONLY for Alpine containers (ash shell) + // This forces the prompt to refresh properly + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('\r') + } + }, 100) + } + + return + } + } + } + } else { + // Already inside LXC, write directly + term.write(event.data) + } + } + } + + return () => { + clearTimeout(initTimeout) + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current) + } + if (wsRef.current) { + wsRef.current.close() + } + if (termRef.current) { + termRef.current.dispose() + } + } + }, [isOpen, vmid]) + + // Resize handling + useEffect(() => { + if (termRef.current && fitAddonRef.current && isOpen) { + setTimeout(() => { + fitAddonRef.current?.fit() + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: "resize", + cols: termRef.current.cols, + rows: termRef.current.rows, + })) + } + }, 100) + } + }, [modalHeight, isOpen]) + + // Resize bar handlers + const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + setIsResizing(true) + modalHeightRef.current = modalHeight + }, [modalHeight]) + + useEffect(() => { + if (!isResizing) return + + const handleMove = (e: MouseEvent | TouchEvent) => { + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY + const windowHeight = window.innerHeight + const newHeight = windowHeight - clientY - 20 + const clampedHeight = Math.max(300, Math.min(windowHeight - 100, newHeight)) + modalHeightRef.current = clampedHeight + setModalHeight(clampedHeight) + } + + const handleEnd = () => { + setIsResizing(false) + } + + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleEnd) + document.addEventListener("touchmove", handleMove) + document.addEventListener("touchend", handleEnd) + + return () => { + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleEnd) + document.removeEventListener("touchmove", handleMove) + document.removeEventListener("touchend", handleEnd) + } + }, [isResizing]) + + // Send key helpers for mobile/tablet + const sendKey = useCallback((key: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(key) + } + }, []) + + const sendEsc = useCallback(() => sendKey("\x1b"), [sendKey]) + const sendTab = useCallback(() => sendKey("\t"), [sendKey]) + const sendArrowUp = useCallback(() => sendKey("\x1b[A"), [sendKey]) + const sendArrowDown = useCallback(() => sendKey("\x1b[B"), [sendKey]) + const sendArrowLeft = useCallback(() => sendKey("\x1b[D"), [sendKey]) + const sendArrowRight = useCallback(() => sendKey("\x1b[C"), [sendKey]) + const sendEnter = useCallback(() => sendKey("\r"), [sendKey]) + const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C + + // Search effect - debounced search with cheat.sh + useEffect(() => { + const searchCheatSh = async (query: string) => { + if (!query.trim()) { + setSearchResults([]) + setFilteredCommands(proxmoxCommands) + return + } + + try { + setIsSearching(true) + const searchEndpoint = `/api/terminal/search-command?q=${encodeURIComponent(query)}` + const data = await fetchApi<{ success: boolean; examples: any[] }>(searchEndpoint, { + method: "GET", + signal: AbortSignal.timeout(10000), + }) + + if (!data.success || !data.examples || data.examples.length === 0) { + throw new Error("No examples found") + } + + const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({ + command: example.command, + description: example.description || "", + examples: [example.command], + })) + + setUseOnline(true) + setSearchResults(formattedResults) + } catch (error) { + const filtered = proxmoxCommands.filter( + (item) => + item.cmd.toLowerCase().includes(query.toLowerCase()) || + item.desc.toLowerCase().includes(query.toLowerCase()), + ) + setFilteredCommands(filtered) + setSearchResults([]) + setUseOnline(false) + } finally { + setIsSearching(false) + } + } + + const debounce = setTimeout(() => { + if (searchQuery && searchQuery.length >= 2) { + searchCheatSh(searchQuery) + } else { + setSearchResults([]) + setFilteredCommands(proxmoxCommands) + } + }, 800) + + return () => clearTimeout(debounce) + }, [searchQuery]) + + const handleClear = useCallback(() => { + if (termRef.current) { + termRef.current.clear() + } + }, []) + + const sendToTerminal = useCallback((command: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(command) + setTimeout(() => { + setSearchModalOpen(false) + }, 100) + } + }, []) + + const showMobileControls = isMobile || isTablet + + return ( + !open && onClose()}> + + {/* Resize bar */} +
+ +
+ + {/* Header */} +
+ + Terminal: {vmName} (ID: {vmid}) + +
+ + +
+
+ + {/* Terminal container */} +
+
+
+ + {/* Mobile/Tablet control buttons */} + {showMobileControls && ( +
+
+ + + + + + + + + + + + + Control Sequences + + sendKey("\x03")}> + Ctrl+C + Cancel/Interrupt + + sendKey("\x18")}> + Ctrl+X + Exit (nano) + + sendKey("\x12")}> + Ctrl+R + Search history + + + +
+
+ )} + + {/* Status bar at bottom */} +
+
+ +
+ {connectionStatus} +
+ +
+ + + {/* Search Commands Modal */} + + + + Search Commands +
+
+
+ + + Search for Linux commands + +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base" + autoCapitalize="none" + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> +
+ + {isSearching && ( +
+
+

Searching cheat.sh...

+
+ )} + +
+ {searchResults.length > 0 ? ( + <> + {searchResults.map((result, index) => ( +
+ {result.description && ( +

# {result.description}

+ )} +
sendToTerminal(result.command)} + className="flex items-start justify-between gap-2 cursor-pointer group hover:bg-zinc-800/50 rounded p-2 -m-2" + > + {result.command} + +
+
+ ))} +
+

+ + Powered by cheat.sh +

+
+ + ) : filteredCommands.length > 0 && !useOnline ? ( + filteredCommands.map((item, index) => ( +
sendToTerminal(item.cmd)} + className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors" + > +
+
+ {item.cmd} +

{item.desc}

+
+ +
+
+ )) + ) : !isSearching && !searchQuery && !useOnline ? ( + proxmoxCommands.map((item, index) => ( +
sendToTerminal(item.cmd)} + className="p-3 rounded-lg border border-zinc-700 bg-zinc-800/50 hover:bg-zinc-800 hover:border-blue-500 cursor-pointer transition-colors" + > +
+
+ {item.cmd} +

{item.desc}

+
+ +
+
+ )) + ) : !isSearching ? ( +
+ {searchQuery ? ( + <> + +
+

{"No results found for \""}{searchQuery}{"\""}

+

Try a different command or check your spelling

+
+ + ) : ( + <> + +
+

Search for any command

+
+

Try searching for:

+
+ {["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => ( + setSearchQuery(cmd)} + className="px-2 py-1 bg-zinc-800 rounded text-blue-400 cursor-pointer hover:bg-zinc-700" + > + {cmd} + + ))} +
+
+
+ {useOnline && ( +
+ + Powered by cheat.sh +
+ )} + + )} +
+ ) : null} +
+ +
+
+ + Tip: Search for any Linux command +
+ {useOnline && searchResults.length > 0 && Powered by cheat.sh} +
+
+ + +
+ ) +} diff --git a/AppImage/components/network-metrics.tsx b/AppImage/components/network-metrics.tsx index f82cafdc..74cafd3e 100644 --- a/AppImage/components/network-metrics.tsx +++ b/AppImage/components/network-metrics.tsx @@ -4,12 +4,14 @@ import { useEffect, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" -import { Wifi, Activity, Network, Router, AlertCircle, Zap } from 'lucide-react' +import { Wifi, Activity, Network, Router, AlertCircle, Zap, Timer } from 'lucide-react' import useSWR from "swr" import { NetworkTrafficChart } from "./network-traffic-chart" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" +import { LatencyDetailModal } from "./latency-detail-modal" +import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts" interface NetworkData { interfaces: NetworkInterface[] @@ -140,8 +142,8 @@ export function NetworkMetrics() { error, isLoading, } = useSWR("/api/network", fetcher, { - refreshInterval: 53000, - revalidateOnFocus: false, + refreshInterval: 15000, + revalidateOnFocus: true, revalidateOnReconnect: true, }) @@ -150,8 +152,19 @@ export function NetworkMetrics() { const [modalTimeframe, setModalTimeframe] = useState<"hour" | "day" | "week" | "month" | "year">("day") const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 }) + const [latencyModalOpen, setLatencyModalOpen] = useState(false) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit()) + + // Latency history for sparkline (last hour) + const { data: latencyData } = useSWR<{ + data: Array<{ timestamp: number; value: number }> + stats: { min: number; max: number; avg: number; current: number } + target: string + }>("/api/network/latency/history?target=gateway&timeframe=hour", + (url: string) => fetchApi(url), + { refreshInterval: 60000, revalidateOnFocus: false } + ) useEffect(() => { setNetworkUnit(getNetworkUnit()) @@ -177,10 +190,13 @@ export function NetworkMetrics() { if (isLoading) { return ( -
-
-
Loading network data...
+
+
+
+
+
Loading network data...
+

Scanning interfaces, bridges and traffic

) } @@ -327,48 +343,95 @@ export function NetworkMetrics() { + {/* Merged Network Config & Health Card */} - Network Configuration - + Network Status + + {healthStatus} +
-
+
Hostname - {hostname} + {hostname}
-
- Domain - {domain} -
-
+
Primary DNS - {primaryDNS} + {primaryDNS} +
+
+ Packet Loss + {avgPacketLoss}% +
+
+ Errors + {totalErrors}
- + {/* Latency Card with Sparkline */} + setLatencyModalOpen(true)} + > - Network Health - + Network Latency + - - {healthStatus} - -
-
- Packet Loss: - {avgPacketLoss}% -
-
- Errors: - {totalErrors} +
+
+ {latencyData?.stats?.current ?? 0} ms
+ + {(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" : + (latencyData?.stats?.current ?? 0) < 100 ? "Good" : + (latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"} +
+ {/* Sparkline */} + {latencyData?.data && latencyData.data.length > 0 && ( +
+ + + + + + + + + + + +
+ )} +

+ Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms +

@@ -1088,6 +1151,12 @@ export function NetworkMetrics() { )}
+ + {/* Latency Detail Modal */} +
) } diff --git a/AppImage/components/node-metrics-charts.tsx b/AppImage/components/node-metrics-charts.tsx index 575b9737..79bd77cc 100644 --- a/AppImage/components/node-metrics-charts.tsx +++ b/AppImage/components/node-metrics-charts.tsx @@ -78,6 +78,10 @@ export function NodeMetricsCharts() { memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true }, }) + // Check if ZFS ARC or Free memory have any non-zero values to decide if we should show them + const hasZfsArc = data.some(d => d.memoryZfsArc > 0) + const hasMemoryFree = data.some(d => d.memoryFree > 0) + useEffect(() => { console.log("[v0] NodeMetricsCharts component mounted") fetchMetrics() @@ -194,6 +198,11 @@ export function NodeMetricsCharts() { return (
{payload.map((entry: any, index: number) => { + // For memory chart, hide ZFS ARC and Free from legend if they have no data + if (chartType === "memory") { + if (entry.dataKey === "memoryZfsArc" && !hasZfsArc) return null + if (entry.dataKey === "memoryFree" && !hasMemoryFree) return null + } const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]] return (
- - + {/* Only show ZFS ARC if there's data */} + {hasZfsArc && ( + + )} + {/* Only show Free memory if there's data */} + {hasMemoryFree && ( + + )} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx new file mode 100644 index 00000000..c750b4bc --- /dev/null +++ b/AppImage/components/notification-settings.tsx @@ -0,0 +1,2324 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useTheme } from "next-themes" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" +import { fetchApi } from "../lib/api-config" +import { + Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, + AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, + Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook, + Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, + Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb +} from "lucide-react" + +interface ChannelConfig { + enabled: boolean + rich_format?: boolean + bot_token?: string + chat_id?: string + topic_id?: string // Telegram topic ID for supergroups with topics + url?: string + token?: string + webhook_url?: string + // Email channel fields + host?: string + port?: string + username?: string + password?: string + tls_mode?: string + from_address?: string + to_addresses?: string + subject_prefix?: string +} + +interface EventTypeInfo { + type: string + title: string + default_enabled: boolean +} + +interface ChannelOverrides { + categories: Record + events: Record +} + +interface NotificationConfig { + enabled: boolean + channels: Record + event_categories: Record + event_toggles: Record + event_types_by_group: Record + channel_overrides: Record + ai_enabled: boolean + ai_provider: string + ai_api_keys: Record // Per-provider API keys + ai_models: Record // Per-provider selected models + ai_model: string // Current active model (for the selected provider) + ai_language: string + ai_ollama_url: string + ai_openai_base_url: string + ai_prompt_mode: string // 'default' or 'custom' + ai_custom_prompt: string // User's custom prompt + ai_allow_suggestions: string | boolean // Enable AI suggestions (experimental) + channel_ai_detail: Record + hostname: string + webhook_secret: string + webhook_allowed_ips: string + pbs_host: string + pve_host: string + pbs_trusted_sources: string +} + +interface ServiceStatus { + enabled: boolean + running: boolean + channels: Record + queue_size: number + last_sent: string | null + total_sent_24h: number +} + +interface HistoryEntry { + id: number + event_type: string + channel: string + title: string + severity: string + sent_at: string + success: boolean + error_message: string | null +} + +const EVENT_CATEGORIES = [ + { key: "vm_ct", label: "VM / CT", desc: "Start, stop, crash, migration" }, + { key: "backup", label: "Backups", desc: "Backup start, complete, fail" }, + { key: "resources", label: "Resources", desc: "CPU, memory, temperature" }, + { key: "storage", label: "Storage", desc: "Disk space, I/O, SMART" }, + { key: "network", label: "Network", desc: "Connectivity, bond, latency" }, + { key: "security", label: "Security", desc: "Auth failures, Fail2Ban, firewall" }, + { key: "cluster", label: "Cluster", desc: "Quorum, split-brain, HA fencing" }, + { key: "services", label: "Services", desc: "System services, shutdown, reboot" }, + { key: "health", label: "Health Monitor", desc: "Health checks, degradation, recovery" }, + { key: "updates", label: "Updates", desc: "System and PVE updates" }, + { key: "other", label: "Other", desc: "Uncategorized notifications" }, +] + +const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email"] as const + +const AI_PROVIDERS = [ + { + value: "groq", + label: "Groq", + description: "Very fast, generous free tier (30 req/min). Ideal to start.", + keyUrl: "https://console.groq.com/keys", + icon: "/icons/Groq Logo_White 25.svg", + iconLight: "/icons/Groq Logo_Black 25.svg" + }, + { + value: "openai", + label: "OpenAI", + description: "Industry standard. Very accurate and widely used.", + keyUrl: "https://platform.openai.com/api-keys", + icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai.webp", + iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openai-light.webp" + }, + { + value: "anthropic", + label: "Anthropic (Claude)", + description: "Excellent for writing and translation. Fast and economical.", + keyUrl: "https://console.anthropic.com/settings/keys", + icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-light.webp", + iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/claude-dark.webp" + }, + { + value: "gemini", + label: "Google Gemini", + description: "Free tier available, great quality/price ratio.", + keyUrl: "https://aistudio.google.com/app/apikey", + icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp", + iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/google-gemini.webp" + }, + { + value: "ollama", + label: "Ollama (Local)", + description: "Uses models available on your Ollama server. 100% local, no costs, total privacy.", + keyUrl: "", + icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama.webp", + iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ollama-light.webp" + }, + { + value: "openrouter", + label: "OpenRouter", + description: "Aggregator with access to 100+ models using a single API key. Maximum flexibility.", + keyUrl: "https://openrouter.ai/keys", + icon: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-light.webp", + iconLight: "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openrouter-dark.webp" + }, +] + +const AI_LANGUAGES = [ + { value: "en", label: "English" }, + { value: "es", label: "Espanol" }, + { value: "fr", label: "Francais" }, + { value: "de", label: "Deutsch" }, + { value: "pt", label: "Portugues" }, + { value: "it", label: "Italiano" }, + { value: "ru", label: "Russkiy" }, + { value: "sv", label: "Svenska" }, + { value: "no", label: "Norsk" }, + { value: "ja", label: "Nihongo" }, + { value: "zh", label: "Zhongwen" }, + { value: "nl", label: "Nederlands" }, +] + +const AI_DETAIL_LEVELS = [ + { value: "brief", label: "Brief", desc: "2-3 lines, essential only" }, + { value: "standard", label: "Standard", desc: "Concise with basic context" }, + { value: "detailed", label: "Detailed", desc: "Complete technical details" }, +] + +// Example custom prompt for users to adapt +const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor. + +Your task is to translate and format server notifications. + +RULES: +1. Translate to the user's preferred language +2. Use plain text only (no markdown, no bold, no italic) +3. Be concise and factual +4. Do not add recommendations or suggestions +5. Present only the facts from the input +6. Keep hostname prefix in titles (e.g., "pve01: ") + +OUTPUT FORMAT: +[TITLE] +your translated title here +[BODY] +your translated message here + +Detail levels: +- brief: 2-3 lines, essential only +- standard: short paragraph with key details +- detailed: full technical breakdown` + +const DEFAULT_CONFIG: NotificationConfig = { + enabled: false, + channels: { + telegram: { enabled: false }, + gotify: { enabled: false }, + discord: { enabled: false }, + email: { enabled: false }, + }, + event_categories: { + vm_ct: true, backup: true, resources: true, storage: true, + network: true, security: true, cluster: true, services: true, + health: true, updates: true, other: true, + }, + event_toggles: {}, + event_types_by_group: {}, + channel_overrides: { + telegram: { categories: {}, events: {} }, + gotify: { categories: {}, events: {} }, + discord: { categories: {}, events: {} }, + email: { categories: {}, events: {} }, + }, + ai_enabled: false, + ai_provider: "groq", + ai_api_keys: { + groq: "", + gemini: "", + anthropic: "", + openai: "", + openrouter: "", + }, + ai_models: { + groq: "", + ollama: "", + gemini: "", + anthropic: "", + openai: "", + openrouter: "", + }, + ai_model: "", + ai_language: "en", + ai_ollama_url: "http://localhost:11434", + ai_openai_base_url: "", + ai_prompt_mode: "default", + ai_custom_prompt: "", + ai_allow_suggestions: "false", + channel_ai_detail: { + telegram: "brief", + gotify: "brief", + discord: "brief", + email: "detailed", + }, + hostname: "", + webhook_secret: "", + webhook_allowed_ips: "", + pbs_host: "", + pve_host: "", + pbs_trusted_sources: "", +} + +export function NotificationSettings() { + const { resolvedTheme } = useTheme() + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [status, setStatus] = useState(null) + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [testing, setTesting] = useState(null) + const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null) + const [showHistory, setShowHistory] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [showSecrets, setShowSecrets] = useState>({}) + const [editMode, setEditMode] = useState(false) + const [hasChanges, setHasChanges] = useState(false) + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + const [originalConfig, setOriginalConfig] = useState(DEFAULT_CONFIG) + const [showProviderInfo, setShowProviderInfo] = useState(false) + const [showTelegramHelp, setShowTelegramHelp] = useState(false) + const [testingAI, setTestingAI] = useState(false) + const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null) + const [providerModels, setProviderModels] = useState([]) + const [loadingProviderModels, setLoadingProviderModels] = useState(false) + const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false) + const [editingCustomPrompt, setEditingCustomPrompt] = useState(false) + const [customPromptDraft, setCustomPromptDraft] = useState("") + const [webhookSetup, setWebhookSetup] = useState<{ + status: "idle" | "running" | "success" | "failed" + fallback_commands: string[] + error: string + }>({ status: "idle", fallback_commands: [], error: "" }) + const [systemHostname, setSystemHostname] = useState("") + + // Load system hostname for display name placeholder + const loadSystemHostname = useCallback(async () => { + try { + const data = await fetchApi<{ hostname?: string }>("/api/system") + if (data.hostname) { + setSystemHostname(data.hostname) + } + } catch { + // Ignore - will show generic placeholder + } + }, []) + + const loadConfig = useCallback(async () => { + try { + const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings") + if (data.success && data.config) { + // Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs) + const configWithDefaults = { + ...data.config, + ai_api_keys: data.config.ai_api_keys || { + groq: "", + ollama: "", + gemini: "", + anthropic: "", + openai: "", + openrouter: "", + }, + ai_models: data.config.ai_models || { + groq: "", + ollama: "", + gemini: "", + anthropic: "", + openai: "", + openrouter: "", + }, + ai_prompt_mode: data.config.ai_prompt_mode || "default", + ai_custom_prompt: data.config.ai_custom_prompt || "", + ai_allow_suggestions: data.config.ai_allow_suggestions || "false", + } + // If ai_model exists but ai_models doesn't have it, save it + if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) { + configWithDefaults.ai_models[configWithDefaults.ai_provider] = configWithDefaults.ai_model + } + setConfig(configWithDefaults) + setOriginalConfig(configWithDefaults) + } + } catch (err) { + console.error("Failed to load notification settings:", err) + } finally { + setLoading(false) + } + }, []) + + const loadStatus = useCallback(async () => { + try { + const data = await fetchApi<{ success: boolean } & ServiceStatus>("/api/notifications/status") + if (data.success) { + setStatus(data) + } + } catch { + // Service may not be running yet + } + }, []) + + const loadHistory = useCallback(async () => { + try { + const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20") + if (data.success) { + setHistory(data.history || []) + } + } catch { + // Ignore + } + }, []) + + useEffect(() => { + loadConfig() + loadStatus() + loadSystemHostname() + }, [loadConfig, loadStatus, loadSystemHostname]) + + useEffect(() => { + if (showHistory) loadHistory() + }, [showHistory, loadHistory]) + + // Auto-expand AI section when AI is enabled + useEffect(() => { + if (config.ai_enabled) { + setShowAdvanced(true) + } + }, [config.ai_enabled]) + + const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => { + setConfig(prev => { + const next = updater(prev) + setHasChanges(true) + return next + }) + } + + const updateChannel = (channel: string, field: string, value: string | boolean) => { + updateConfig(prev => ({ + ...prev, + channels: { + ...prev.channels, + [channel]: { ...prev.channels[channel], [field]: value }, + }, + })) + } + + /** Reusable 10+1 category block rendered inside each channel tab. */ + const renderChannelCategories = (chName: string) => { + const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} } + const evtByGroup = config.event_types_by_group || {} + + return ( +
+
+ + +
+
+ {EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { + const isEnabled = overrides.categories[cat.key] ?? true + const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) + const eventsForGroup = evtByGroup[cat.key] || [] + const enabledCount = eventsForGroup.filter( + e => (overrides.events?.[e.type] ?? e.default_enabled) + ).length + + return ( +
+ {/* Category row -- entire block is clickable to expand/collapse */} +
{ + if (!isEnabled) return + setExpandedCategories(prev => { + const next = new Set(prev) + const key = `${chName}.${cat.key}` + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + }} + > + {/* Expand arrow */} + + + {/* Label */} +
+ {cat.label} +
+ + {/* Count badge */} + {isEnabled && eventsForGroup.length > 0 && ( + + {enabledCount}/{eventsForGroup.length} + + )} + + {/* Toggle -- same style as channel enable toggle */} + +
+ + {/* Sub-event toggles */} + {isEnabled && isExpanded && eventsForGroup.length > 0 && ( +
+ {eventsForGroup.map(evt => { + const evtEnabled = overrides.events?.[evt.type] ?? evt.default_enabled + return ( +
+ + {evt.title} + + +
+ ) + })} +
+ )} +
+ ) + })} +
+
+ ) + } + + /** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */ + const flattenConfig = (cfg: NotificationConfig): Record => { + const flat: Record = { + enabled: String(cfg.enabled), + ai_enabled: String(cfg.ai_enabled), + ai_provider: cfg.ai_provider, + ai_model: cfg.ai_model, + ai_language: cfg.ai_language, + ai_ollama_url: cfg.ai_ollama_url, + ai_openai_base_url: cfg.ai_openai_base_url, + ai_prompt_mode: cfg.ai_prompt_mode || "default", + ai_custom_prompt: cfg.ai_custom_prompt || "", + ai_allow_suggestions: String(cfg.ai_allow_suggestions === "true" || cfg.ai_allow_suggestions === true), + hostname: cfg.hostname, + webhook_secret: cfg.webhook_secret, + webhook_allowed_ips: cfg.webhook_allowed_ips, + pbs_host: cfg.pbs_host, + pve_host: cfg.pve_host, + pbs_trusted_sources: cfg.pbs_trusted_sources, + } + // Flatten per-provider API keys + if (cfg.ai_api_keys) { + for (const [provider, key] of Object.entries(cfg.ai_api_keys)) { + if (key) { + flat[`ai_api_key_${provider}`] = key + } + } + } + // Flatten per-provider selected models + if (cfg.ai_models) { + for (const [provider, model] of Object.entries(cfg.ai_models)) { + if (model) { + flat[`ai_model_${provider}`] = model + } + } + } + // Flatten channels: { telegram: { enabled, bot_token, chat_id } } -> telegram.enabled, telegram.bot_token, ... + for (const [chName, chCfg] of Object.entries(cfg.channels)) { + for (const [field, value] of Object.entries(chCfg)) { + flat[`${chName}.${field}`] = String(value ?? "") + } + } + // Per-channel category & event toggles: telegram.events.vm_ct, telegram.event.vm_start, etc. + // Each channel independently owns its notification preferences. + if (cfg.channel_overrides) { + for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) { + if (overrides.categories) { + for (const [cat, enabled] of Object.entries(overrides.categories)) { + flat[`${chName}.events.${cat}`] = String(enabled) + } + } + if (overrides.events) { + for (const [evt, enabled] of Object.entries(overrides.events)) { + flat[`${chName}.event.${evt}`] = String(enabled) + } + } + } + } + // Per-channel AI detail level + if (cfg.channel_ai_detail) { + for (const [chName, level] of Object.entries(cfg.channel_ai_detail)) { + flat[`${chName}.ai_detail_level`] = level + } + } + return flat + } + + const handleSave = async () => { + setSaving(true) + try { + // If notifications are being disabled, clean up PVE webhook first + const wasEnabled = originalConfig.enabled + const isNowDisabled = !config.enabled + + if (wasEnabled && isNowDisabled) { + try { + await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" }) + } catch { + // Non-fatal: webhook cleanup failed but we still save settings + } + } + + const payload = flattenConfig(config) + await fetchApi("/api/notifications/settings", { + method: "POST", + body: JSON.stringify(payload), + }) + setOriginalConfig(config) + setHasChanges(false) + setEditMode(false) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + loadStatus() + } catch (err) { + console.error("Failed to save notification settings:", err) + } finally { + setSaving(false) + } + } + + const handleCancel = () => { + setConfig(originalConfig) + setHasChanges(false) + setEditMode(false) + } + + const handleTest = async (channel: string) => { + setTesting(channel) + setTestResult(null) + try { + // Auto-save current config before testing so backend has latest channel data + const payload = flattenConfig(config) + await fetchApi("/api/notifications/settings", { + method: "POST", + body: JSON.stringify(payload), + }) + setOriginalConfig(config) + setHasChanges(false) + + const data = await fetchApi<{ + success: boolean + message?: string + error?: string + results?: Record + }>("/api/notifications/test", { + method: "POST", + body: JSON.stringify({ channel }), + }) + + // Extract message from the results object if present + let message = data.message || "" + if (!message && data.results) { + const channelResult = data.results[channel] + if (channelResult) { + message = channelResult.success + ? "Test notification sent successfully" + : channelResult.error || "Test failed" + } + } + if (!message && data.error) { + message = data.error + } + if (!message) { + message = data.success ? "Test notification sent successfully" : "Test failed" + } + + setTestResult({ channel, success: data.success, message }) + } catch (err) { + setTestResult({ channel, success: false, message: String(err) }) + } finally { + setTesting(null) + setTimeout(() => setTestResult(null), 8000) + } + } + + const fetchProviderModels = useCallback(async () => { + const provider = config.ai_provider + const apiKey = config.ai_api_keys?.[provider] || "" + + // For Ollama, we need the URL; for others, we need the API key + if (provider === 'ollama') { + if (!config.ai_ollama_url) return + } else if (provider !== 'anthropic') { + // Anthropic doesn't have a models list endpoint, skip validation + if (!apiKey) return + } + + setLoadingProviderModels(true) + try { + const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/api/notifications/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + api_key: apiKey, + ollama_url: config.ai_ollama_url, + openai_base_url: config.ai_openai_base_url, + }), + }) + if (data.success && data.models && data.models.length > 0) { + setProviderModels(data.models) + // Auto-select recommended model if current selection is empty or not in the list + updateConfig(prev => { + if (!prev.ai_model || !data.models.includes(prev.ai_model)) { + const modelToSelect = data.recommended || data.models[0] + return { + ...prev, + ai_model: modelToSelect, + ai_models: { ...prev.ai_models, [provider]: modelToSelect } + } + } + return prev + }) + } else { + setProviderModels([]) + } + } catch { + setProviderModels([]) + } finally { + setLoadingProviderModels(false) + } + }, [config.ai_provider, config.ai_api_keys, config.ai_ollama_url, config.ai_openai_base_url]) + + // Note: Users use the "Load" button explicitly to fetch models. + + const handleTestAI = async () => { + setTestingAI(true) + setAiTestResult(null) + try { + // Get the API key for the current provider + const currentApiKey = config.ai_api_keys?.[config.ai_provider] || "" + // Use the model selected by the user (loaded from provider) + const modelToUse = config.ai_model + + if (!modelToUse) { + setAiTestResult({ success: false, message: "No model selected. Click 'Load' to fetch available models first." }) + return + } + + const data = await fetchApi<{ success: boolean; message: string; model: string }>("/api/notifications/test-ai", { + method: "POST", + body: JSON.stringify({ + provider: config.ai_provider, + api_key: currentApiKey, + model: modelToUse, + ollama_url: config.ai_ollama_url, + openai_base_url: config.ai_openai_base_url, + }), + }) + setAiTestResult(data) + } catch (err) { + setAiTestResult({ success: false, message: String(err) }) + } finally { + setTestingAI(false) + setTimeout(() => setAiTestResult(null), 8000) + } + } + + const handleClearHistory = async () => { + try { + await fetchApi("/api/notifications/history", { method: "DELETE" }) + setHistory([]) + } catch { + // Ignore + } + } + + const toggleSecret = (key: string) => { + setShowSecrets(prev => ({ ...prev, [key]: !prev[key] })) + } + + if (loading) { + return ( + + +
+ + Notifications +
+
+ +
+
+
+ + + ) + } + + const activeChannels = Object.entries(config.channels).filter(([, ch]) => ch.enabled).length + + const handleEnable = async () => { + setSaving(true) + setWebhookSetup({ status: "running", fallback_commands: [], error: "" }) + try { + // 1) Save enabled=true + const newConfig = { ...config, enabled: true } + await fetchApi("/api/notifications/settings", { + method: "POST", + body: JSON.stringify(newConfig), + }) + setConfig(newConfig) + setOriginalConfig(newConfig) + + // 2) Auto-configure PVE webhook + try { + const setup = await fetchApi<{ + configured: boolean + secret?: string + fallback_commands?: string[] + error?: string + }>("/api/notifications/proxmox/setup-webhook", { method: "POST" }) + + if (setup.configured) { + setWebhookSetup({ status: "success", fallback_commands: [], error: "" }) + // Update secret in local config if one was generated + if (setup.secret) { + const updated = { ...newConfig, webhook_secret: setup.secret } + setConfig(updated) + setOriginalConfig(updated) + } + } else { + setWebhookSetup({ + status: "failed", + fallback_commands: setup.fallback_commands || [], + error: setup.error || "Unknown error", + }) + } + } catch { + setWebhookSetup({ + status: "failed", + fallback_commands: [], + error: "Could not reach setup endpoint", + }) + } + + setEditMode(true) + loadStatus() + } catch (err) { + console.error("Failed to enable notifications:", err) + setWebhookSetup({ status: "idle", fallback_commands: [], error: "" }) + } finally { + setSaving(false) + } + } + + // ── Disabled state: show activation card ── + if (!config.enabled && !editMode) { + return ( + + +
+ + Notifications + + Disabled + +
+ + Get real-time alerts about your Proxmox environment via Telegram, Discord, Gotify, or Email. + +
+ +
+
+
+ +
+

Enable notification service

+

+ Monitor system health, VM/CT events, backups, security alerts, and cluster status. + PVE webhook integration is configured automatically. +

+
+
+
+ +
+ + {/* Webhook setup result */} + {webhookSetup.status === "success" && ( +
+ +

+ PVE webhook configured automatically. Proxmox will send notifications to ProxMenux. +

+
+ )} + {webhookSetup.status === "failed" && ( +
+
+ +
+

+ Automatic PVE configuration failed: {webhookSetup.error} +

+

+ Notifications are enabled. Run the commands below on the PVE host to complete webhook setup. +

+
+
+ {webhookSetup.fallback_commands.length > 0 && ( +
+{webhookSetup.fallback_commands.join('\n')}
+                    
+ )} +
+ )} +
+ + +
+
+
+ ) + } + + return ( + <> + + +
+
+ + Notifications + {config.enabled && ( + + Active + + )} +
+
+ {saved && ( + + + Saved + + )} + {editMode ? ( + <> + + + + ) : ( + + )} +
+
+ + Configure notification channels and event filters. Receive alerts via Telegram, Gotify, Discord, or Email. + +
+ + + {/* ── Service Status ── */} + {status && ( +
+
+
+ + {status.running ? "Service running" : "Service stopped"} + + {status.total_sent_24h > 0 && ( + + {status.total_sent_24h} sent in last 24h + + )} +
+ {activeChannels > 0 && ( + + {activeChannels} channel{activeChannels > 1 ? "s" : ""} + + )} +
+ )} + + {/* ── Enable/Disable ── */} +
+
+ {config.enabled ? ( + + ) : ( + + )} +
+ Enable Notifications +

Activate the notification service

+
+
+ +
+ + {config.enabled && ( + <> + {/* ── Channel Configuration ── */} +
+
+ + Channels +
+ +
+ + + + Telegram + + + Gotify + + + Discord + + + Email + + + + {/* Telegram */} + +
+
+ + +
+ +
+ {config.channels.telegram?.enabled && ( + <> +
+ +
+ updateChannel("telegram", "bot_token", e.target.value)} + disabled={!editMode} + /> + +
+
+
+ + updateChannel("telegram", "chat_id", e.target.value)} + disabled={!editMode} + /> +
+
+ + updateChannel("telegram", "topic_id", e.target.value)} + disabled={!editMode} + /> +

For supergroups with topics enabled. Leave empty for regular chats.

+
+ {/* Message format */} +
+
+ +

Enrich notifications with contextual emojis and icons

+
+ +
+ {renderChannelCategories("telegram")} + {/* Send Test */} +
+ +
+ + )} +
+ + {/* Gotify */} + +
+ + +
+ {config.channels.gotify?.enabled && ( + <> +
+ + updateChannel("gotify", "url", e.target.value)} + disabled={!editMode} + /> +
+
+ +
+ updateChannel("gotify", "token", e.target.value)} + disabled={!editMode} + /> + +
+
+ {/* Message format */} +
+
+ +

Enrich notifications with contextual emojis and icons

+
+ +
+ {renderChannelCategories("gotify")} + {/* Send Test */} +
+ +
+ + )} +
+ + {/* Discord */} + +
+ + +
+ {config.channels.discord?.enabled && ( + <> +
+ +
+ updateChannel("discord", "webhook_url", e.target.value)} + disabled={!editMode} + /> + +
+
+ {/* Message format */} +
+
+ +

Enrich notifications with contextual emojis and icons

+
+ +
+ {renderChannelCategories("discord")} + {/* Send Test */} +
+ +
+ + )} +
+ + {/* Email */} + +
+ + +
+ {config.channels.email?.enabled && ( + <> +
+
+ + updateChannel("email", "host", e.target.value)} + disabled={!editMode} + /> +
+
+ + updateChannel("email", "port", e.target.value)} + disabled={!editMode} + /> +
+
+
+ + +
+
+
+ + updateChannel("email", "username", e.target.value)} + disabled={!editMode} + /> +
+
+ +
+ updateChannel("email", "password", e.target.value)} + disabled={!editMode} + /> + +
+
+
+
+ + updateChannel("email", "from_address", e.target.value)} + disabled={!editMode} + /> +
+
+ + updateChannel("email", "to_addresses", e.target.value)} + disabled={!editMode} + /> +
+
+ + updateChannel("email", "subject_prefix", e.target.value)} + disabled={!editMode} + /> +
+
+ +

+ Leave SMTP Host empty to use local sendmail (must be installed on the server). + For Gmail, use an App Password instead of your account password. +

+
+ {renderChannelCategories("email")} + {/* Send Test */} +
+ +
+ + )} +
+
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
{/* close bordered channel container */} +
+ + {/* ── Display Name ── */} +
+
+ + +
+ updateConfig(p => ({ ...p, hostname: e.target.value }))} + disabled={!editMode} + readOnly={!editMode} + /> +

+ Name shown in notifications. Edit to customize, or leave empty to use the system hostname. +

+
+ + {/* ── Advanced: AI Enhancement ── */} +
+
+ + {showAdvanced && ( +
+ {editMode ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ +{showAdvanced && ( +
+
+
+ +
+ AI-Enhanced Messages +

Use AI to generate contextual notification messages

+
+
+ +
+ + {config.ai_enabled && ( + <> + {/* Provider + Info button */} +
+
+ + + +
+ +
+ + {/* Ollama URL (conditional) */} + {config.ai_provider === "ollama" && ( +
+ + updateConfig(p => ({ ...p, ai_ollama_url: e.target.value }))} + disabled={!editMode} + /> +
+ )} + + {/* Custom Base URL for OpenAI-compatible APIs */} + {config.ai_provider === "openai" && ( +
+
+ + (optional) +
+ updateConfig(p => ({ ...p, ai_openai_base_url: e.target.value }))} + disabled={!editMode} + /> +

+ For OpenAI-compatible APIs: BytePlus, LocalAI, LM Studio, vLLM, etc. +

+
+ )} + + {/* API Key (not shown for Ollama) */} + {config.ai_provider !== "ollama" && ( +
+ +
+ updateConfig(p => ({ + ...p, + ai_api_keys: { + ...p.ai_api_keys, + [p.ai_provider]: e.target.value + } + }))} + disabled={!editMode} + /> + +
+
+ )} + + {/* Model - selector with Load button for all providers */} +
+
+ + +
+
+ + +
+ {providerModels.length > 0 && ( +

{providerModels.length} models available

+ )} +
+ + {/* Prompt Mode section */} +
+
+ + +
+ +
+ + {/* Default mode options: Language and Detail Level per Channel */} + {(config.ai_prompt_mode || "default") === "default" && ( +
+ {/* Language selector - only for default mode */} +
+
+ + +
+ +
+ + {/* Detail Level per Channel */} +
+ +
+ {CHANNEL_TYPES.map(ch => ( +
+ {ch} + +
+ ))} +
+
+ +

+ AI translates and formats notifications to your selected language. Each channel can have different detail levels. +

+
+
+ + {/* Experimental: AI Suggestions toggle */} +
+
+ +
+
+ AI Suggestions + BETA +
+

+ Allow AI to add brief troubleshooting tips based on log context +

+
+
+ +
+
+ )} + + {/* Custom mode: Editable prompt textarea */} + {config.ai_prompt_mode === "custom" && ( +
+
+
+ +
+ {!editingCustomPrompt ? ( + + ) : ( + <> + + + + )} +
+
+