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 logo +
+
+

+ 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. + + + +
+ {SUPPORT_LINKS.map(row => ( + + ))} + + + + +
+
+ GPL-3.0 license + +
+

+ Free software — see the LICENSE file for the full text. +

+
+
+
+
+
+
+ ) +} 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} + + +
+ {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 ( +
+ + +
+ + {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 ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+ + 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 && ( +
+ +
{error}
+
+ )} + + {SECTIONS.map((section) => { + const Icon = section.icon + return ( +
+
+
+ +

{section.title}

+
+ {!editMode && ( + + )} +
+ {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 ( +
+
+
+ +

+ During this window only CRITICAL events reach this channel. +

+
+ +
+ {enabled && ( + <> +
+
+ + updateChannel(chName, "quiet_start", e.target.value)} + disabled={!editMode} + className="h-7 text-xs font-mono" + /> +
+
+ + updateChannel(chName, "quiet_end", e.target.value)} + disabled={!editMode} + className="h-7 text-xs font-mono" + /> +
+
+

+ {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 ( +
+
+
+ +

+ 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. +

+
+ +
+ {enabled && ( + <> +
+ + 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 */}
@@ -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 */}
+
@@ -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 `