diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage b/AppImage/ProxMenux-1.2.1.1-beta.AppImage
new file mode 100755
index 00000000..8e23f573
Binary files /dev/null and b/AppImage/ProxMenux-1.2.1.1-beta.AppImage differ
diff --git a/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256 b/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256
new file mode 100644
index 00000000..065a3188
--- /dev/null
+++ b/AppImage/ProxMenux-1.2.1.1-beta.AppImage.sha256
@@ -0,0 +1 @@
+774332ee537b6c24caadc0a2f8ad5c8952f4a07d7c875ec04e6a310383371c09 /tmp/ProxMenux-1.2.1.1-beta.AppImage
diff --git a/AppImage/components/about.tsx b/AppImage/components/about.tsx
new file mode 100644
index 00000000..630e48b7
--- /dev/null
+++ b/AppImage/components/about.tsx
@@ -0,0 +1,223 @@
+"use client"
+
+import Image from "next/image"
+import {
+ Github,
+ Heart,
+ BookOpen,
+ MessageSquare,
+ Bug,
+ Sparkles,
+ Scale,
+ ExternalLink,
+} from "lucide-react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
+import { APP_VERSION } from "./release-notes-modal"
+
+// Issue #191: a dedicated About tab. Centralises project metadata
+// (version, license, author) and every external link the project
+// already exposes — GitHub, docs, donation. Replaces the lone
+// "Support and contribute to the project" footer link with a proper
+// information surface that's easy to extend with new social channels
+// without re-cluttering the dashboard footer.
+
+interface LinkRow {
+ label: string
+ description: string
+ href: string
+ Icon: React.ComponentType<{ className?: string }>
+ accent?: keyof typeof ACCENT_CLASSES
+}
+
+// Tailwind only emits classes that appear as literal strings in the
+// source. A dynamic `bg-${accent}/10` template does not survive the
+// purge step, so each accent maps to a fully-spelled class pair below.
+const ACCENT_CLASSES = {
+ gray: "bg-gray-500/10 text-gray-400",
+ blue: "bg-blue-500/10 text-blue-500",
+ purple: "bg-purple-500/10 text-purple-400",
+ red: "bg-red-500/10 text-red-500",
+ pink: "bg-pink-500/10 text-pink-500",
+} as const
+
+const PROJECT_LINKS: LinkRow[] = [
+ {
+ label: "GitHub repository",
+ description: "Source code, releases and issue tracker.",
+ href: "https://github.com/MacRimi/ProxMenux",
+ Icon: Github,
+ accent: "gray",
+ },
+ {
+ label: "Documentation",
+ description: "Full user guide for ProxMenux and the Monitor.",
+ href: "https://proxmenux.com",
+ Icon: BookOpen,
+ accent: "blue",
+ },
+ {
+ label: "Discussions",
+ description: "Ask questions, share custom AI prompts, swap ideas.",
+ href: "https://github.com/MacRimi/ProxMenux/discussions",
+ Icon: MessageSquare,
+ accent: "purple",
+ },
+ {
+ label: "Report a bug or request a feature",
+ description: "Open an issue on GitHub — bugs, ideas, regressions.",
+ href: "https://github.com/MacRimi/ProxMenux/issues",
+ Icon: Bug,
+ accent: "red",
+ },
+]
+
+const SUPPORT_LINKS: LinkRow[] = [
+ {
+ label: "Support the project on Ko-fi",
+ description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
+ href: "https://ko-fi.com/macrimi",
+ Icon: Heart,
+ accent: "pink",
+ },
+]
+
+function LinkCard({ row }: { row: LinkRow }) {
+ const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
+ // Style mirrors the PCI Devices cards in the Hardware tab: subtle
+ // translucent background by default, slightly lighter on hover, no
+ // accent-coloured borders or text colour changes — keeps the look
+ // consistent with the rest of the project.
+ return (
+
+
+
+
+
+
+ {row.label}
+
+
+
{row.description}
+
+
+ )
+}
+
+export function About() {
+ return (
+
+ {/* Hero — logo, name, version, one-line description. */}
+
+
+
+
+
+
+
+
+ ProxMenux Monitor
+
+
+ A web dashboard and management layer for Proxmox VE — health monitoring,
+ notifications, terminal, optimization tracker and more, packaged as a single
+ AppImage.
+
+
+
+
+ v{APP_VERSION}
+
+ {/* Changelog goes to the web — the in-app modal version
+ duplicated content and lacked a close affordance on
+ some viewports, forcing a page refresh. The web
+ changelog is canonical and auto-syncs with releases. */}
+
+ Changelog
+
+
+
+
+
+
+
+
+ {/* Project links — GitHub, docs, discussions, bug tracker. */}
+
+
+
+
+ Project
+
+ Repository, documentation and community channels.
+
+
+
+ {PROJECT_LINKS.map(row => (
+
+ ))}
+
+
+
+
+ {/* Support + License combined — donation link and licensing
+ info in one card. The previous layout had a separate "Author"
+ block that has been removed by request. */}
+
+
+
+
+ Support & License
+
+
+ ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
+ you, a one-off contribution helps keep it that way.
+
+
+
+
+
+
+
+ )
+}
diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx
index ed336c8f..86e01b29 100644
--- a/AppImage/components/auth-setup.tsx
+++ b/AppImage/components/auth-setup.tsx
@@ -58,24 +58,20 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setError("")
try {
- console.log("[v0] Skipping authentication setup...")
const response = await fetch(getApiUrl("/api/auth/skip"), {
method: "POST",
headers: { "Content-Type": "application/json" },
})
const data = await response.json()
- console.log("[v0] Auth skip response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to skip authentication")
}
if (data.auth_declined) {
- console.log("[v0] Authentication skipped successfully - APIs should be accessible without token")
}
- console.log("[v0] Authentication skipped successfully")
localStorage.setItem("proxmenux-auth-declined", "true")
localStorage.removeItem("proxmenux-auth-token") // Remove any old token
setOpen(false)
@@ -109,7 +105,6 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setLoading(true)
try {
- console.log("[v0] Setting up authentication...")
const response = await fetch(getApiUrl("/api/auth/setup"), {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -120,7 +115,6 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
})
const data = await response.json()
- console.log("[v0] Auth setup response:", data)
if (!response.ok) {
throw new Error(data.error || "Failed to setup authentication")
@@ -129,7 +123,6 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
if (data.token) {
localStorage.setItem("proxmenux-auth-token", data.token)
localStorage.removeItem("proxmenux-auth-declined")
- console.log("[v0] Authentication setup successful")
}
setOpen(false)
diff --git a/AppImage/components/disk-temperature-card.tsx b/AppImage/components/disk-temperature-card.tsx
new file mode 100644
index 00000000..b892f0cb
--- /dev/null
+++ b/AppImage/components/disk-temperature-card.tsx
@@ -0,0 +1,161 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { Thermometer } from "lucide-react"
+import { Badge } from "./ui/badge"
+import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts"
+import { fetchApi } from "@/lib/api-config"
+import { useDiskTempThresholds } from "@/lib/health-thresholds"
+
+interface TempPoint {
+ timestamp: number
+ value: number
+}
+
+interface DiskTemperatureCardProps {
+ diskName: string
+ liveTemperature: number
+ /** Disk class — "HDD" | "SSD" | "NVMe" | "SAS". Drives the threshold colors. */
+ diskType: string
+ /** Click handler — opens the full timeframe-selector modal as drill-down. */
+ onOpenDetail?: () => void
+}
+
+// Disk-temperature thresholds come from the user-configurable backend
+// (lib/health-thresholds.ts). The classifier here takes the resolved
+// pair so the consumer can read it from the hook once per render.
+function statusFor(temp: number, t: { warn: number; hot: number }) {
+ if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
+ if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
+ if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
+ return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
+}
+
+const MiniTooltip = ({ active, payload }: any) => {
+ if (active && payload && payload.length) {
+ const ts = payload[0].payload?.timestamp
+ const date = ts ? new Date(ts * 1000) : null
+ return (
+
+ {date && (
+
+ {date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+
+ )}
+
{payload[0].value}°C
+
+ )
+ }
+ return null
+}
+
+export function DiskTemperatureCard({
+ diskName,
+ liveTemperature,
+ diskType,
+ onOpenDetail,
+}: DiskTemperatureCardProps) {
+ const [data, setData] = useState([])
+ const [loading, setLoading] = useState(true)
+ const cancelled = useRef(false)
+
+ useEffect(() => {
+ cancelled.current = false
+ const fetchHistory = async () => {
+ setLoading(true)
+ try {
+ const result = await fetchApi<{ data: TempPoint[] }>(
+ `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=hour`,
+ )
+ if (cancelled.current) return
+ setData(result?.data || [])
+ } catch {
+ if (!cancelled.current) setData([])
+ } finally {
+ if (!cancelled.current) setLoading(false)
+ }
+ }
+ fetchHistory()
+ // Refresh once a minute so the inline chart tracks the collector
+ // without needing the user to reopen the modal.
+ const id = setInterval(fetchHistory, 60_000)
+ return () => {
+ cancelled.current = true
+ clearInterval(id)
+ }
+ }, [diskName])
+
+ const allThresholds = useDiskTempThresholds()
+ const dt = (() => {
+ const t = (diskType || "").toUpperCase()
+ if (t === "HDD") return allThresholds.HDD
+ if (t === "NVME") return allThresholds.NVMe
+ if (t === "SAS") return allThresholds.SAS
+ return allThresholds.SSD
+ })()
+ const status = statusFor(liveTemperature, dt)
+ const lineColor = status.color
+ const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
+ const samples = data.length
+
+ const interactive = !!onOpenDetail
+ const Wrapper: any = interactive ? "button" : "div"
+
+ return (
+
+
+
+
Temperature
+
+ {tempDisplay}
+
+
+
+
+
+ {status.label}
+
+
+
+
+
+ {loading ? (
+
+ ) : samples < 2 ? (
+
+ Collecting samples — chart populates after ~2 minutes
+
+ ) : (
+
+
+
+
+
+
+
+
+ } cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
+
+
+
+ )}
+
+
+ )
+}
diff --git a/AppImage/components/disk-temperature-detail-modal.tsx b/AppImage/components/disk-temperature-detail-modal.tsx
new file mode 100644
index 00000000..0d2dba52
--- /dev/null
+++ b/AppImage/components/disk-temperature-detail-modal.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
+import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
+import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
+import { useIsMobile } from "../hooks/use-mobile"
+import { fetchApi } from "@/lib/api-config"
+import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
+
+const TIMEFRAME_OPTIONS = [
+ { value: "hour", label: "1 Hour" },
+ { value: "day", label: "24 Hours" },
+ { value: "week", label: "7 Days" },
+ { value: "month", label: "30 Days" },
+]
+
+interface TempHistoryPoint {
+ timestamp: number
+ value: number
+ min?: number
+ max?: number
+}
+
+interface TempStats {
+ min: number
+ max: number
+ avg: number
+ current: number
+}
+
+interface DiskTemperatureDetailModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ diskName: string
+ diskModel?: string
+ liveTemperature?: number
+ diskType?: "HDD" | "SSD" | "NVMe" | "SAS" | string
+}
+
+const CustomTooltip = ({ active, payload, label }: any) => {
+ if (active && payload && payload.length) {
+ return (
+
+
{label}
+
+ {payload.map((entry: any, index: number) => (
+
+
+
{entry.name}:
+
{entry.value}°C
+
+ ))}
+
+
+ )
+ }
+ return null
+}
+
+// Per-disk-class thresholds come from the user-configurable backend
+// (lib/health-thresholds.ts), so the chart line color stays in sync
+// with whatever the user sets in Settings → Health Monitor Thresholds.
+function colorFor(temp: number, t: DiskTempThreshold): string {
+ if (temp >= t.hot) return "#ef4444"
+ if (temp >= t.warn) return "#f59e0b"
+ return "#22c55e"
+}
+
+function statusInfoFor(temp: number, t: DiskTempThreshold) {
+ if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
+ if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
+ if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
+ return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
+}
+
+export function DiskTemperatureDetailModal({
+ open,
+ onOpenChange,
+ diskName,
+ diskModel,
+ liveTemperature,
+ diskType,
+}: DiskTemperatureDetailModalProps) {
+ const [timeframe, setTimeframe] = useState("day")
+ const [data, setData] = useState([])
+ const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 })
+ const [loading, setLoading] = useState(true)
+ const isMobile = useIsMobile()
+
+ useEffect(() => {
+ if (open && diskName) {
+ fetchHistory()
+ }
+ }, [open, timeframe, diskName])
+
+ const fetchHistory = async () => {
+ setLoading(true)
+ try {
+ const result = await fetchApi<{ data: TempHistoryPoint[]; stats: TempStats }>(
+ `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=${timeframe}`,
+ )
+ if (result && result.data) {
+ setData(result.data)
+ setStats(result.stats)
+ } else {
+ setData([])
+ setStats({ min: 0, max: 0, avg: 0, current: 0 })
+ }
+ } catch (err) {
+ console.error("[ProxMenux] Failed to fetch disk temperature history:", err)
+ setData([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatTime = (timestamp: number) => {
+ const date = new Date(timestamp * 1000)
+ if (timeframe === "hour" || timeframe === "day") {
+ return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
+ }
+ return date.toLocaleDateString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })
+ }
+
+ const chartData = data.map((d) => ({ ...d, time: formatTime(d.timestamp) }))
+
+ const currentTemp = liveTemperature && liveTemperature > 0 ? Math.round(liveTemperature * 10) / 10 : stats.current
+ const allThresholds = useDiskTempThresholds()
+ const dt: DiskTempThreshold = (() => {
+ const t = (diskType || "").toUpperCase()
+ if (t === "HDD") return allThresholds.HDD
+ if (t === "NVME") return allThresholds.NVMe
+ if (t === "SAS") return allThresholds.SAS
+ return allThresholds.SSD
+ })()
+ const chartColor = colorFor(currentTemp, dt)
+ const currentStatus = statusInfoFor(currentTemp, dt)
+
+ const values = data.map((d) => d.value)
+ const yMin = values.length > 0 ? Math.max(0, Math.floor(Math.min(...values) - 3)) : 0
+ const yMax = values.length > 0 ? Math.ceil(Math.max(...values) + 3) : 100
+
+ return (
+
+
+
+ {/*
+ Header layout mirrors temperature-detail-modal exactly so the
+ mobile breakpoints behave the same. Earlier we tried to inline
+ the model name in the DialogTitle, but the long WD/Samsung
+ strings broke `truncate` and pushed the dialog past the
+ viewport — clipping the timeframe selector and the right two
+ stat cards. Keeping the title short and parking the model in
+ a second line (DialogDescription) lets the standard mobile
+ grid render correctly.
+ */}
+
+
+
+ /dev/{diskName}
+
+
+
+
+
+
+ {TIMEFRAME_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ {diskModel && (
+ {diskModel}
+ )}
+
+
+
+
+
Current
+
{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}
+
+
+
+ Min
+
+
{stats.min}°C
+
+
+
+ Avg
+
+
{stats.avg}°C
+
+
+
+ Max
+
+
{stats.max}°C
+
+
+
+
+ {loading ? (
+
+ ) : chartData.length === 0 ? (
+
+
+
+
No temperature data yet for this disk
+
Samples are collected every 60 seconds
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ `${v}°`}
+ width={isMobile ? 40 : 45}
+ />
+ } />
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx
index 21959c37..a7544434 100644
--- a/AppImage/components/hardware.tsx
+++ b/AppImage/components/hardware.tsx
@@ -258,7 +258,6 @@ export default function Hardware() {
useEffect(() => {
if (hardwareData?.storage_devices) {
- console.log("[v0] Storage devices data from backend:", hardwareData.storage_devices)
hardwareData.storage_devices.forEach((device) => {
if (device.name.startsWith("nvme")) {
console.log(`[v0] NVMe device ${device.name}:`, {
@@ -272,6 +271,50 @@ export default function Hardware() {
}
}, [hardwareData])
+ const [managedInstalls, setManagedInstalls] = useState>([])
+ useEffect(() => {
+ let cancelled = false
+ fetchApi<{ success: boolean; items: any[] }>("/api/managed-installs")
+ .then((res) => {
+ if (cancelled) return
+ if (res?.success && Array.isArray(res.items)) {
+ setManagedInstalls(res.items)
+ }
+ })
+ .catch(() => {})
+ return () => { cancelled = true }
+ }, [])
+ const nvidiaInstall = managedInstalls.find((it) => it.type === "nvidia_xfree86")
+
+ const formatLastChecked = (iso?: string | null): string => {
+ if (!iso) return "never"
+ const d = new Date(iso)
+ if (isNaN(d.getTime())) return "unknown"
+ const now = Date.now()
+ const ageMs = now - d.getTime()
+ const sameDay = new Date(now).toDateString() === d.toDateString()
+ const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
+ const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
+ if (sameDay) return time
+ if (yesterday) return `yesterday ${time}`
+ if (ageMs < 7 * 86_400_000) {
+ return d.toLocaleDateString([], { weekday: "short" }) + " " + time
+ }
+ return d.toLocaleDateString([], { month: "short", day: "numeric" })
+ }
+
const [selectedGPU, setSelectedGPU] = useState(null)
const [realtimeGPUData, setRealtimeGPUData] = useState(null)
const [detailsLoading, setDetailsLoading] = useState(false)
@@ -381,17 +424,14 @@ export default function Hardware() {
}
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)
}
@@ -935,8 +975,38 @@ return (
{gpu.pci_kernel_module}
)}
+
+ {gpu.vendor?.toLowerCase().includes("nvidia") &&
+ nvidiaInstall?.current_version &&
+ nvidiaInstall.update_check?.last_check && (
+
+ {nvidiaInstall.update_check.available ? (
+ <>
+
+ Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "}
+
+ NVIDIA driver v{nvidiaInstall.update_check.latest} available
+
+
+ {nvidiaInstall.menu_label && (
+
+ Reinstall via ProxMenux post-install: {nvidiaInstall.menu_label}
+
+ )}
+ >
+ ) : (
+
+ Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)}
+ {` · NVIDIA driver v${nvidiaInstall.current_version}`}
+ {" · "}
+ No updates available
+
+ )}
+
+ )}
+
{/* GPU Switch Mode Indicator */}
{getGpuSwitchMode(gpu) !== "unknown" && (
@@ -2848,7 +2918,6 @@ return (
mutateStatic()
}}
onComplete={(success) => {
- console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed")
if (success) {
mutateStatic()
}
diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx
new file mode 100644
index 00000000..5ebd6e77
--- /dev/null
+++ b/AppImage/components/health-thresholds.tsx
@@ -0,0 +1,576 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
+import { Input } from "./ui/input"
+import {
+ SlidersHorizontal,
+ Cpu,
+ MemoryStick,
+ HardDrive,
+ Server,
+ Thermometer,
+ Settings2,
+ Check,
+ Loader2,
+ RotateCcw,
+ AlertCircle,
+ FolderOpen,
+ Database,
+ Waves,
+} from "lucide-react"
+import { getApiUrl, getAuthToken } from "../lib/api-config"
+
+// Local fetch wrapper that *preserves* the JSON body on non-2xx
+// responses so we can surface backend validation messages
+// (e.g. "critical must be >= warning") to the user. The shared
+// `fetchApi` throws a generic "API request failed: 400" on any
+// non-OK response, eating the body.
+async function fetchJson
(endpoint: string, init?: RequestInit): Promise {
+ const token = getAuthToken()
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...((init?.headers as Record) || {}),
+ }
+ if (token) headers["Authorization"] = `Bearer ${token}`
+ const res = await fetch(getApiUrl(endpoint), {
+ ...init,
+ headers,
+ cache: "no-store",
+ })
+ let data: any = null
+ try {
+ data = await res.json()
+ } catch {
+ // empty body — fall through with raw status
+ }
+ if (!res.ok) {
+ if (res.status === 401 && typeof window !== "undefined") {
+ try {
+ localStorage.removeItem("proxmenux-auth-token")
+ } catch {}
+ const path = window.location.pathname
+ if (!path.startsWith("/auth") && !path.startsWith("/login")) {
+ window.location.assign("/")
+ }
+ }
+ const msg =
+ (data && (data.message || data.error)) ||
+ `${res.status} ${res.statusText}`
+ throw new Error(msg)
+ }
+ return data as T
+}
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+//
+// The backend returns a tree of leaves. Each leaf carries the metadata
+// the UI needs to render an input + the recommended/customised flags.
+// We mirror the shape rather than hand-coding it to keep the contract
+// in one place — the backend is the source of truth.
+interface ThresholdLeaf {
+ value: number
+ recommended: number
+ customised: boolean
+ unit: string
+ min: number
+ max: number
+ step: number
+}
+
+interface ThresholdsTree {
+ cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf }
+ host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ disk_temperature: {
+ hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ ssd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ nvme: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ sas: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ }
+ // Phase 3 additions
+ lxc_mount: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ pve_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+ zfs_pool: { warning: ThresholdLeaf; critical: ThresholdLeaf }
+}
+
+// Pending edits: { "section/key" : "76" } — kept as raw strings while
+// the user types so partial input ("8" mid-type) doesn't fail the
+// numeric coercion. Coerced + validated on Save.
+type PendingEdits = Record
+
+// ─── Section descriptors ─────────────────────────────────────────────────────
+//
+// Drives both the render order and the labels. Keeping it data-only
+// means adding a new section later (Phase 4) is one entry, not a JSX
+// surgery.
+interface SectionField {
+ // Path in the thresholds tree, e.g. ["cpu", "warning"] or
+ // ["disk_temperature", "nvme", "critical"].
+ path: string[]
+ label: string
+}
+
+interface SectionDef {
+ id: string // Backend section key — used by the reset endpoint
+ title: string
+ icon: React.ComponentType<{ className?: string }>
+ description?: string
+ fields: SectionField[]
+ // For tabular sections (disk temperature) we group by sub-key. When
+ // present, fields are rendered in a 2-column grid (warning, critical)
+ // labelled by sub-key (HDD / SSD / NVMe / SAS).
+ rowGroups?: Array<{ subKey: string; label: string }>
+}
+
+// Order: compute → heat → storage capacity. Reading top-to-bottom
+// flows naturally with no domain jumps:
+// • Compute (CPU usage, RAM/Swap)
+// • Heat (CPU temp, then disk temp — both °C)
+// • Storage capacity (host → LXC rootfs → LXC mounts → PVE → ZFS,
+// i.e. concrete to abstract)
+const SECTIONS: SectionDef[] = [
+ // ── Compute ─────────────────────────────────────────────────────
+ {
+ id: "cpu",
+ title: "CPU usage",
+ icon: Cpu,
+ fields: [
+ { path: ["cpu", "warning"], label: "Warning" },
+ { path: ["cpu", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "memory",
+ title: "Memory & Swap",
+ icon: MemoryStick,
+ fields: [
+ { path: ["memory", "warning"], label: "Memory warning" },
+ { path: ["memory", "critical"], label: "Memory critical" },
+ { path: ["memory", "swap_critical"], label: "Swap critical" },
+ ],
+ },
+ // ── Heat ────────────────────────────────────────────────────────
+ {
+ id: "cpu_temperature",
+ title: "CPU temperature",
+ icon: Thermometer,
+ fields: [
+ { path: ["cpu_temperature", "warning"], label: "Warning" },
+ { path: ["cpu_temperature", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "disk_temperature",
+ title: "Disk temperature",
+ icon: Thermometer,
+ description:
+ "Per-class thresholds. Same units (°C) — different defaults because each class tolerates a different envelope.",
+ rowGroups: [
+ { subKey: "hdd", label: "HDD" },
+ { subKey: "ssd", label: "SSD" },
+ { subKey: "nvme", label: "NVMe" },
+ { subKey: "sas", label: "SAS" },
+ ],
+ // For row-group sections, `fields` is unused — we generate per-row
+ // path lookups from the rowGroups + a hardcoded ["warning","critical"].
+ fields: [],
+ },
+ // ── Storage capacity ────────────────────────────────────────────
+ {
+ id: "host_storage",
+ title: "Disk space — host",
+ icon: HardDrive,
+ description: "Applies to / and every mountpoint under /var/lib/vz, /mnt/* etc.",
+ fields: [
+ { path: ["host_storage", "warning"], label: "Warning" },
+ { path: ["host_storage", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "lxc_rootfs",
+ title: "Disk space — LXC rootfs",
+ icon: Server,
+ description: "Per-container root disk, evaluated against the rootfs size from PVE.",
+ fields: [
+ { path: ["lxc_rootfs", "warning"], label: "Warning" },
+ { path: ["lxc_rootfs", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "lxc_mount",
+ title: "LXC mount points",
+ icon: FolderOpen,
+ description:
+ "Capacity of mountpoints inside running CTs (mp0, mp1, NFS, bind mounts). Excludes the rootfs — that's covered above.",
+ fields: [
+ { path: ["lxc_mount", "warning"], label: "Warning" },
+ { path: ["lxc_mount", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "pve_storage",
+ title: "PVE storage capacity",
+ icon: Database,
+ description:
+ "Block-style PVE storages: LVM, LVM-thin, ZFS-pool, RBD/Ceph, PBS. Filesystem-style (dir/nfs/cifs) is already covered by host disk thresholds.",
+ fields: [
+ { path: ["pve_storage", "warning"], label: "Warning" },
+ { path: ["pve_storage", "critical"], label: "Critical" },
+ ],
+ },
+ {
+ id: "zfs_pool",
+ title: "ZFS pool capacity",
+ icon: Waves,
+ description:
+ "ZFS pools at the host level — independent of PVE registration so rpool and dedicated backup pools are also monitored.",
+ fields: [
+ { path: ["zfs_pool", "warning"], label: "Warning" },
+ { path: ["zfs_pool", "critical"], label: "Critical" },
+ ],
+ },
+]
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function getLeaf(tree: ThresholdsTree | null, path: string[]): ThresholdLeaf | null {
+ if (!tree) return null
+ let node: any = tree
+ for (const p of path) {
+ if (node == null || typeof node !== "object") return null
+ node = node[p]
+ }
+ return node as ThresholdLeaf | null
+}
+
+function pathKey(path: string[]): string {
+ return path.join("/")
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export function HealthThresholds() {
+ const [tree, setTree] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [editMode, setEditMode] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [savedFlash, setSavedFlash] = useState(false)
+ const [error, setError] = useState(null)
+ const [pending, setPending] = useState({})
+
+ // Load on mount + auto-refresh after each save
+ const fetchTree = async () => {
+ try {
+ setLoading(true)
+ const res = await fetchJson<{ success: boolean; thresholds: ThresholdsTree }>(
+ "/api/health/thresholds",
+ )
+ if (res?.success && res.thresholds) setTree(res.thresholds)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load thresholds")
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchTree()
+ }, [])
+
+ const hasPendingChanges = Object.keys(pending).length > 0
+
+ // Build the partial payload from pending. Any blank or unparseable
+ // entry is skipped — the backend will reject anything malformed
+ // anyway, but we want to fail fast on the UI side too.
+ const buildPayload = (): Record | null => {
+ const payload: Record = {}
+ for (const [key, raw] of Object.entries(pending)) {
+ const parts = key.split("/")
+ const trimmed = raw.trim()
+ if (trimmed === "") continue
+ const num = Number(trimmed)
+ if (!isFinite(num)) {
+ setError(`Invalid value for ${key}: must be a number`)
+ return null
+ }
+ // Walk into payload mirroring the path
+ let cur: any = payload
+ for (let i = 0; i < parts.length - 1; i++) {
+ cur[parts[i]] = cur[parts[i]] || {}
+ cur = cur[parts[i]]
+ }
+ cur[parts[parts.length - 1]] = num
+ }
+ return payload
+ }
+
+ const handleEdit = () => {
+ setEditMode(true)
+ setError(null)
+ }
+
+ const handleCancel = () => {
+ setEditMode(false)
+ setPending({})
+ setError(null)
+ }
+
+ const handleSave = async () => {
+ const payload = buildPayload()
+ if (payload === null) return
+ if (Object.keys(payload).length === 0) {
+ setEditMode(false)
+ return
+ }
+ try {
+ setSaving(true)
+ setError(null)
+ const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
+ "/api/health/thresholds",
+ { method: "PUT", body: JSON.stringify(payload) },
+ )
+ if (!data.success || !data.thresholds) {
+ setError(data.message || "Save failed")
+ return
+ }
+ setTree(data.thresholds)
+ setPending({})
+ setEditMode(false)
+ setSavedFlash(true)
+ setTimeout(() => setSavedFlash(false), 2000)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Network error while saving")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleResetSection = async (sectionId: string) => {
+ if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`))
+ return
+ try {
+ const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
+ `/api/health/thresholds/reset?section=${encodeURIComponent(sectionId)}`,
+ { method: "POST" },
+ )
+ if (!data.success || !data.thresholds) {
+ setError(data.message || "Reset failed")
+ return
+ }
+ setTree(data.thresholds)
+ // Drop any pending edits within this section so the UI stays
+ // consistent — the values were just reset on the server.
+ setPending((p) => {
+ const next: PendingEdits = {}
+ for (const [k, v] of Object.entries(p)) {
+ if (!k.startsWith(sectionId + "/")) next[k] = v
+ }
+ return next
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Network error while resetting")
+ }
+ }
+
+ const handleResetAll = async () => {
+ if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
+ try {
+ const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
+ "/api/health/thresholds/reset",
+ { method: "POST" },
+ )
+ if (!data.success || !data.thresholds) {
+ setError(data.message || "Reset failed")
+ return
+ }
+ setTree(data.thresholds)
+ setPending({})
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Network error while resetting")
+ }
+ }
+
+ const renderField = (path: string[], label: string) => {
+ const leaf = getLeaf(tree, path)
+ if (!leaf) return null
+ const key = pathKey(path)
+ const editingValue = pending[key] ?? String(leaf.value)
+ // Pick the badge palette from the leaf name so warning rows render
+ // amber and critical rows render red. `swap_critical` and any other
+ // *_critical key fall into the red bucket via the substring check.
+ const last = path[path.length - 1] || ""
+ const isCritical = last.toLowerCase().includes("critical")
+ const isWarning = last.toLowerCase().includes("warning")
+ const badgeClasses = isCritical
+ ? "bg-red-500/10 text-red-500 border-red-500/30"
+ : isWarning
+ ? "bg-amber-500/10 text-amber-500 border-amber-500/30"
+ : "bg-muted text-muted-foreground border-border"
+ return (
+
+
+
+ {label}
+
+
+
+ {leaf.recommended}
+ {leaf.unit}
+
+
+ setPending((p) => ({ ...p, [key]: e.target.value }))
+ }
+ className={`w-20 h-7 text-xs text-right tabular-nums ${
+ !editMode ? "opacity-70" : ""
+ } ${
+ leaf.customised && !(key in pending) ? "border-blue-500/40" : ""
+ }`}
+ />
+ {leaf.unit}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ Health Monitor Thresholds
+
+ {!loading && (
+
+ {savedFlash && (
+
+
+ Saved
+
+ )}
+ {editMode ? (
+ <>
+
+ Cancel
+
+
+ {saving ? (
+
+ ) : (
+
+ )}
+ Save
+
+ >
+ ) : (
+ <>
+
+
+ Reset all
+
+
+
+ Edit
+
+ >
+ )}
+
+ )}
+
+
+ The Health Monitor and notifications fire when these thresholds are crossed.
+ Recommended values are shown with their reference color (amber for warning,
+ red for critical); your edits override them. Leave a value unchanged to keep
+ the recommended.
+
+
+
+ {loading ? (
+
+
+
+ ) : !tree ? (
+ Failed to load thresholds.
+ ) : (
+
+ {error && (
+
+ )}
+
+ {SECTIONS.map((section) => {
+ const Icon = section.icon
+ return (
+
+
+
+
+
{section.title}
+
+ {!editMode && (
+
handleResetSection(section.id)}
+ title="Reset this section to recommended"
+ >
+
+
+ )}
+
+ {section.description && (
+
+ {section.description}
+
+ )}
+
+ {section.rowGroups
+ ? section.rowGroups.map((group) => (
+
+
+ {group.label}
+
+ {renderField([section.id, group.subKey, "warning"], "Warning")}
+ {renderField([section.id, group.subKey, "critical"], "Critical")}
+
+ ))
+ : section.fields.map((f) => renderField(f.path, f.label))}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx
index 7c2c387f..bbfcbb96 100644
--- a/AppImage/components/lxc-terminal-modal.tsx
+++ b/AppImage/components/lxc-terminal-modal.tsx
@@ -19,7 +19,10 @@ import {
Terminal,
Trash2,
X,
+ Copy,
+ Clipboard,
} from "lucide-react"
+import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
import {
DropdownMenu,
DropdownMenuContent,
@@ -33,6 +36,7 @@ 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"
+import { getTicketedWsUrl } from "@/lib/terminal-ws"
interface LxcTerminalModalProps {
open: boolean
@@ -161,9 +165,16 @@ export function LxcTerminalModal({
useEffect(() => {
if (!isOpen) return
+ // `cancelled` short-circuits the async init if the modal closes
+ // before the dynamic xterm import resolves. Without this, we'd
+ // construct a Terminal instance, attach it to a now-stale ref, and
+ // open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
+ // con `import("xterm")` sin cancelación.
+ let cancelled = false
+
// Small delay to ensure Dialog content is rendered
const initTimeout = setTimeout(() => {
- if (!terminalContainerRef.current) return
+ if (cancelled || !terminalContainerRef.current) return
initTerminal()
}, 100)
@@ -172,12 +183,13 @@ export function LxcTerminalModal({
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
])
+ if (cancelled) return
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
- fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
+ fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
fontSize: fontSize,
lineHeight: 1,
cursorBlink: true,
@@ -221,9 +233,11 @@ export function LxcTerminalModal({
termRef.current = term
fitAddonRef.current = fitAddon
- // Connect WebSocket to host terminal
+ // Connect WebSocket to host terminal. We append a single-use ticket
+ // (`?ticket=...`) which the backend consumes on handshake — see
+ // lib/terminal-ws.ts and AppImage/scripts/flask_terminal_routes.py.
const wsUrl = getWebSocketUrl()
- const ws = new WebSocket(wsUrl)
+ const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
wsRef.current = ws
// Reset state for new connection
@@ -252,11 +266,22 @@ export function LxcTerminalModal({
rows: term.rows,
}))
- // Auto-execute pct enter after connection is ready
+ // Auto-execute pct enter after connection is ready.
+ // The string is sent verbatim to the bash PTY, so a non-numeric
+ // `vmid` would land as shell input (e.g. `pct enter ; rm -rf /`).
+ // The prop is typed `number` but JSON / URL query injections can
+ // sneak strings in; validate as a defensive redundancy. Audit
+ // residual #lxc-terminal-vmid-injection.
setTimeout(() => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(`pct enter ${vmid}\r`)
+ if (ws.readyState !== WebSocket.OPEN) return
+ // Coerce + verify: must be a positive integer that round-trips
+ // through Number without losing fidelity.
+ const id = Number(vmid)
+ if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
+ term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m')
+ return
}
+ ws.send(`pct enter ${id}\r`)
}, 300)
}
@@ -302,13 +327,17 @@ export function LxcTerminalModal({
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/)
+ // Extract the host name from the prompt BEFORE pct enter (e.g., "root@amd").
+ // Charset widened to accept dotted FQDNs (`proxmox.lan`) and unicode
+ // letters/numbers (host names like `próxmox` or non-Latin scripts).
+ // The previous `[a-zA-Z0-9_-]` truncated the hostname and the
+ // "are we inside the LXC?" comparison then misfired.
+ const hostPromptMatch = cleanBuffer.match(/@([\p{L}\p{N}._-]+).*pct enter/u)
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*$/)
+ const promptMatch = afterPctEnter.match(/[@\[]([\p{L}\p{N}._-]+)[^\r\n]*[#$]\s*$/u)
if (promptMatch) {
const lxcHostname = promptMatch[1]
@@ -354,6 +383,7 @@ export function LxcTerminalModal({
}
return () => {
+ cancelled = true
clearTimeout(initTimeout)
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
@@ -435,6 +465,14 @@ export function LxcTerminalModal({
const sendEnter = useCallback(() => sendKey("\r"), [sendKey])
const sendCtrlC = useCallback(() => sendKey("\x03"), [sendKey]) // Ctrl+C
+ // Mobile clipboard helpers — see lib/terminal-clipboard.ts for the rationale.
+ const handleCopy = useCallback(async () => {
+ await copyTerminalSelection(termRef.current)
+ }, [])
+ const handlePaste = useCallback(async () => {
+ await pasteFromClipboard(sendKey)
+ }, [sendKey])
+
// Search effect - debounced search with cheat.sh
useEffect(() => {
const searchCheatSh = async (query: string) => {
@@ -634,7 +672,7 @@ export function LxcTerminalModal({
-
+
Control Sequences
sendKey("\x03")}>
@@ -649,6 +687,16 @@ export function LxcTerminalModal({
Ctrl+R
Search history
+
+ Clipboard
+ { void handleCopy() }}>
+
+ Copy selection
+
+ { void handlePaste() }}>
+
+ Paste
+
diff --git a/AppImage/components/network-traffic-chart.tsx b/AppImage/components/network-traffic-chart.tsx
index 456339f7..8c6ac350 100644
--- a/AppImage/components/network-traffic-chart.tsx
+++ b/AppImage/components/network-traffic-chart.tsx
@@ -110,7 +110,6 @@ export function NetworkTrafficChart({
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
- console.log("[v0] Fetching network metrics from:", apiPath)
const result = await fetchApi(apiPath)
diff --git a/AppImage/components/node-metrics-charts.tsx b/AppImage/components/node-metrics-charts.tsx
index 79bd77cc..30db761e 100644
--- a/AppImage/components/node-metrics-charts.tsx
+++ b/AppImage/components/node-metrics-charts.tsx
@@ -83,21 +83,16 @@ export function NodeMetricsCharts() {
const hasMemoryFree = data.some(d => d.memoryFree > 0)
useEffect(() => {
- console.log("[v0] NodeMetricsCharts component mounted")
fetchMetrics()
}, [timeframe])
const fetchMetrics = async () => {
- console.log("[v0] fetchMetrics called with timeframe:", timeframe)
setLoading(true)
setError(null)
try {
const result = await fetchApi(`/api/node/metrics?timeframe=${timeframe}`)
- console.log("[v0] Node metrics result:", result)
- console.log("[v0] Result keys:", Object.keys(result))
- console.log("[v0] Data array length:", result.data?.length || 0)
if (!result.data || !Array.isArray(result.data)) {
console.error("[v0] Invalid data format - data is not an array:", result)
@@ -111,13 +106,7 @@ export function NodeMetricsCharts() {
return
}
- console.log("[v0] First data point sample:", result.data[0])
- console.log("[v0] First data point loadavg field:", result.data[0]?.loadavg)
- console.log("[v0] loadavg type:", typeof result.data[0]?.loadavg)
- console.log("[v0] loadavg is array:", Array.isArray(result.data[0]?.loadavg))
if (result.data[0]?.loadavg) {
- console.log("[v0] loadavg length:", result.data[0].loadavg.length)
- console.log("[v0] loadavg[0]:", result.data[0].loadavg[0])
}
const transformedData = result.data.map((item: any) => {
@@ -175,7 +164,6 @@ export function NodeMetricsCharts() {
console.error("[v0] Error stack:", err.stack)
setError(err.message || "Error loading metrics")
} finally {
- console.log("[v0] fetchMetrics finally block - setting loading to false")
setLoading(false)
}
}
@@ -220,10 +208,8 @@ export function NodeMetricsCharts() {
)
}
- console.log("[v0] Render state - loading:", loading, "error:", error, "data length:", data.length)
if (loading) {
- console.log("[v0] Rendering loading state")
return (
@@ -245,7 +231,6 @@ export function NodeMetricsCharts() {
}
if (error) {
- console.log("[v0] Rendering error state:", error)
return (
@@ -269,7 +254,6 @@ export function NodeMetricsCharts() {
}
if (data.length === 0) {
- console.log("[v0] Rendering no data state")
return (
@@ -290,7 +274,6 @@ export function NodeMetricsCharts() {
)
}
- console.log("[v0] Rendering charts with", data.length, "data points")
return (
diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx
index c750b4bc..1bfc3899 100644
--- a/AppImage/components/notification-settings.tsx
+++ b/AppImage/components/notification-settings.tsx
@@ -16,7 +16,8 @@ import {
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
+ Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
+ Moon, Newspaper
} from "lucide-react"
interface ChannelConfig {
@@ -37,6 +38,13 @@ interface ChannelConfig {
from_address?: string
to_addresses?: string
subject_prefix?: string
+ // Quiet hours: skip below-CRITICAL events between [start, end) local time
+ quiet_enabled?: boolean
+ quiet_start?: string // "HH:MM"
+ quiet_end?: string // "HH:MM"
+ // Daily digest: buffer INFO events and ship one summary at digest_time
+ digest_enabled?: boolean
+ digest_time?: string // "HH:MM"
}
interface EventTypeInfo {
@@ -97,6 +105,44 @@ interface HistoryEntry {
error_message: string | null
}
+// Validation helpers for webhook/URL fields. The server still does the
+// authoritative validation (see notification_manager.validate_config). These
+// are defense-in-depth + immediate UX feedback so users notice typos / pasted
+// internal endpoints before they hit Save.
+const DISCORD_WEBHOOK_RE = /^https:\/\/(discord(app)?\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[\w-]+$/
+
+function validateDiscordWebhook(url: string): { error?: string } {
+ if (!url) return {}
+ if (!DISCORD_WEBHOOK_RE.test(url.trim())) {
+ return { error: "Must be a Discord webhook URL (https://discord.com/api/webhooks/
/)" }
+ }
+ return {}
+}
+
+function validateGotifyUrl(url: string): { error?: string; warning?: string } {
+ if (!url) return {}
+ let parsed: URL
+ try {
+ parsed = new URL(url.trim())
+ } catch {
+ return { error: "Not a valid URL" }
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return { error: `Unsupported scheme "${parsed.protocol}" — only http(s) is allowed` }
+ }
+ // Block the obvious SSRF target: the local PVE API. RFC1918 ranges remain
+ // allowed since self-hosted Gotify on a LAN is a normal deployment.
+ const host = parsed.hostname.toLowerCase()
+ const port = parsed.port
+ if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && (port === "8006" || port === "8007")) {
+ return { error: "Cannot point at the local PVE API (localhost:8006/8007)" }
+ }
+ if (host === "169.254.169.254") {
+ return { error: "Link-local metadata IP is not a valid Gotify endpoint" }
+ }
+ return {}
+}
+
const EVENT_CATEGORIES = [
{ key: "vm_ct", label: "VM / CT", desc: "Start, stop, crash, migration" },
{ key: "backup", label: "Backups", desc: "Backup start, complete, fail" },
@@ -276,6 +322,11 @@ export function NotificationSettings() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
+ // Save errors used to be silently swallowed — the user thought their
+ // tokens / API keys were persisted when in fact the POST had failed.
+ // Surface the failure as a banner so the user can retry. Audit residual
+ // #notification-settings-handleSave-silent-fail.
+ const [saveError, setSaveError] = useState(null)
const [testing, setTesting] = useState(null)
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
const [showHistory, setShowHistory] = useState(false)
@@ -411,6 +462,157 @@ export function NotificationSettings() {
}))
}
+ const formatHHMM = (raw: string | undefined, fallback: string): string => {
+ const v = (raw || fallback).match(/^(\d{1,2}):(\d{2})$/)
+ if (!v) return fallback
+ const hh = String(Math.min(23, Math.max(0, parseInt(v[1], 10)))).padStart(2, "0")
+ const mm = String(Math.min(59, Math.max(0, parseInt(v[2], 10)))).padStart(2, "0")
+ return `${hh}:${mm}`
+ }
+
+ const inQuietWindow = (start: string, end: string): boolean => {
+ if (start === end) return false
+ const now = new Date()
+ const cur = now.getHours() * 60 + now.getMinutes()
+ const [sh, sm] = start.split(":").map((x) => parseInt(x, 10))
+ const [eh, em] = end.split(":").map((x) => parseInt(x, 10))
+ const s = sh * 60 + sm
+ const e = eh * 60 + em
+ return s < e ? cur >= s && cur < e : cur >= s || cur < e
+ }
+
+ const renderQuietHours = (chName: string) => {
+ const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined
+ const enabled = !!ch?.quiet_enabled
+ const start = formatHHMM(ch?.quiet_start, "22:00")
+ const end = formatHHMM(ch?.quiet_end, "06:00")
+ const sameTime = start === end
+ const live = enabled && !sameTime && inQuietWindow(start, end)
+ return (
+
+
+
+
+
+ Quiet hours
+
+
+ During this window only CRITICAL events reach this channel.
+
+
+
{ if (editMode) updateChannel(chName, "quiet_enabled", !enabled) }}
+ >
+
+
+
+ {enabled && (
+ <>
+
+
+ {sameTime
+ ? "Set a different start and end time to activate."
+ : live
+ ? `Active right now — only CRITICAL events pass until ${end}.`
+ : `Inactive right now — will start at ${start}.`}
+
+ >
+ )}
+
+ )
+ }
+
+ const renderDailyDigest = (chName: string) => {
+ const ch = config.channels[chName as keyof typeof config.channels] as ChannelConfig | undefined
+ const enabled = !!ch?.digest_enabled
+ const time = formatHHMM(ch?.digest_time, "09:00")
+ let nextLabel = ""
+ if (enabled) {
+ const now = new Date()
+ const cur = now.getHours() * 60 + now.getMinutes()
+ const [hh, mm] = time.split(":").map((x) => parseInt(x, 10))
+ const target = hh * 60 + mm
+ const minsAway = target > cur ? target - cur : 24 * 60 - cur + target
+ const h = Math.floor(minsAway / 60)
+ const m = minsAway % 60
+ nextLabel = `Next digest in ${h}h ${m}m (at ${time}).`
+ }
+ return (
+
+
+
+
+
+ Daily digest of INFO events
+
+
+ All INFO events (backups OK, updates available, etc.) accumulate during the day and arrive once at this time as a single summary. CRITICAL and WARNING are never delayed.
+
+
+
{ if (editMode) updateChannel(chName, "digest_enabled", !enabled) }}
+ >
+
+
+
+ {enabled && (
+ <>
+
+ Send at
+ updateChannel(chName, "digest_time", e.target.value)}
+ disabled={!editMode}
+ className="h-7 text-xs font-mono"
+ />
+
+
{nextLabel}
+ >
+ )}
+
+ )
+ }
+
/** Reusable 10+1 category block rendered inside each channel tab. */
const renderChannelCategories = (chName: string) => {
const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} }
@@ -621,11 +823,12 @@ export function NotificationSettings() {
const handleSave = async () => {
setSaving(true)
+ setSaveError(null)
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" })
@@ -633,7 +836,7 @@ export function NotificationSettings() {
// Non-fatal: webhook cleanup failed but we still save settings
}
}
-
+
const payload = flattenConfig(config)
await fetchApi("/api/notifications/settings", {
method: "POST",
@@ -647,6 +850,8 @@ export function NotificationSettings() {
loadStatus()
} catch (err) {
console.error("Failed to save notification settings:", err)
+ const msg = err instanceof Error ? err.message : "Failed to save notification settings"
+ setSaveError(msg)
} finally {
setSaving(false)
}
@@ -977,6 +1182,14 @@ export function NotificationSettings() {
Saved
)}
+ {saveError && (
+
+ Save failed: {saveError}
+
+ )}
{editMode ? (
<>
{renderChannelCategories("telegram")}
+ {renderQuietHours("telegram")}
+ {renderDailyDigest("telegram")}
{/* Send Test */}
updateChannel("gotify", "url", e.target.value)}
disabled={!editMode}
/>
+ {(() => {
+ const v = validateGotifyUrl(config.channels.gotify?.url || "")
+ if (v.error) return {v.error}
+ if (v.warning) return {v.warning}
+ return null
+ })()}
App Token
@@ -1266,6 +1487,8 @@ export function NotificationSettings() {
{renderChannelCategories("gotify")}
+ {renderQuietHours("gotify")}
+ {renderDailyDigest("gotify")}
{/* Send Test */}
: }
+ {(() => {
+ const v = validateDiscordWebhook(config.channels.discord?.webhook_url || "")
+ return v.error ? {v.error}
: null
+ })()}
{/* Message format */}
@@ -1342,6 +1569,8 @@ export function NotificationSettings() {
{renderChannelCategories("discord")}
+ {renderQuietHours("discord")}
+ {renderDailyDigest("discord")}
{/* Send Test */}
{renderChannelCategories("email")}
+ {renderQuietHours("email")}
+ {renderDailyDigest("email")}
{/* Send Test */}
-
+ {/* Issue #191: 10 tabs after adding About. The grid wraps via
+ Tabs primitives so the extra column doesn't push the
+ triggers off-screen on common laptop widths. */}
+
Settings
+
+ About
+
@@ -738,6 +748,21 @@ export function ProxmoxDashboard() {
Settings
+ {
+ setActiveTab("about")
+ setMobileMenuOpen(false)
+ }}
+ className={`w-full justify-start gap-3 ${
+ activeTab === "about"
+ ? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
+ : ""
+ }`}
+ >
+
+ About
+
@@ -782,6 +807,10 @@ export function ProxmoxDashboard() {
+
+
+
+
diff --git a/AppImage/components/release-notes-modal.tsx b/AppImage/components/release-notes-modal.tsx
index 8a4d0456..c2ad9cbc 100644
--- a/AppImage/components/release-notes-modal.tsx
+++ b/AppImage/components/release-notes-modal.tsx
@@ -3,10 +3,10 @@
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
-import { X, Sparkles, Thermometer, Terminal, Activity, HardDrive, Bell, Shield, Globe, Cpu, Zap } from "lucide-react"
+import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
-const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
+const APP_VERSION = "1.2.1.1-beta" // Sync with AppImage/package.json
interface ReleaseNote {
date: string
@@ -18,6 +18,30 @@ interface ReleaseNote {
}
export const CHANGELOG: Record = {
+ "1.2.1.1-beta": {
+ date: "May 9, 2026",
+ changes: {
+ added: [
+ "Post-install function update detection - The Monitor now tracks installed ProxMenux optimizations (Log2Ram, Memory Settings, System Limits, Logrotate...) and notifies when a newer version of any of them is available, with one-click apply",
+ "Health Monitor Thresholds - Per-category warning and critical levels for CPU, memory, temperature, storage and more, configurable from Settings",
+ "NVIDIA driver update notifications - Kernel-aware detection of new compatible driver versions, surfaced in the Hardware tab and as notifications when a newer build is published upstream",
+ "Secure Gateway update flow - One-click Tailscale update from Settings with Last-checked / Installed / Latest indicators and notification when a new version is available",
+ "Helper-Scripts menu - Richer context and useful information for each entry, making it easier to know what every script does before running it",
+ ],
+ changed: [
+ "Disk temperature monitoring - Improved readings, smarter caching across SMART probes and a redesigned history modal that opens at 24h by default with min/avg/max statistics",
+ "VM and LXC modal - Expanded with additional information so a single panel covers the data you previously had to look up across multiple tabs",
+ "Page load - Faster first paint and lighter network usage on the Overview, Storage and Hardware tabs",
+ "Security improvements - Tighter authentication checks across notification, scripts and terminal endpoints, plus a more conservative default policy for new installs",
+ ],
+ fixed: [
+ "NVIDIA installer - The version menu now respects the running kernel compatibility window, only offering driver branches that won't fail to compile",
+ "NVIDIA installer on Alpine LXC - Container-side userspace install reworked so it succeeds on Alpine hosts, and free-space detection works reliably across all storage layouts",
+ "NVIDIA installer with NVENC patch - When the host has the NVENC patch applied, the version menu narrows to drivers supported by the patch so reinstalling never silently loses it",
+ "Webhook URL - PVE notification webhook now follows the active SSL state automatically, switching between http and https when you toggle HTTPS in the panel",
+ ],
+ },
+ },
"1.1.2-beta": {
date: "March 18, 2026",
changes: {
@@ -82,36 +106,36 @@ export const CHANGELOG: Record = {
const CURRENT_VERSION_FEATURES = [
{
- icon: ,
- text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
+ icon: ,
+ text: "Post-install function update detection - The Monitor tracks installed ProxMenux optimizations and notifies when a newer version of any of them is available, with one-click apply",
},
{
- icon: ,
- text: "WebSocket Terminal - Direct terminal access to Proxmox host and LXC containers from the browser",
- },
- {
- icon: ,
- text: "Enhanced Health Monitor - Configurable health monitoring with advanced settings and disk observations",
- },
- {
- icon: ,
- text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
- },
- {
- icon: ,
- text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
- },
- {
- icon: ,
- text: "VPN Integration - Easy Tailscale VPN installation and configuration for secure remote access",
+ icon: ,
+ text: "Health Monitor Thresholds - Per-category warning and critical levels for CPU, memory, temperature, storage and more, fully configurable from Settings",
},
{
icon: ,
- text: "GPU Drivers - Installation scripts for Intel, AMD and NVIDIA graphics drivers and utilities",
+ text: "NVIDIA driver update notifications - Kernel-aware detection of new compatible driver versions, surfaced in the Hardware tab and as notifications when a newer build is published",
+ },
+ {
+ icon: ,
+ text: "Secure Gateway update flow - One-click Tailscale update from Settings, with version indicators and notification when a new release is available",
+ },
+ {
+ icon: ,
+ text: "Helper-Scripts menu - Richer context and useful information for each entry, so you know what every script does before running it",
+ },
+ {
+ icon: ,
+ text: "Improved disk temperature monitoring - Better readings, smarter caching across SMART probes and a redesigned history modal that opens at 24h by default",
+ },
+ {
+ icon: ,
+ text: "VM and LXC modal expanded - Additional information consolidated into a single panel so you don't have to look it up across multiple tabs",
},
{
icon: ,
- text: "Performance Improvements - Optimized data fetching and reduced resource consumption",
+ text: "Faster page load and tighter security - Lighter network usage on the main tabs, plus stricter authentication checks across notification, scripts and terminal endpoints",
},
]
diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx
index 6068a57b..59c67769 100644
--- a/AppImage/components/script-terminal-modal.tsx
+++ b/AppImage/components/script-terminal-modal.tsx
@@ -16,7 +16,10 @@ import {
CornerDownLeft,
GripHorizontal,
ChevronDown,
+ Copy,
+ Clipboard,
} from "lucide-react"
+import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,6 +30,7 @@ import {
} from "@/components/ui/dropdown-menu"
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
+import { getTicketedWsUrl } from "@/lib/terminal-ws"
interface WebInteraction {
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
@@ -57,6 +61,10 @@ export function ScriptTerminalModal({
}: ScriptTerminalModalProps) {
const termRef = useRef(null)
const wsRef = useRef(null)
+ // Mirrors `isOpen` for use inside async closures (initializeTerminal)
+ // after dynamic imports resolve — captures the latest value without
+ // re-binding the closure.
+ const isOpenRef = useRef(false)
const fitAddonRef = useRef(null)
const sessionIdRef = useRef(Math.random().toString(36).substring(2, 8))
@@ -99,14 +107,15 @@ export function ScriptTerminalModal({
clearTimeout(reconnectTimeoutRef.current)
}
- reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
wsRef.current.close()
}
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
- const ws = new WebSocket(wsUrl)
+ // Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
+ const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
wsRef.current = ws
ws.onopen = () => {
@@ -213,17 +222,24 @@ const initMessage = {
}, [])
const initializeTerminal = async () => {
+ // Snapshot the open-state at call time. After the dynamic xterm
+ // imports resolve, bail out if the modal has since been closed —
+ // otherwise we attach a Terminal to a stale ref and open a WS that
+ // nobody reads. Audit Tier 6 — useEffect con `import("xterm")` sin
+ // cancelación.
+ const wasOpenAtCall = isOpenRef.current
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
import("xterm-addon-fit").then((mod) => mod.FitAddon),
import("xterm/css/xterm.css"),
])
+ if (!wasOpenAtCall || !isOpenRef.current) return
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
- fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
+ fontFamily: '"MesloLGS NF", "FiraCode Nerd Font", "JetBrainsMono Nerd Font", "Hack Nerd Font", "Symbols Nerd Font", "Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
fontSize: fontSize,
lineHeight: 1,
cursorBlink: true,
@@ -272,7 +288,8 @@ const initMessage = {
}, 100)
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
- const ws = new WebSocket(wsUrl)
+ // Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
+ const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
wsRef.current = ws
ws.onopen = () => {
@@ -368,9 +385,14 @@ const initMessage = {
}
}
+ // Read `wsRef.current` inside the handler so reconnect (which swaps
+ // `wsRef.current` to a fresh WebSocket) doesn't leave us writing to the
+ // dead closure-captured `ws`. Without this fix, after reconnect the
+ // user's stdin disappears into the void. Audit residual #8.
term.onData((data) => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(data)
+ const live = wsRef.current
+ if (live && live.readyState === WebSocket.OPEN) {
+ live.send(data)
}
})
@@ -410,6 +432,7 @@ const initMessage = {
}
useEffect(() => {
+ isOpenRef.current = isOpen
const savedHeight = localStorage.getItem("scriptModalHeight")
if (savedHeight) {
const height = Number.parseInt(savedHeight, 10)
@@ -624,6 +647,14 @@ const initMessage = {
}
}
+ // Mobile clipboard helpers — see lib/terminal-clipboard.ts.
+ const handleCopy = async () => {
+ await copyTerminalSelection(termRef.current)
+ }
+ const handlePaste = async () => {
+ await pasteFromClipboard(sendCommand)
+ }
+
return (
<>
@@ -775,7 +806,7 @@ const initMessage = {
-
+
Control Sequences
sendCommand("\x03")}>
@@ -790,6 +821,16 @@ const initMessage = {
Ctrl+R
Search history
+
+ Clipboard
+ { void handleCopy() }}>
+
+ Copy selection
+
+ { void handlePaste() }}>
+
+ Paste
+
@@ -844,12 +885,19 @@ const initMessage = {
>
{currentInteraction.title}
-
").replace(/\n/g, "
"),
- }}
- />
+ {/*
+ Render the interaction message as plain text. The message
+ comes through the WebSocket from a script running as root —
+ a script bug or compromised author could embed `