update beta ProxMenux 1.2.1.1-beta

This commit is contained in:
MacRimi
2026-05-09 18:59:59 +02:00
parent 5ed1fc44fd
commit 2f919de9e3
125 changed files with 16506 additions and 2877 deletions
+223
View File
@@ -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 (
<a
href={row.href}
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span
className={`inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md ${accentClass}`}
>
<row.Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{row.label}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{row.description}</p>
</div>
</a>
)
}
export function About() {
return (
<div className="space-y-4 md:space-y-6">
{/* Hero — logo, name, version, one-line description. */}
<Card>
<CardContent className="pt-6 pb-6">
<div className="flex flex-col md:flex-row items-center md:items-start gap-4 md:gap-6">
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
<Image
src="/images/proxmenux-logo.png"
alt="ProxMenux logo"
fill
priority
className="object-contain"
/>
</div>
<div className="text-center md:text-left flex-1 min-w-0">
<h2 className="text-2xl md:text-3xl font-semibold text-foreground">
ProxMenux Monitor
</h2>
<p className="text-sm text-muted-foreground mt-1">
A web dashboard and management layer for Proxmox VE health monitoring,
notifications, terminal, optimization tracker and more, packaged as a single
AppImage.
</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
<Sparkles className="h-3 w-3" />
v{APP_VERSION}
</span>
{/* 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. */}
<a
href="https://proxmenux.com/changelog"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-muted hover:bg-muted/70 transition-colors text-foreground border border-border px-2.5 py-1 text-xs"
>
Changelog
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Project links — GitHub, docs, discussions, bug tracker. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Github className="h-4 w-4 text-muted-foreground" />
Project
</CardTitle>
<CardDescription>Repository, documentation and community channels.</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{PROJECT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
</div>
</CardContent>
</Card>
{/* 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. */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Heart className="h-4 w-4 text-pink-500" />
Support &amp; License
</CardTitle>
<CardDescription>
ProxMenux is free and open source under the GPL-3.0 license. If it&apos;s useful to
you, a one-off contribution helps keep it that way.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-2">
{SUPPORT_LINKS.map(row => (
<LinkCard key={row.href} row={row} />
))}
<a
href="https://github.com/MacRimi/ProxMenux/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer flex items-start gap-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
>
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md bg-gray-500/10 text-gray-400">
<Scale className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
GPL-3.0 license
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
Free software see the LICENSE file for the full text.
</p>
</div>
</a>
</div>
</CardContent>
</Card>
</div>
)
}
-7
View File
@@ -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)
@@ -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 (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-md px-2 py-1 shadow-xl">
{date && (
<p className="text-[10px] text-gray-300">
{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
)}
<p className="text-xs font-semibold text-white">{payload[0].value}°C</p>
</div>
)
}
return null
}
export function DiskTemperatureCard({
diskName,
liveTemperature,
diskType,
onOpenDetail,
}: DiskTemperatureCardProps) {
const [data, setData] = useState<TempPoint[]>([])
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 (
<Wrapper
type={interactive ? "button" : undefined}
onClick={interactive ? onOpenDetail : undefined}
className={[
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
].join(" ")}
title={interactive ? "Open temperature history" : undefined}
>
<div className="flex items-start justify-between gap-3 mb-1.5">
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Temperature</p>
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
{tempDisplay}
</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
{status.label}
</Badge>
</div>
</div>
<div className="h-[40px] -mx-1">
{loading ? (
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
) : samples < 2 ? (
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
Collecting samples chart populates after ~2 minutes
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 2, right: 4, left: 4, bottom: 0 }}>
<defs>
<linearGradient id={`diskTempCardGrad-${diskName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={lineColor} stopOpacity={0.35} />
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
<Area
type="monotone"
dataKey="value"
stroke={lineColor}
strokeWidth={1.6}
fill={`url(#diskTempCardGrad-${diskName})`}
dot={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</Wrapper>
)
}
@@ -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 (
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
<p className="text-sm font-semibold text-white mb-2">{label}</p>
<div className="space-y-1.5">
{payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
<span className="text-sm font-semibold text-white">{entry.value}°C</span>
</div>
))}
</div>
</div>
)
}
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<TempHistoryPoint[]>([])
const [stats, setStats] = useState<TempStats>({ 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-card border-border px-3 sm:px-6">
<DialogHeader>
{/*
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.
*/}
<div className="flex items-center justify-between pr-6">
<DialogTitle className="text-foreground flex items-center gap-2">
<Thermometer className="h-5 w-5" />
/dev/{diskName}
</DialogTitle>
<Select value={timeframe} onValueChange={setTimeframe}>
<SelectTrigger className="w-[130px] bg-card border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{diskModel && (
<p className="text-xs text-muted-foreground truncate pr-6 mt-0.5">{diskModel}</p>
)}
</DialogHeader>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
<div className="text-xs opacity-80 mb-1">Current</div>
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3" /> Min
</div>
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<Minus className="h-3 w-3" /> Avg
</div>
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
</div>
<div className="bg-muted/50 rounded-lg p-3 text-center">
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3" /> Max
</div>
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
</div>
</div>
<div className="h-[300px] lg:h-[350px]">
{loading ? (
<div className="h-full flex items-center justify-center">
<div className="space-y-3 w-full animate-pulse">
<div className="h-4 bg-muted rounded w-1/4 mx-auto" />
<div className="h-[250px] bg-muted/50 rounded" />
</div>
</div>
) : chartData.length === 0 ? (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No temperature data yet for this disk</p>
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
</div>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`diskTempGradient-${diskName}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={chartColor} stopOpacity={0.3} />
<stop offset="100%" stopColor={chartColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
<XAxis
dataKey="time"
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
interval="preserveStartEnd"
minTickGap={isMobile ? 40 : 60}
/>
<YAxis
domain={[yMin, yMax]}
stroke="currentColor"
className="text-foreground"
tick={{ fill: "currentColor", fontSize: isMobile ? 10 : 12 }}
tickFormatter={(v) => `${v}°`}
width={isMobile ? 40 : 45}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="value"
name="Temperature"
stroke={chartColor}
strokeWidth={2}
fill={`url(#diskTempGradient-${diskName})`}
dot={false}
activeDot={{ r: 4, fill: chartColor, stroke: "#fff", strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</DialogContent>
</Dialog>
)
}
+74 -5
View File
@@ -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<Array<{
id: string
type: string
name?: string
current_version?: string | null
menu_label?: string | null
update_check?: {
available: boolean
latest?: string | null
last_check?: string | null
error?: string | null
} | null
}>>([])
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<GPU | null>(null)
const [realtimeGPUData, setRealtimeGPUData] = useState<any>(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 (
<span className="font-mono text-xs">{gpu.pci_kernel_module}</span>
</div>
)}
</div>
{gpu.vendor?.toLowerCase().includes("nvidia") &&
nvidiaInstall?.current_version &&
nvidiaInstall.update_check?.last_check && (
<div className="pt-2 mt-2 border-t border-border">
{nvidiaInstall.update_check.available ? (
<>
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "}
<span className="text-purple-400 font-medium">
NVIDIA driver v{nvidiaInstall.update_check.latest} available
</span>
</div>
{nvidiaInstall.menu_label && (
<div className="text-[11px] text-muted-foreground mt-1">
Reinstall via ProxMenux post-install: {nvidiaInstall.menu_label}
</div>
)}
</>
) : (
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)}
{` · NVIDIA driver v${nvidiaInstall.current_version}`}
{" · "}
<span className="text-green-500/80">No updates available</span>
</div>
)}
</div>
)}
{/* GPU Switch Mode Indicator */}
{getGpuSwitchMode(gpu) !== "unknown" && (
<div className="mt-3 pt-3 border-t border-border/30">
@@ -2848,7 +2918,6 @@ return (
mutateStatic()
}}
onComplete={(success) => {
console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed")
if (success) {
mutateStatic()
}
+576
View File
@@ -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<T>(endpoint: string, init?: RequestInit): Promise<T> {
const token = getAuthToken()
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) || {}),
}
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<string, string>
// ─── 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<ThresholdsTree | null>(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<string | null>(null)
const [pending, setPending] = useState<PendingEdits>({})
// 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<string, any> | null => {
const payload: Record<string, any> = {}
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 (
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
<span className="text-xs sm:text-sm text-foreground/90 min-w-0 flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 flex-shrink-0" aria-hidden="true" />
{label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={`inline-flex items-center justify-center h-6 px-2 rounded-md border text-[11px] font-mono tabular-nums ${badgeClasses}`}
title="Recommended default value"
>
{leaf.recommended}
{leaf.unit}
</span>
<Input
type="number"
min={leaf.min}
max={leaf.max}
step={leaf.step}
disabled={!editMode}
value={editingValue}
onChange={(e) =>
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" : ""
}`}
/>
<span className="text-[11px] text-muted-foreground w-6">{leaf.unit}</span>
</div>
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
<CardTitle>Health Monitor Thresholds</CardTitle>
</div>
{!loading && (
<div className="flex items-center gap-2">
{savedFlash && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
Saved
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
Cancel
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={handleSave}
disabled={saving || !hasPendingChanges}
>
{saving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Save
</button>
</>
) : (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
onClick={handleResetAll}
title="Reset every threshold to its recommended value"
>
<RotateCcw className="h-3 w-3" />
Reset all
</button>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={handleEdit}
>
<Settings2 className="h-3 w-3" />
Edit
</button>
</>
)}
</div>
)}
</div>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : !tree ? (
<div className="text-sm text-muted-foreground">Failed to load thresholds.</div>
) : (
<div className="space-y-4">
{error && (
<div className="flex items-start gap-2 p-2.5 rounded-md bg-red-500/10 border border-red-500/30 text-red-500 text-xs">
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="flex-1">{error}</div>
</div>
)}
{SECTIONS.map((section) => {
const Icon = section.icon
return (
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h4 className="text-sm font-medium">{section.title}</h4>
</div>
{!editMode && (
<button
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
onClick={() => handleResetSection(section.id)}
title="Reset this section to recommended"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
</div>
{section.description && (
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
{section.description}
</p>
)}
<div className="divide-y divide-border/40">
{section.rowGroups
? section.rowGroups.map((group) => (
<div key={group.subKey} className="py-1.5">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-0.5 px-1">
{group.label}
</div>
{renderField([section.id, group.subKey, "warning"], "Warning")}
{renderField([section.id, group.subKey, "critical"], "Critical")}
</div>
))
: section.fields.map((f) => renderField(f.path, f.label))}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
+60 -12
View File
@@ -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({
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
@@ -649,6 +687,16 @@ export function LxcTerminalModal({
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -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<any>(apiPath)
@@ -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<any>(`/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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-card border-border">
@@ -245,7 +231,6 @@ export function NodeMetricsCharts() {
}
if (error) {
console.log("[v0] Rendering error state:", error)
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-card border-border">
@@ -269,7 +254,6 @@ export function NodeMetricsCharts() {
}
if (data.length === 0) {
console.log("[v0] Rendering no data state")
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-card border-border">
@@ -290,7 +274,6 @@ export function NodeMetricsCharts() {
)
}
console.log("[v0] Rendering charts with", data.length, "data points")
return (
<div className="space-y-6">
+234 -3
View File
@@ -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/<id>/<token>)" }
}
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<string | null>(null)
const [testing, setTesting] = useState<string | null>(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 (
<div className="space-y-2 pt-2 border-t border-border/50">
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium flex items-center gap-1.5">
<Moon className="h-3.5 w-3.5 text-blue-400" />
Quiet hours
</Label>
<p className="text-[10px] text-muted-foreground">
During this window only CRITICAL events reach this channel.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel(chName, "quiet_enabled", !enabled) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{enabled && (
<>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px] text-muted-foreground">From</Label>
<Input
type="time"
value={start}
onChange={(e) => updateChannel(chName, "quiet_start", e.target.value)}
disabled={!editMode}
className="h-7 text-xs font-mono"
/>
</div>
<div>
<Label className="text-[10px] text-muted-foreground">Until</Label>
<Input
type="time"
value={end}
onChange={(e) => updateChannel(chName, "quiet_end", e.target.value)}
disabled={!editMode}
className="h-7 text-xs font-mono"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
{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}.`}
</p>
</>
)}
</div>
)
}
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 (
<div className="space-y-2 pt-2 border-t border-border/50">
<div className="flex items-center justify-between py-1">
<div>
<Label className="text-xs font-medium flex items-center gap-1.5">
<Newspaper className="h-3.5 w-3.5 text-violet-400" />
Daily digest of INFO events
</Label>
<p className="text-[10px] text-muted-foreground">
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.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={!editMode}
className={`relative w-9 h-[18px] shrink-0 rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${enabled ? "bg-blue-600" : "bg-muted-foreground/20 border border-muted-foreground/40"}`}
onClick={() => { if (editMode) updateChannel(chName, "digest_enabled", !enabled) }}
>
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
{enabled && (
<>
<div>
<Label className="text-[10px] text-muted-foreground">Send at</Label>
<Input
type="time"
value={time}
onChange={(e) => updateChannel(chName, "digest_time", e.target.value)}
disabled={!editMode}
className="h-7 text-xs font-mono"
/>
</div>
<p className="text-[10px] text-muted-foreground">{nextLabel}</p>
</>
)}
</div>
)
}
/** 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
</span>
)}
{saveError && (
<span
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
title={saveError}
>
Save failed: {saveError}
</span>
)}
{editMode ? (
<>
<button
@@ -1180,6 +1393,8 @@ export function NotificationSettings() {
</button>
</div>
{renderChannelCategories("telegram")}
{renderQuietHours("telegram")}
{renderDailyDigest("telegram")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -1224,6 +1439,12 @@ export function NotificationSettings() {
onChange={e => updateChannel("gotify", "url", e.target.value)}
disabled={!editMode}
/>
{(() => {
const v = validateGotifyUrl(config.channels.gotify?.url || "")
if (v.error) return <p className="text-[10px] text-red-500">{v.error}</p>
if (v.warning) return <p className="text-[10px] text-yellow-500">{v.warning}</p>
return null
})()}
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">App Token</Label>
@@ -1266,6 +1487,8 @@ export function NotificationSettings() {
</button>
</div>
{renderChannelCategories("gotify")}
{renderQuietHours("gotify")}
{renderDailyDigest("gotify")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -1319,6 +1542,10 @@ export function NotificationSettings() {
{showSecrets["dc_hook"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
</div>
{(() => {
const v = validateDiscordWebhook(config.channels.discord?.webhook_url || "")
return v.error ? <p className="text-[10px] text-red-500">{v.error}</p> : null
})()}
</div>
{/* Message format */}
<div className="flex items-center justify-between py-1">
@@ -1342,6 +1569,8 @@ export function NotificationSettings() {
</button>
</div>
{renderChannelCategories("discord")}
{renderQuietHours("discord")}
{renderDailyDigest("discord")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -1485,6 +1714,8 @@ export function NotificationSettings() {
</p>
</div>
{renderChannelCategories("email")}
{renderQuietHours("email")}
{renderDailyDigest("email")}
{/* Send Test */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
+30 -1
View File
@@ -12,6 +12,7 @@ import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { Settings } from "./settings"
import { Security } from "./security"
import { About } from "./about"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -530,7 +531,10 @@ export function ProxmoxDashboard() {
>
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
<TabsList className="hidden lg:grid w-full grid-cols-9 bg-card border border-border">
{/* 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. */}
<TabsList className="hidden lg:grid w-full grid-cols-10 bg-card border border-border">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
@@ -585,6 +589,12 @@ export function ProxmoxDashboard() {
>
Settings
</TabsTrigger>
<TabsTrigger
value="about"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
About
</TabsTrigger>
</TabsList>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
@@ -738,6 +748,21 @@ export function ProxmoxDashboard() {
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</Button>
<Button
variant="ghost"
onClick={() => {
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"
: ""
}`}
>
<Info className="h-5 w-5" />
<span>About</span>
</Button>
</div>
</SheetContent>
</Sheet>
@@ -782,6 +807,10 @@ export function ProxmoxDashboard() {
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
<Settings />
</TabsContent>
<TabsContent value="about" className="space-y-4 md:space-y-6 mt-0">
<About />
</TabsContent>
</Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
+48 -24
View File
@@ -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<string, ReleaseNote> = {
"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<string, ReleaseNote> = {
const CURRENT_VERSION_FEATURES = [
{
icon: <Thermometer className="h-5 w-5" />,
text: "Temperature & Latency Charts - Real-time visual monitoring with interactive historical graphs",
icon: <RefreshCw className="h-5 w-5" />,
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: <Terminal className="h-5 w-5" />,
text: "WebSocket Terminal - Direct terminal access to Proxmox host and LXC containers from the browser",
},
{
icon: <Activity className="h-5 w-5" />,
text: "Enhanced Health Monitor - Configurable health monitoring with advanced settings and disk observations",
},
{
icon: <Bell className="h-5 w-5" />,
text: "AI-Enhanced Notifications - Intelligent message formatting with support for OpenAI, Groq, Anthropic and Ollama",
},
{
icon: <Shield className="h-5 w-5" />,
text: "Security Section - Comprehensive security configuration for both ProxMenux and Proxmox systems",
},
{
icon: <Globe className="h-5 w-5" />,
text: "VPN Integration - Easy Tailscale VPN installation and configuration for secure remote access",
icon: <Sliders className="h-5 w-5" />,
text: "Health Monitor Thresholds - Per-category warning and critical levels for CPU, memory, temperature, storage and more, fully configurable from Settings",
},
{
icon: <Cpu className="h-5 w-5" />,
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: <Globe className="h-5 w-5" />,
text: "Secure Gateway update flow - One-click Tailscale update from Settings, with version indicators and notification when a new release is available",
},
{
icon: <Wrench className="h-5 w-5" />,
text: "Helper-Scripts menu - Richer context and useful information for each entry, so you know what every script does before running it",
},
{
icon: <Thermometer className="h-5 w-5" />,
text: "Improved disk temperature monitoring - Better readings, smarter caching across SMART probes and a redesigned history modal that opens at 24h by default",
},
{
icon: <Server className="h-5 w-5" />,
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: <Zap className="h-5 w-5" />,
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",
},
]
+61 -13
View File
@@ -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<any>(null)
const wsRef = useRef<WebSocket | null>(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<boolean>(false)
const fitAddonRef = useRef<any>(null)
const sessionIdRef = useRef<string>(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 (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -775,7 +806,7 @@ const initMessage = {
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
@@ -790,6 +821,16 @@ const initMessage = {
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -844,12 +885,19 @@ const initMessage = {
>
<DialogTitle>{currentInteraction.title}</DialogTitle>
<div className="space-y-4">
<p
className="whitespace-pre-wrap"
dangerouslySetInnerHTML={{
__html: currentInteraction.message.replace(/\\n/g, "<br/>").replace(/\n/g, "<br/>"),
}}
/>
{/*
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 `<script>` or
`<img onerror=...>` and run JS in the admin's browser, leaking
the JWT and any keys held in React state. `whitespace-pre-wrap`
already preserves the `\n` formatting we previously emulated
via `<br/>`, so we don't need any HTML conversion. See audit
Tier 2 #17b.
*/}
<p className="whitespace-pre-wrap break-words">
{currentInteraction.message.replace(/\\n/g, "\n")}
</p>
{currentInteraction.type === "yesno" && (
<div className="flex gap-2">
+210 -2
View File
@@ -17,6 +17,7 @@ import {
ShieldCheck, Globe, ExternalLink, Loader2, CheckCircle, XCircle,
Play, Square, RotateCw, Trash2, FileText, ChevronRight, ChevronDown,
AlertTriangle, Info, Network, Eye, EyeOff, Settings, Wifi, Key,
ArrowUpCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
@@ -80,6 +81,11 @@ export function SecureGatewaySetup() {
const [loading, setLoading] = useState(true)
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
const [runtimeInfo, setRuntimeInfo] = useState<{ runtime: string; version: string } | null>(null)
// Surface initial-data load failures. Wizard rendering depends on
// wizardSteps being populated; if loadInitialData throws, we previously
// ended up with `loading=false` and an empty wizard, which read as a
// broken UI. Keep the error message so we can show a retry button.
const [loadError, setLoadError] = useState<string | null>(null)
const [appStatus, setAppStatus] = useState<AppStatus>({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" })
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
const [wizardSteps, setWizardSteps] = useState<WizardStep[]>([])
@@ -114,6 +120,25 @@ export function SecureGatewaySetup() {
const [newAuthKey, setNewAuthKey] = useState("")
const [updateAuthKeyLoading, setUpdateAuthKeyLoading] = useState(false)
const [updateAuthKeyError, setUpdateAuthKeyError] = useState("")
// Sprint 14.6: Tailscale / Alpine package update flow.
// `updateInfo`: result of GET /api/oci/installed/<id>/update-check.
// `null` until the first probe lands.
// `updateApplying`: true while POST /update is running. Long op
// (apk upgrade can take 1-3 min on slow links).
// `updateError` / `updateResultMsg`: surfaced as a small banner
// so the user gets explicit feedback.
const [updateInfo, setUpdateInfo] = useState<{
available: boolean
current_version?: string | null
latest_version?: string | null
packages?: Array<{ name: string; current: string; latest: string }>
last_checked_iso?: string
error?: string | null
} | null>(null)
const [updateApplying, setUpdateApplying] = useState(false)
const [updateError, setUpdateError] = useState<string | null>(null)
const [updateResultMsg, setUpdateResultMsg] = useState<string | null>(null)
// Password visibility
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set())
@@ -124,6 +149,7 @@ export function SecureGatewaySetup() {
const loadInitialData = async () => {
setLoading(true)
setLoadError(null)
try {
// Secure Gateway uses standard LXC, not OCI containers
// So we don't require PVE 9.1+ - it works on any Proxmox version
@@ -181,6 +207,7 @@ export function SecureGatewaySetup() {
}
} catch (err) {
console.error("Failed to load data:", err)
setLoadError(err instanceof Error ? err.message : "Failed to load wizard data")
} finally {
setLoading(false)
}
@@ -191,13 +218,79 @@ export function SecureGatewaySetup() {
const statusRes = await fetchApi("/api/oci/status/secure-gateway")
if (statusRes.success) {
setAppStatus(statusRes.status)
// Once we know the gateway is installed, kick off the update
// probe in the background. It hits the 24h-cached endpoint, so
// repeating this on every status reload is essentially free.
if (statusRes.status?.state && statusRes.status.state !== "not_installed") {
loadUpdateInfo()
}
}
} catch (err) {
// Not installed is ok
}
}
// Pull the cached update-check from the backend. The server-side
// cache is 24h, so this is cheap to call on mount. After applying
// an update we pass `force=true` so the panel doesn't keep
// rendering the pre-update "available" state from a stale cache
// entry.
const loadUpdateInfo = async (force = false) => {
try {
const url = force
? "/api/oci/installed/secure-gateway/update-check?force=1"
: "/api/oci/installed/secure-gateway/update-check"
const res: any = await fetchApi(url)
if (res?.success) {
setUpdateInfo({
available: !!res.available,
current_version: res.current_version,
latest_version: res.latest_version,
packages: res.packages,
last_checked_iso: res.last_checked_iso,
error: res.error || null,
})
}
} catch {
// Silent — the panel just won't show the update line.
}
}
const handleApplyUpdate = async () => {
setUpdateApplying(true)
setUpdateError(null)
setUpdateResultMsg(null)
try {
const res: any = await fetchApi("/api/oci/installed/secure-gateway/update", {
method: "POST",
})
if (res?.success) {
setUpdateResultMsg(res.message || "Update applied")
// Re-probe with force=true so the panel flips back to "No
// updates available" immediately, bypassing the 24h server
// cache which may still hold the pre-apply "available" entry.
await loadUpdateInfo(true)
// Status may briefly show "stopped" if tailscale was restarted —
// refresh that too so the action buttons render the right state.
await loadStatus()
} else {
setUpdateError(res?.message || "Update failed")
}
} catch (err) {
setUpdateError(err instanceof Error ? err.message : "Network error during update")
} finally {
setUpdateApplying(false)
}
}
const handleDeploy = async () => {
// Concurrency guard. The button is also `disabled={deploying}`, but
// a screen reader, a fast double-tap on a high-latency link, or an
// automated test can fire two clicks before React re-renders the
// disabled state. The handler-level guard makes it impossible to
// submit a second deploy while one is still in flight. Audit Tier 6
// — `secure-gateway-setup.tsx` action buttons sin guard.
if (deploying) return
setDeploying(true)
setDeployError("")
setDeployProgress("Preparing deployment...")
@@ -255,7 +348,13 @@ export function SecureGatewaySetup() {
}
setDeployProgress("Gateway deployed successfully!")
// Wipe the Tailscale auth_key from React state so it's no longer
// reachable from a future XSS / state-inspection. The key only needs
// to live in memory for the duration of the deploy POST. Audit
// residual #11 — secure-gateway auth_key persistence.
setConfig((prev) => ({ ...prev, auth_key: "" }))
// Wait and reload status, then show post-deploy info
setTimeout(async () => {
await loadStatus()
@@ -283,6 +382,7 @@ export function SecureGatewaySetup() {
}
const handleAction = async (action: "start" | "stop" | "restart") => {
if (actionLoading) return
setActionLoading(action)
try {
const result = await fetchApi(`/api/oci/installed/secure-gateway/${action}`, {
@@ -304,9 +404,10 @@ export function SecureGatewaySetup() {
return
}
if (updateAuthKeyLoading) return
setUpdateAuthKeyLoading(true)
setUpdateAuthKeyError("")
try {
const result = await fetchApi("/api/oci/installed/secure-gateway/update-auth-key", {
method: "POST",
@@ -333,6 +434,7 @@ export function SecureGatewaySetup() {
}
const handleRemove = async () => {
if (actionLoading) return
setActionLoading("remove")
try {
const result = await fetchApi("/api/oci/installed/secure-gateway?remove_data=false", {
@@ -370,6 +472,26 @@ export function SecureGatewaySetup() {
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`
}
// Format an ISO timestamp as a friendly "HH:MM" / "yesterday HH:MM" /
// date-only string. Used in the Updates panel — the user wants to know
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
const formatLastChecked = (iso?: string): 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 renderField = (fieldName: string) => {
const field = configSchema?.[fieldName]
if (!field) return null
@@ -822,6 +944,30 @@ export function SecureGatewaySetup() {
)
}
// Initial data load failed — show the error and a retry button instead
// of an empty wizard. Without this, a transient network error or 401
// dropped the user into a wizard with zero steps and no signal.
if (loadError) {
return (
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-cyan-500" />
<CardTitle className="text-base">Secure Gateway</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 py-2">
<p className="text-sm text-red-500">Could not load setup data: {loadError}</p>
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
Retry
</Button>
</div>
</CardContent>
</Card>
)
}
// Installed state
if (appStatus.state !== "not_installed") {
const isRunning = appStatus.state === "running"
@@ -928,6 +1074,68 @@ export function SecureGatewaySetup() {
</Button>
</div>
{/* Updates panel — only when we have a probe result. The
cached 24h backend means this stays cheap; the user
doesn't see anything during the very first load. */}
{updateInfo && !updateInfo.error && (
<div className="pt-2 border-t border-border space-y-2">
{updateInfo.available ? (
<>
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
<span className="text-purple-400 font-medium">
Tailscale v{updateInfo.latest_version} available
</span>
</div>
</div>
<Button
size="sm"
onClick={handleApplyUpdate}
disabled={updateApplying || actionLoading !== null}
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
>
{updateApplying ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
) : (
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
)}
{updateApplying
? "Updating…"
: `Update to v${updateInfo.latest_version}`}
</Button>
{updateInfo.packages && updateInfo.packages.length > 1 && (
<div className="text-[11px] text-muted-foreground">
+{updateInfo.packages.length - 1} other package
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
</div>
)}
</>
) : (
<div className="text-xs text-muted-foreground">
Last checked: {formatLastChecked(updateInfo.last_checked_iso)}
{updateInfo.current_version
? ` · Tailscale v${updateInfo.current_version}`
: ""}
{" · "}
<span className="text-green-500/80">No updates available</span>
</div>
)}
{updateError && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<XCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
{updateError}
</div>
)}
{updateResultMsg && !updateError && (
<div className="text-xs text-green-400 flex items-start gap-1.5">
<CheckCircle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
{updateResultMsg}
</div>
)}
</div>
)}
{/* Update Auth Key button */}
<div className="pt-2 border-t border-border flex items-center justify-between">
<Button
+162 -49
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
@@ -10,7 +10,7 @@ import {
Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle,
Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban,
FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, ArrowDownLeft, ArrowUpRight,
ChevronRight, Network, Zap, Pencil, Check, X,
ChevronRight, Network, Zap, Pencil, Check, X, ExternalLink,
} from "lucide-react"
import { getApiUrl, fetchApi } from "../lib/api-config"
import { TwoFactorSetup } from "./two-factor-setup"
@@ -26,6 +26,37 @@ interface ApiTokenEntry {
revoked: boolean
}
// Replaces the previous `password.length < 6` check. Bumped the minimum
// floor and require at least 3 of the 4 character categories so a brute-
// force on the password hash isn't trivial. Also screens the few obvious
// strings that real users still type. Server-side enforces the same floor
// in auth_manager.setup_auth.
const _OBVIOUS_PASSWORDS = new Set([
"password", "password1", "password123",
"12345678", "123456789", "1234567890",
"qwerty", "qwertyuiop", "letmein", "welcome",
"admin", "administrator", "root", "proxmox", "proxmenux",
"changeme", "abcdefgh",
])
function validatePasswordStrength(pw: string): string | null {
if (pw.length < 10) {
return "Password must be at least 10 characters"
}
const categories = [
/[a-z]/.test(pw),
/[A-Z]/.test(pw),
/\d/.test(pw),
/[^A-Za-z0-9]/.test(pw),
].filter(Boolean).length
if (categories < 3) {
return "Password must mix at least 3 of: lowercase, uppercase, digits, symbols"
}
if (_OBVIOUS_PASSWORDS.has(pw.toLowerCase())) {
return "That password is in the common-passwords list — pick something else"
}
return null
}
export function Security() {
const [authEnabled, setAuthEnabled] = useState(false)
const [totpEnabled, setTotpEnabled] = useState(false)
@@ -48,6 +79,7 @@ export function Security() {
const [show2FASetup, setShow2FASetup] = useState(false)
const [show2FADisable, setShow2FADisable] = useState(false)
const [disable2FAPassword, setDisable2FAPassword] = useState("")
const [disable2FATotpCode, setDisable2FATotpCode] = useState("")
// API Token state management
const [showApiTokenSection, setShowApiTokenSection] = useState(false)
@@ -142,6 +174,17 @@ export function Security() {
const [lynisReportLoading, setLynisReportLoading] = useState(false)
const [lynisShowReport, setLynisShowReport] = useState(false)
const [lynisActiveTab, setLynisActiveTab] = useState<"overview" | "warnings" | "suggestions" | "checks">("overview")
// Tracks the active Lynis poll so a component unmount mid-audit clears
// the setInterval. Without this the timer kept firing every 3s and
// calling setState on an unmounted component, which logs a React
// warning and leaks the closure.
const lynisPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => {
if (lynisPollRef.current) {
clearInterval(lynisPollRef.current)
lynisPollRef.current = null
}
}, [])
// Fail2Ban detailed state
interface BannedIp {
@@ -217,8 +260,11 @@ export function Security() {
monitor_port_open: data.monitor_port_open,
})
}
} catch {
// Silently fail
} catch (err) {
// Was a silent catch — left the user staring at "0 firewall rules" when
// the request 401'd or the backend was down. At minimum surface the
// failure in the browser console so devtools shows what went wrong.
console.error("[security] Failed to load firewall status:", err)
} finally {
setFirewallLoading(false)
}
@@ -248,8 +294,8 @@ export function Security() {
setFail2banInfo(data.tools.fail2ban || null)
setLynisInfo(data.tools.lynis || null)
}
} catch {
// Silently fail
} catch (err) {
console.error("[security] Failed to load security tools (fail2ban/lynis):", err)
} finally {
setToolsLoading(false)
}
@@ -382,12 +428,18 @@ export function Security() {
try {
const data = await fetchApi("/api/security/lynis/run", { method: "POST" })
if (data.success) {
// Poll for completion
const pollInterval = setInterval(async () => {
// Poll for completion. Stash the interval id in a ref so the
// component unmount cleanup (above) can clear it if the user
// navigates away while the audit is still running.
if (lynisPollRef.current) clearInterval(lynisPollRef.current)
lynisPollRef.current = setInterval(async () => {
try {
const status = await fetchApi("/api/security/lynis/status")
if (!status.running) {
clearInterval(pollInterval)
if (lynisPollRef.current) {
clearInterval(lynisPollRef.current)
lynisPollRef.current = null
}
setLynisAuditRunning(false)
if (status.progress === "completed") {
setSuccess("Security audit completed successfully")
@@ -398,7 +450,10 @@ export function Security() {
}
}
} catch {
clearInterval(pollInterval)
if (lynisPollRef.current) {
clearInterval(lynisPollRef.current)
lynisPollRef.current = null
}
setLynisAuditRunning(false)
}
}, 3000)
@@ -419,8 +474,8 @@ export function Security() {
if (data.success && data.report) {
setLynisReport(data.report)
}
} catch {
// ignore
} catch (err) {
console.error("[security] Failed to load Lynis report:", err)
} finally {
setLynisReportLoading(false)
}
@@ -670,8 +725,9 @@ export function Security() {
return
}
if (password.length < 6) {
setError("Password must be at least 6 characters")
const pwError = validatePasswordStrength(password)
if (pwError) {
setError(pwError)
return
}
@@ -768,8 +824,9 @@ export function Security() {
return
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters")
const pwError = validatePasswordStrength(newPassword)
if (pwError) {
setError(pwError)
return
}
@@ -818,6 +875,13 @@ export function Security() {
setError("Please enter your password")
return
}
// Mirror backend hardening (auth_manager.disable_totp): turning 2FA off must
// require the second factor — otherwise an attacker who phished the password
// could strip the protection. Accepts a 6-digit TOTP code or a backup code.
if (!disable2FATotpCode) {
setError("Please enter your 2FA code (or a backup code)")
return
}
setLoading(true)
@@ -829,7 +893,10 @@ export function Security() {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ password: disable2FAPassword }),
body: JSON.stringify({
password: disable2FAPassword,
totp_code: disable2FATotpCode.trim(),
}),
})
const data = await response.json()
@@ -842,6 +909,7 @@ export function Security() {
setTotpEnabled(false)
setShow2FADisable(false)
setDisable2FAPassword("")
setDisable2FATotpCode("")
checkAuthStatus()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to disable 2FA")
@@ -863,8 +931,8 @@ export function Security() {
if (data.success) {
setExistingTokens(data.tokens || [])
}
} catch {
// Silently fail - tokens section is optional
} catch (err) {
console.error("[security] Failed to load API tokens:", err)
} finally {
setLoadingTokens(false)
}
@@ -987,6 +1055,22 @@ export function Security() {
}
const generatePrintableReport = (report: LynisReport) => {
// Escape user/server-controlled strings before they land in the printable
// HTML. Without this, any Lynis check name / description / solution that
// contained `<script>` or `<img onerror=...>` would execute in the admin's
// browser when the report is opened — a stored XSS path. Numbers, CSS
// colors and our static markup are safe; only dynamic strings are escaped.
// See audit Tier 2 #14.
const esc = (raw: unknown): string => {
const s = raw == null ? "" : String(raw)
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
const adjScore = report.proxmox_adjusted_score ?? report.hardening_index
const rawScore = report.hardening_index
const displayScore = adjScore ?? rawScore
@@ -1011,7 +1095,7 @@ export function Security() {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Security Audit Report - ${report.hostname || "ProxMenux"}</title>
<title>Security Audit Report - ${esc(report.hostname || "ProxMenux")}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
@@ -1206,8 +1290,8 @@ function pmxPrint(){
</div>
</div>
<div class="rpt-header-right">
<div><strong>Date:</strong> ${now}</div>
<div><strong>Auditor:</strong> Lynis ${report.lynis_version || ""}</div>
<div><strong>Date:</strong> ${esc(now)}</div>
<div><strong>Auditor:</strong> Lynis ${esc(report.lynis_version || "")}</div>
<div class="rid">ID: PMXA-${Date.now().toString(36).toUpperCase()}</div>
</div>
</div>
@@ -1223,8 +1307,8 @@ function pmxPrint(){
<div class="exec-text">
<h3>System Hardening Assessment${hasAdjustment ? " (Proxmox Adjusted)" : ""}</h3>
<p>
Audit of <strong>${report.hostname || "Unknown"}</strong>
running <strong>${report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "Unknown OS"}</strong> (Proxmox VE).
Audit of <strong>${esc(report.hostname || "Unknown")}</strong>
running <strong>${esc(report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "Unknown OS")}</strong> (Proxmox VE).
${report.tests_performed} tests executed.
${actionableWarnings > 0 ? `<strong style="color:#dc2626;">${actionableWarnings} actionable warning(s)</strong>` : '<strong style="color:#16a34a;">No actionable warnings</strong>'}
and <strong style="color:${actionableSuggestions > 0 ? '#ca8a04' : '#16a34a'};">${actionableSuggestions} actionable suggestion(s)</strong>.
@@ -1249,11 +1333,11 @@ function pmxPrint(){
<div class="section">
<div class="section-title">2. System Information</div>
<div class="grid-3">
<div class="card"><div class="card-label">Hostname</div><div class="card-value">${report.hostname || "N/A"}</div></div>
<div class="card"><div class="card-label">Operating System</div><div class="card-value">${report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "N/A"}</div></div>
<div class="card"><div class="card-label">Kernel</div><div class="card-value">${report.kernel_version || "N/A"}</div></div>
<div class="card"><div class="card-label">Lynis Version</div><div class="card-value">${report.lynis_version || "N/A"}</div></div>
<div class="card"><div class="card-label">Report Date</div><div class="card-value">${report.datetime_start ? report.datetime_start.replace("T", " ").substring(0, 16) : "N/A"}</div></div>
<div class="card"><div class="card-label">Hostname</div><div class="card-value">${esc(report.hostname || "N/A")}</div></div>
<div class="card"><div class="card-label">Operating System</div><div class="card-value">${esc(report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "N/A")}</div></div>
<div class="card"><div class="card-label">Kernel</div><div class="card-value">${esc(report.kernel_version || "N/A")}</div></div>
<div class="card"><div class="card-label">Lynis Version</div><div class="card-value">${esc(report.lynis_version || "N/A")}</div></div>
<div class="card"><div class="card-label">Report Date</div><div class="card-value">${esc(report.datetime_start ? report.datetime_start.replace("T", " ").substring(0, 16) : "N/A")}</div></div>
<div class="card"><div class="card-label">Tests Performed</div><div class="card-value">${report.tests_performed}</div></div>
</div>
</div>
@@ -1293,7 +1377,7 @@ function pmxPrint(){
</div>
<div class="card card-c">
<div class="card-label">Installed Packages</div>
<div class="card-value" style="font-size:13px;">${report.installed_packages || "N/A"}</div>
<div class="card-value" style="font-size:13px;">${esc(report.installed_packages || "N/A")}</div>
</div>
</div>
</div>
@@ -1308,14 +1392,14 @@ function pmxPrint(){
<div class="finding ${w.proxmox_expected ? 'f-pve' : 'f-warn'}">
<div class="f-hdr">
<span class="f-num">#${i + 1}</span>
<span class="f-id${w.proxmox_expected ? ' pve' : ''}">${w.test_id}</span>
<span class="f-id${w.proxmox_expected ? ' pve' : ''}">${esc(w.test_id)}</span>
${w.proxmox_expected ? '<span class="f-tag f-tag-pve">PVE Expected</span>' : ''}
${!w.proxmox_expected && w.proxmox_severity === "low" ? '<span class="f-tag f-tag-low">Low Risk</span>' : ''}
${!w.proxmox_expected && !w.proxmox_severity && w.severity ? `<span class="f-tag f-tag-sev">${w.severity}</span>` : ""}
${!w.proxmox_expected && !w.proxmox_severity && w.severity ? `<span class="f-tag f-tag-sev">${esc(w.severity)}</span>` : ""}
</div>
<div class="f-desc">${w.description}</div>
${w.proxmox_context ? `<div class="f-ctx"><strong>Proxmox:</strong> ${w.proxmox_context}</div>` : ""}
${w.solution ? `<div class="f-sol"><strong>Recommendation:</strong> ${w.solution}</div>` : ""}
<div class="f-desc">${esc(w.description)}</div>
${w.proxmox_context ? `<div class="f-ctx"><strong>Proxmox:</strong> ${esc(w.proxmox_context)}</div>` : ""}
${w.solution ? `<div class="f-sol"><strong>Recommendation:</strong> ${esc(w.solution)}</div>` : ""}
</div>`).join("")}
</div>
@@ -1329,14 +1413,14 @@ function pmxPrint(){
<div class="finding ${s.proxmox_expected ? 'f-pve' : 'f-sugg'}">
<div class="f-hdr">
<span class="f-num">#${i + 1}</span>
<span class="f-id${s.proxmox_expected ? ' pve' : ''}">${s.test_id}</span>
<span class="f-id${s.proxmox_expected ? ' pve' : ''}">${esc(s.test_id)}</span>
${s.proxmox_expected ? '<span class="f-tag f-tag-pve">PVE Expected</span>' : ''}
${!s.proxmox_expected && s.proxmox_severity === "low" ? '<span class="f-tag f-tag-low">Low Priority</span>' : ''}
</div>
<div class="f-desc">${s.description}</div>
${s.proxmox_context ? `<div class="f-ctx"><strong>Proxmox:</strong> ${s.proxmox_context}</div>` : ""}
${s.solution ? `<div class="f-sol"><strong>Recommendation:</strong> ${s.solution}</div>` : ""}
${s.details ? `<div class="f-det">${s.details}</div>` : ""}
<div class="f-desc">${esc(s.description)}</div>
${s.proxmox_context ? `<div class="f-ctx"><strong>Proxmox:</strong> ${esc(s.proxmox_context)}</div>` : ""}
${s.solution ? `<div class="f-sol"><strong>Recommendation:</strong> ${esc(s.solution)}</div>` : ""}
${s.details ? `<div class="f-det">${esc(s.details)}</div>` : ""}
</div>`).join("")}
</div>
@@ -1349,7 +1433,7 @@ ${(report.sections && report.sections.length > 0) ? `
<div style="margin-bottom:10px;page-break-inside:avoid;">
<div class="cat-head">
<span class="cat-num">${sIdx + 1}</span>
<span class="cat-name">${section.name}</span>
<span class="cat-name">${esc(section.name)}</span>
<span class="cat-cnt">${section.checks.length} checks</span>
</div>
<table class="chk-tbl">
@@ -1363,8 +1447,8 @@ ${(report.sections && report.sections.length > 0) ? `
const color = isWarn ? "#dc2626" : isSugg ? "#ca8a04" : isOk ? "#16a34a" : "#64748b"
const cls = isWarn ? ' class="warn"' : isSugg ? ' class="sugg"' : ""
return `<tr${cls}>
<td>${check.name}${check.detail ? ` <span class="chk-det">(${check.detail})</span>` : ""}</td>
<td style="color:${color};">${check.status}</td>
<td>${esc(check.name)}${check.detail ? ` <span class="chk-det">(${esc(check.detail)})</span>` : ""}</td>
<td style="color:${color};">${esc(check.status)}</td>
</tr>`
}).join("")}
</tbody>
@@ -1374,8 +1458,8 @@ ${(report.sections && report.sections.length > 0) ? `
<!-- Footer -->
<div class="rpt-footer">
<div>Generated by ProxMenux Monitor / Lynis ${report.lynis_version || ""}</div>
<div>${now}</div>
<div>Generated by ProxMenux Monitor / Lynis ${esc(report.lynis_version || "")}</div>
<div>${esc(now)}</div>
<div style="font-style:italic;">Confidential</div>
</div>
@@ -1395,8 +1479,8 @@ ${(report.sections && report.sections.length > 0) ? `
setProxmoxCertAvailable(data.proxmox_available || false)
setProxmoxCertInfo(data.cert_info || null)
}
} catch {
// Silently fail
} catch (err) {
console.error("[security] Failed to load SSL status:", err)
} finally {
setLoadingSsl(false)
}
@@ -1770,7 +1854,9 @@ ${(report.sections && report.sections.length > 0) ? `
{show2FADisable && (
<div className="space-y-4 border border-border rounded-lg p-4">
<h3 className="font-semibold">Disable Two-Factor Authentication</h3>
<p className="text-sm text-muted-foreground">Enter your password to confirm</p>
<p className="text-sm text-muted-foreground">
Enter your password and a current 2FA code (or one of your backup codes) to confirm.
</p>
<div className="space-y-2">
<Label htmlFor="disable-2fa-password">Password</Label>
@@ -1788,6 +1874,20 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
</div>
<div className="space-y-2">
<Label htmlFor="disable-2fa-totp">2FA code or backup code</Label>
<Input
id="disable-2fa-totp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code or backup code"
value={disable2FATotpCode}
onChange={(e) => setDisable2FATotpCode(e.target.value)}
disabled={loading}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleDisable2FA} variant="destructive" className="flex-1" disabled={loading}>
{loading ? "Disabling..." : "Disable 2FA"}
@@ -1796,6 +1896,7 @@ ${(report.sections && report.sections.length > 0) ? `
onClick={() => {
setShow2FADisable(false)
setDisable2FAPassword("")
setDisable2FATotpCode("")
setError("")
}}
variant="outline"
@@ -2068,7 +2169,19 @@ ${(report.sections && report.sections.length > 0) ? `
<li>Tokens are valid for 1 year</li>
<li>Use them to access APIs from external services</li>
<li>{'Include in Authorization header: Bearer YOUR_TOKEN'}</li>
<li>See README.md for complete integration examples</li>
<li>
See the{" "}
<a
href="https://proxmenux.com/docs/monitor/integrations"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-200 hover:text-blue-100 underline underline-offset-2"
>
integrations guide
<ExternalLink className="h-3 w-3" />
</a>{" "}
for complete examples
</li>
</ul>
</div>
</div>
+425 -12
View File
@@ -2,8 +2,10 @@
import { useState, useEffect } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy } from "lucide-react"
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle } from "lucide-react"
import { NotificationSettings } from "./notification-settings"
import { HealthThresholds } from "./health-thresholds"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
import { Input } from "./ui/input"
@@ -190,6 +192,21 @@ interface ProxMenuxTool {
name: string
enabled: boolean
version?: string
// Sprint 12B: post-install function update fields. The version above is
// what the user has installed; available_version is what the on-disk
// post-install script declares. has_update is set when the latter is
// higher than the former. update_source_certain is false for legacy
// tools that lack a recorded source — the UI must let the user pick
// auto vs custom before re-running. `function` is the bash function
// name the wrapper script should invoke for the chosen source.
available_version?: string
description?: string
source?: string // "auto" | "custom" | ""
function?: string
function_auto?: string
function_custom?: string
has_update?: boolean
update_source_certain?: boolean
has_source?: boolean
deprecated?: boolean
}
@@ -222,21 +239,40 @@ interface NetworkInterface {
export function Settings() {
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
const [loadingTools, setLoadingTools] = useState(true)
// Sprint 12B: multi-select modal state. Tracks which tools the user
// has marked for batch update + the open/closed state of the dialog.
const [updateModalOpen, setUpdateModalOpen] = useState(false)
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
// Sprint 12B: script terminal modal — running one or many post-install
// function updates. `params` is what gets handed to flask_script_runner
// (becomes env vars for update_post_install_function.sh).
const [updateTerminal, setUpdateTerminal] = useState<{
open: boolean
title: string
description: string
params: Record<string, string>
} | null>(null)
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
// Code viewer modal state
// Code viewer modal state. `version` is the version the user has
// installed (read from installed_tools.json); `availableVersion` is
// what the on-disk script declares — they differ when an update is
// pending. Sprint 12B v2 tweak: the header now shows both so the user
// can see at a glance what they have and what they'd get.
const [codeModal, setCodeModal] = useState<{
open: boolean
loading: boolean
toolName: string
version: string
availableVersion: string
functionName: string
source: string
script: string
error: string
deprecated: boolean
}>({ open: false, loading: false, toolName: '', version: '', functionName: '', source: '', script: '', error: '', deprecated: false })
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
const [codeCopied, setCodeCopied] = useState(false)
// Health Monitor suppression settings
@@ -258,12 +294,52 @@ export function Settings() {
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
const [savingInterface, setSavingInterface] = useState<string | null>(null)
// Sprint 13 / issue #195: snippets storage selector. The bash helper
// resolves it on first GPU passthrough and saves to config.json; this
// card surfaces the same setting so the user can see/change it from
// the Monitor without touching JSON or running bash interactively.
const [snippetsStorage, setSnippetsStorage] = useState<string>("")
const [snippetsCandidates, setSnippetsCandidates] = useState<Array<{ name: string; type: string; active: boolean }>>([])
const [snippetsSaving, setSnippetsSaving] = useState(false)
const loadSnippetsStorage = async () => {
try {
const data = await fetchApi("/api/proxmenux/snippets-storage")
if (data.success) {
setSnippetsStorage(data.selected || "")
setSnippetsCandidates(data.candidates || [])
}
} catch (err) {
console.error("Failed to load snippets storage candidates:", err)
}
}
const saveSnippetsStorage = async (storage: string) => {
if (!storage || storage === snippetsStorage) return
setSnippetsSaving(true)
try {
const data = await fetchApi("/api/proxmenux/snippets-storage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ storage }),
})
if (data.success) {
setSnippetsStorage(storage)
}
} catch (err) {
console.error("Failed to save snippets storage:", err)
} finally {
setSnippetsSaving(false)
}
}
useEffect(() => {
loadProxmenuxTools()
getUnitsSettings()
loadHealthSettings()
loadRemoteStorages()
loadNetworkInterfaces()
loadSnippetsStorage()
}, [])
const loadProxmenuxTools = async () => {
@@ -271,6 +347,9 @@ export function Settings() {
const data = await fetchApi("/api/proxmenux/installed-tools")
if (data.success) {
setProxmenuxTools(data.installed_tools || [])
// Sprint 12B: backend computes the count, no need to derive it
// from has_update on every render.
setUpdatesAvailableCount(data.updates_available_count || 0)
}
} catch (err) {
console.error("Failed to load ProxMenux tools:", err)
@@ -279,8 +358,92 @@ export function Settings() {
}
}
// Sprint 12B: launch the script terminal for one or many post-install
// function updates. `entries` is a list of (source, function, key)
// triples joined into the FUNCTIONS_BATCH env var the wrapper script
// understands. After the terminal closes we reload the tools list so
// the freshly-applied versions are reflected in the cards.
const runPostInstallUpdates = (entries: Array<{ source: string; function: string; key: string; name: string }>) => {
if (entries.length === 0) return
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
const title = entries.length === 1
? `Update: ${entries[0].name}`
: `Update ${entries.length} optimizations`
const description = entries.length === 1
? `Re-running ${entries[0].function} from the ${entries[0].source} flow.`
: `Re-running ${entries.length} post-install functions in sequence.`
setUpdateTerminal({
open: true,
title,
description,
params: {
EXECUTION_MODE: "web",
FUNCTIONS_BATCH: batch,
},
})
}
const closeUpdateTerminal = async () => {
setUpdateTerminal(null)
// Sprint 12B v2: force the server-side rescan FIRST, then refetch
// the tools list. The previous order (fetch + scan in parallel)
// raced — the fetch returned the stale cache before the scan had a
// chance to update it, so the badge and the purple cards stuck
// around until the user hit refresh. Backend's _ensure_fresh_cache
// also auto-rescans on file mtime change, but we keep the explicit
// POST here as a belt-and-braces signal that an update just landed.
try {
await fetchApi("/api/updates/post-install/scan", { method: "POST" })
} catch {
// Auto-refresh on the next read path will still pick up the
// change via _ensure_fresh_cache — this catch is just to keep
// the close flow non-blocking on transient errors.
}
loadProxmenuxTools()
}
// Sprint 12B v2: click on a tool's update icon → run the update
// straight away. If the tool's source is recorded (modern entries) we
// re-run that flow; otherwise (legacy bool entries from before Sprint
// 12A) we default to `auto`. Per user feedback the previous "pick
// auto/custom" picker was confusing — the system already knows the
// available version, and updating doesn't need to ask which flavour
// to install in. The user can always re-install via the
// customizable post-install flow if they want different parameters.
const handleSingleToolUpdate = (tool: ProxMenuxTool) => {
if (!tool.has_update) return
const source = tool.source || "auto"
runPostInstallUpdates([{
source,
function: deriveFunctionName(tool, source),
key: tool.key,
name: tool.name,
}])
}
// Backend exposes both function_auto and function_custom per tool so
// that legacy bool entries (where the user picks the source at update
// time) can route to the correct function in the chosen flow.
// When the source is recorded, `function` is already correct.
const deriveFunctionName = (tool: ProxMenuxTool, source: string): string => {
if (source === "auto") return tool.function_auto || tool.function || ""
if (source === "custom") return tool.function_custom || tool.function || ""
return tool.function || ""
}
const viewToolSource = async (tool: ProxMenuxTool) => {
setCodeModal({ open: true, loading: true, toolName: tool.name, version: tool.version || '1.0', functionName: '', source: '', script: '', error: '', deprecated: !!tool.deprecated })
setCodeModal({
open: true,
loading: true,
toolName: tool.name,
version: tool.version || '1.0',
availableVersion: tool.available_version || tool.version || '1.0',
functionName: '',
source: '',
script: '',
error: '',
deprecated: !!tool.deprecated,
})
try {
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
if (data.success) {
@@ -819,13 +982,14 @@ export function Settings() {
{remoteStorages.map((storage) => {
const isExcluded = storage.exclude_health || storage.exclude_notifications
const isSaving = savingStorage === storage.name
const isOffline = storage.status === 'error' || storage.total === 0
const isNamespaceRestricted = storage.status === 'namespace_restricted'
const isOffline = !isNamespaceRestricted && (storage.status === 'error' || storage.total === 0)
return (
<div key={storage.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-2 h-2 rounded-full shrink-0 ${
isOffline ? 'bg-red-500' : 'bg-green-500'
isOffline ? 'bg-red-500' : isNamespaceRestricted ? 'bg-blue-400' : 'bg-green-500'
}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
@@ -837,6 +1001,9 @@ export function Settings() {
{isOffline && (
<p className="text-[11px] text-red-400 mt-0.5">Offline or unavailable</p>
)}
{isNamespaceRestricted && (
<p className="text-[11px] text-blue-400 mt-0.5">Reachable; datastore size hidden by ACL</p>
)}
</div>
</div>
@@ -1023,9 +1190,64 @@ export function Settings() {
</CardContent>
</Card>
{/* Health Monitor Thresholds — placed above Notifications because the
values configured here drive what triggers the notifications below. */}
<HealthThresholds />
{/* Notification Settings */}
<NotificationSettings />
{/* Issue #195: snippets storage selector. Only renders when more
than one storage advertises content=snippets — on a typical
standalone host with just `local` there's nothing to choose,
so showing an empty selector would be noise. */}
{snippetsCandidates.length > 1 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-cyan-500" />
<CardTitle>Snippets storage</CardTitle>
</div>
<CardDescription>
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs).
Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes
<code className="mx-1">local</code>
is node-specific and breaks migration.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row md:items-center gap-3">
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
<SelectTrigger className="w-full md:w-72">
<SelectValue placeholder="Pick a storage…" />
</SelectTrigger>
<SelectContent>
{snippetsCandidates.map(c => (
<SelectItem key={c.name} value={c.name} disabled={!c.active}>
{c.name}
<span className="ml-2 text-xs text-muted-foreground">
{c.type}{!c.active && " · inactive"}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{snippetsSaving && (
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-3">
Existing VMs/LXCs already configured with the previous storage keep working.
Only new GPU passthrough operations (or running &quot;sync hookscripts&quot; on the host)
will use the new selection.
</p>
</CardContent>
</Card>
)}
{/* ProxMenux Optimizations */}
<Card>
<CardHeader>
@@ -1050,21 +1272,59 @@ export function Settings() {
<div className="space-y-2">
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Installed Tools</span>
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
{/* Sprint 12B: count badge that doubles as the trigger
for the multi-select update modal. Only shown when
at least one tool has an available update. */}
{updatesAvailableCount > 0 && (
<button
onClick={() => {
// Sprint 12B v2: pre-select every available
// update. The user clicks the badge already
// intending to apply them — defaulting to all
// saves a tick when the common case is "update
// everything".
const initial = new Set<string>(
proxmenuxTools.filter(t => t.has_update).map(t => t.key)
)
setSelectedUpdates(initial)
setUpdateModalOpen(true)
}}
className="flex items-center gap-1.5 text-xs font-semibold text-purple-300 bg-purple-500/15 border border-purple-500/40 hover:bg-purple-500/25 transition-colors rounded-full px-3 py-1"
title="View available updates"
>
<Sparkles className="h-3.5 w-3.5" />
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'update' : 'updates'}
</button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{proxmenuxTools.map((tool) => {
const clickable = !!tool.has_source
const isDeprecated = !!tool.deprecated
// Sprint 12B: the card turns purple-tinted when an
// update is available — replaces the normal muted
// styling so the user sees at a glance which tools
// need attention. Click on the body still opens the
// source viewer; the small ArrowUpCircle on the right
// is the dedicated update trigger.
const hasUpdate = !!tool.has_update
const baseClasses = hasUpdate
? 'border-purple-500/40 bg-purple-500/10 hover:bg-purple-500/20 hover:border-purple-500/60'
: 'bg-muted/50 border-border hover:bg-muted hover:border-orange-500/40'
return (
<div
key={tool.key}
onClick={clickable ? () => viewToolSource(tool) : undefined}
className={`flex items-center justify-between gap-2 p-3 bg-muted/50 rounded-lg border border-border transition-colors ${clickable ? 'hover:bg-muted hover:border-orange-500/40 cursor-pointer' : ''}`}
className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`}
title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${isDeprecated ? 'bg-amber-500' : 'bg-green-500'}`} />
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
hasUpdate ? 'bg-purple-400' : (isDeprecated ? 'bg-amber-500' : 'bg-green-500')
}`} />
<span className="text-sm font-medium truncate">{tool.name}</span>
{isDeprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
@@ -1072,7 +1332,24 @@ export function Settings() {
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono flex-shrink-0">v{tool.version || '1.0'}</span>
<div className="flex items-center gap-2 flex-shrink-0">
{hasUpdate ? (
<>
<span className="text-[10px] text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
v{tool.version || '1.0'} v{tool.available_version || '?'}
</span>
<button
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
className="text-purple-300 hover:text-purple-200 transition-colors"
title={`Update ${tool.name} to v${tool.available_version}`}
>
<ArrowUpCircle className="h-4 w-4" />
</button>
</>
) : (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono">v{tool.version || '1.0'}</span>
)}
</div>
</div>
)
})}
@@ -1106,7 +1383,17 @@ export function Settings() {
<p className="text-xs text-muted-foreground">
{codeModal.functionName && <span className="font-mono">{codeModal.functionName}()</span>}
{codeModal.script && <span> {codeModal.script}</span>}
{codeModal.version && <span className="ml-2 bg-muted px-1.5 py-0.5 rounded font-mono">v{codeModal.version}</span>}
{/* Sprint 12B v2: when an update is pending the user
sees `v1.0 → v1.1` so the source viewer matches
the badge in the card. When no update, just the
single installed version. */}
{codeModal.version && codeModal.availableVersion && codeModal.availableVersion !== codeModal.version ? (
<span className="ml-2 bg-purple-500/15 text-purple-300 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
v{codeModal.version} v{codeModal.availableVersion}
</span>
) : codeModal.version ? (
<span className="ml-2 bg-muted px-1.5 py-0.5 rounded font-mono">v{codeModal.version}</span>
) : null}
</p>
</div>
</div>
@@ -1151,6 +1438,132 @@ export function Settings() {
</div>
</div>
)}
{/* Sprint 12B: multi-select Update modal — opened from the
"X updates" badge in the Optimizations card header. The user
ticks the tools they want to update, hits Update Selected,
and the wrapper script runs them all in one terminal session. */}
{updateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setUpdateModalOpen(false)}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-3">
<Sparkles className="h-5 w-5 text-purple-400" />
<div>
<h3 className="text-sm font-semibold">Available updates</h3>
<p className="text-xs text-muted-foreground">
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'optimization' : 'optimizations'} can be updated to a newer version.
</p>
</div>
</div>
<button
onClick={() => setUpdateModalOpen(false)}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-2">
{/* Sprint 12B v2: every row is selectable. Legacy bool
entries (no recorded source) default to the auto flow
on update — the previous "pick source first" path
required an extra click for what is in practice always
the same answer. */}
{proxmenuxTools.filter(t => t.has_update).map(tool => {
const isSelected = selectedUpdates.has(tool.key)
return (
<label
key={tool.key}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
isSelected
? 'border-purple-500/50 bg-purple-500/10'
: 'border-border bg-muted/40 hover:bg-muted/60'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const next = new Set(selectedUpdates)
if (e.target.checked) next.add(tool.key); else next.delete(tool.key)
setSelectedUpdates(next)
}}
className="mt-1 h-4 w-4 accent-purple-500 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{tool.name}</span>
<span className="text-[10px] text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
v{tool.version || '1.0'} v{tool.available_version || '?'}
</span>
</div>
{tool.description && (
<p className="text-xs text-muted-foreground mt-1 leading-snug">{tool.description}</p>
)}
</div>
</label>
)
})}
</div>
<div className="flex items-center justify-between p-4 border-t border-border">
<span className="text-xs text-muted-foreground">
{selectedUpdates.size} of {updatesAvailableCount} selected
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setUpdateModalOpen(false)}
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
>
Cancel
</button>
<button
disabled={selectedUpdates.size === 0}
onClick={() => {
const entries = proxmenuxTools
.filter(t => selectedUpdates.has(t.key))
.map(t => ({
source: t.source || 'auto',
function: deriveFunctionName(t, t.source || 'auto'),
key: t.key,
name: t.name,
}))
.filter(e => !!e.function)
setUpdateModalOpen(false)
setSelectedUpdates(new Set())
runPostInstallUpdates(entries)
}}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
>
<ArrowUpCircle className="h-3.5 w-3.5" />
Update selected
</button>
</div>
</div>
</div>
</div>
)}
{/* Sprint 12B: terminal that runs the update_post_install_function.sh
wrapper. The wrapper sources the chosen flow script and invokes
one or many functions in sequence (FUNCTIONS_BATCH). On close
we refresh the tools list so the new versions show up. */}
{updateTerminal?.open && (
<ScriptTerminalModal
open={updateTerminal.open}
onClose={closeUpdateTerminal}
scriptPath="/usr/local/share/proxmenux/scripts/post_install/update_post_install_function.sh"
scriptName="update_post_install_function"
title={updateTerminal.title}
description={updateTerminal.description}
params={updateTerminal.params}
/>
)}
</div>
)
}
-2
View File
@@ -28,7 +28,6 @@ interface DiskInfo {
const fetchStorageData = async (): Promise<StorageData | null> => {
try {
console.log("[v0] Fetching storage data from Flask server...")
const response = await fetch("/api/storage", {
method: "GET",
headers: {
@@ -42,7 +41,6 @@ const fetchStorageData = async (): Promise<StorageData | null> => {
}
const data = await response.json()
console.log("[v0] Successfully fetched storage data from Flask:", data)
return data
} catch (error) {
console.error("[v0] Failed to fetch storage data from Flask server:", error)
+671 -94
View File
@@ -1,13 +1,21 @@
"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { fetchApi } from "../lib/api-config"
import { DiskTemperatureDetailModal } from "./disk-temperature-detail-modal"
import { DiskTemperatureCard } from "./disk-temperature-card"
import {
useDiskTempThresholds,
loadDiskTempThresholds,
getDiskTempThresholdsSync,
type DiskTempMap,
} from "../lib/health-thresholds"
interface DiskInfo {
name: string
@@ -101,6 +109,38 @@ interface ProxmoxStorageData {
error?: string
}
// Sprint 13: shape returned by /api/mounts. Lists every NFS/CIFS/SMB
// mount on the host with a per-mount health status — complements the
// PVE-storage list above with arbitrary mounts done outside PVE
// (fstab, manual `mount` commands).
interface RemoteMount {
source: string
target: string
fstype: string
options: string
readonly: boolean
reachable: boolean
error?: string | null
status: "ok" | "stale" | "readonly"
// Sprint 13.16: extra fields the modal renders. Backend fills them
// when the mount is reachable; nullable when df couldn't run (stale).
proxmox_managed?: boolean
total_bytes?: number | null
used_bytes?: number | null
available_bytes?: number | null
// Sprint 13.24: present only on LXC-internal mounts.
lxc_id?: string
lxc_name?: string
lxc_pid?: string
}
interface RemoteMountsData {
mounts: RemoteMount[]
lxc_mounts?: RemoteMount[]
available: boolean
error?: string
}
const formatStorage = (sizeInGB: number): string => {
if (sizeInGB < 1) {
// Less than 1 GB, show in MB
@@ -114,8 +154,19 @@ const formatStorage = (sizeInGB: number): string => {
}
export function StorageOverview() {
// User-configurable disk temperature thresholds (Settings → Health
// Monitor Thresholds). Until the API responds the hook returns
// sensible defaults from `lib/health-thresholds`, so first paint
// never blocks on the network.
const dtThresholds = useDiskTempThresholds()
const [storageData, setStorageData] = useState<StorageData | null>(null)
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
const [remoteMounts, setRemoteMounts] = useState<RemoteMount[]>([])
// Sprint 13.19: detail modal for a single remote mount. Tracks the
// mount object itself rather than just an id so a stale data fetch
// can't leave the modal showing nothing.
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
const [loading, setLoading] = useState(true)
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
@@ -130,16 +181,21 @@ export function StorageOverview() {
history?: Array<{ filename: string; timestamp: string; test_type: string; date_readable: string }>
} | null>(null)
const [loadingSmartJson, setLoadingSmartJson] = useState(false)
const [tempHistoryDisk, setTempHistoryDisk] = useState<DiskInfo | null>(null)
const fetchStorageData = async () => {
try {
const [data, proxmoxData] = await Promise.all([
const [data, proxmoxData, mountsData] = await Promise.all([
fetchApi<StorageData>("/api/storage"),
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
// so a failure here doesn't blank the whole storage tab.
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
])
setStorageData(data)
setProxmoxStorage(proxmoxData)
setRemoteMounts(mountsData?.mounts || [])
} catch (error) {
console.error("Error fetching storage data:", error)
} finally {
@@ -190,37 +246,19 @@ export function StorageOverview() {
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
if (temp === 0) return "text-gray-500"
// Determinar el tipo de disco
let diskType = "HDD" // Por defecto
if (diskName) {
if (diskName.startsWith("nvme")) {
diskType = "NVMe"
} else if (!rotationRate || rotationRate === 0) {
diskType = "SSD"
}
}
// Aplicar rangos de temperatura según el tipo
switch (diskType) {
case "NVMe":
// NVMe: ≤70°C verde, 71-80°C amarillo, >80°C rojo
if (temp <= 70) return "text-green-500"
if (temp <= 80) return "text-yellow-500"
return "text-red-500"
case "SSD":
// SSD: ≤59°C verde, 60-70°C amarillo, >70°C rojo
if (temp <= 59) return "text-green-500"
if (temp <= 70) return "text-yellow-500"
return "text-red-500"
case "HDD":
default:
// HDD: ≤45°C verde, 46-55°C amarillo, >55°C rojo
if (temp <= 45) return "text-green-500"
if (temp <= 55) return "text-yellow-500"
return "text-red-500"
// Resolve disk class → threshold pair from the user-configurable
// backend (single source of truth). The semantics: temp BELOW warn
// is green, between warn and hot is amber, hot or above is red.
let cls: keyof DiskTempMap = "HDD"
if (diskName?.startsWith("nvme")) {
cls = "NVMe"
} else if (!rotationRate || rotationRate === 0) {
cls = "SSD"
}
const t = dtThresholds[cls]
if (temp >= t.hot) return "text-red-500"
if (temp >= t.warn) return "text-yellow-500"
return "text-green-500"
}
const formatHours = (hours: number) => {
@@ -344,7 +382,16 @@ export function StorageOverview() {
nfs: "bg-orange-500/10 text-orange-500 border-orange-500/20",
cifs: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
}
return typeColors[type.toLowerCase()] || "bg-gray-500/10 text-gray-500 border-gray-500/20"
// Sprint 13: /proc/mounts reports `nfs4`, `cifs2`, `smb3`, `smbfs`,
// etc. PVE storage types are clean (`nfs`, `cifs`) but the kernel
// mount types carry version suffixes. Match the family so the
// Remote Mounts list shows the same colour as the matching PVE
// storage row instead of falling through to the grey default.
const lower = type.toLowerCase()
if (typeColors[lower]) return typeColors[lower]
if (lower.startsWith("nfs")) return typeColors.nfs
if (lower.startsWith("cifs") || lower.startsWith("smb")) return typeColors.cifs
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
const getStatusIcon = (status: string) => {
@@ -352,6 +399,8 @@ export function StorageOverview() {
case "active":
case "online":
return <CheckCircle2 className="h-5 w-5 text-green-500" />
case "namespace_restricted":
return <CheckCircle2 className="h-5 w-5 text-blue-400" />
case "inactive":
case "offline":
return <Square className="h-5 w-5 text-gray-500" />
@@ -401,9 +450,12 @@ export function StorageOverview() {
const wearPercent = wearIndicator.value
const hoursUsed = disk.power_on_hours
// Si el desgaste es 0, no podemos calcular
// If the drive reports zero wear we cannot extrapolate (division by zero).
// The drive is alive and healthy — return a friendlier label than "N/A",
// which users mistook for "the monitor is broken". A new drive can sit at
// 0% wear for hundreds of hours before the first measurable tick.
if (wearPercent === 0) {
return "N/A"
return "No wear detected yet"
}
// Calcular horas totales estimadas: hoursUsed / (wearPercent / 100)
@@ -733,8 +785,20 @@ export function StorageOverview() {
<Database className="h-5 w-5 text-muted-foreground" />
<h3 className="font-semibold text-lg">{storage.name}</h3>
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
{/* Sprint 13: hint that this PVE storage also
shows up below in Remote Mounts where the
user can inspect mount options + health.
Uses the default Badge size to match the
adjacent type / status badges — earlier
versions used text-[10px] which looked
shrunken next to them. */}
{/^(nfs|cifs|smb)/i.test(storage.type) && (
<Badge className="bg-cyan-500/10 text-cyan-400 border-cyan-500/20">
remote mount
</Badge>
)}
{isExcluded && (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20">
excluded
</Badge>
)}
@@ -743,6 +807,11 @@ export function StorageOverview() {
<div className="flex md:hidden items-center gap-2 flex-1">
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
{/^(nfs|cifs|smb)/i.test(storage.type) && (
<Badge className="bg-cyan-500/10 text-cyan-400 border-cyan-500/20">
remote
</Badge>
)}
<h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3>
{isExcluded ? (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20 text-[10px]">
@@ -761,12 +830,23 @@ export function StorageOverview() {
? "bg-purple-500/10 text-purple-400 border-purple-500/20"
: storage.status === "active"
? "bg-green-500/10 text-green-500 border-green-500/20"
: storage.status === "error"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
: storage.status === "namespace_restricted"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: storage.status === "error"
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
title={
storage.status === "namespace_restricted"
? "Storage reachable; datastore size hidden by ACL (e.g. PBS DatastoreAdmin on a single namespace)"
: undefined
}
>
{isExcluded ? "not monitored" : storage.status}
{isExcluded
? "not monitored"
: storage.status === "namespace_restricted"
? "namespace-restricted"
: storage.status}
</Badge>
<span className="text-sm font-medium">{storage.percent}%</span>
</div>
@@ -816,6 +896,256 @@ export function StorageOverview() {
</Card>
)}
{/* Sprint 13 — Remote Mounts (NFS/CIFS/SMB) detected on the
host. Renders only when at least one is present so a
standalone host with no shares doesn't see an empty card.
Stale mounts get a red bg + critical icon; read-only get
amber; healthy get a green dot. */}
{remoteMounts.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Remote Mounts
<Badge variant="outline" className="ml-2 text-[10px]">
{remoteMounts.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{remoteMounts
.slice()
.sort((a, b) => a.target.localeCompare(b.target))
.map((mount) => {
const isStale = mount.status === "stale"
const isReadonly = mount.status === "readonly"
const cardClasses = isStale
? "border-red-500/50 bg-red-500/5 sm:hover:bg-red-500/10"
: isReadonly
? "border-amber-500/40 bg-amber-500/5 sm:hover:bg-amber-500/10"
: "border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5"
return (
<div
key={mount.target}
onClick={() => setMountDetail(mount)}
className={`cursor-pointer border rounded-lg p-3 transition-colors ${cardClasses}`}
>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isStale ? "bg-red-500" : isReadonly ? "bg-amber-500" : "bg-green-500"
}`}
/>
<h3 className="font-mono text-sm truncate">{mount.target}</h3>
<Badge className={getStorageTypeBadge(mount.fstype)}>{mount.fstype}</Badge>
{/* Sprint 13.18: makes it explicit that the
row corresponds to an entry already in the
Proxmox Storage card above. Default size
keeps it visually consistent with the
adjacent type badge. */}
{mount.proxmox_managed && (
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
managed by Proxmox
</Badge>
)}
{mount.readonly && (
<Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20">
ro
</Badge>
)}
</div>
<Badge
className={
isStale
? "bg-red-500/10 text-red-500 border-red-500/20"
: isReadonly
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
}
>
{isStale ? "stale" : isReadonly ? "read-only" : "reachable"}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-2 truncate">
<span className="font-medium text-foreground">Source:</span>{" "}
<span className="font-mono">{mount.source || "—"}</span>
</div>
{isStale && mount.error && (
<p className="text-xs text-red-400 mt-2">{mount.error}</p>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)}
{/* Sprint 13.29: the "Remote Mounts (LXC)" card that previously
lived here was removed because the information was redundant
with the host card (the same NAS shows up twice) and a
Storage page is the wrong scope for per-CT details anyway.
LXC mount-points are now surfaced inside the LXC modal
(VMs & LXCs tab) where they belong contextually. The
backend helper `mount_monitor.scan_lxc_mounts()` is kept so
the health monitor can still alert on stale mounts inside
containers in the background. */}
{/* Sprint 13.19: remote mount detail modal.
Uses shadcn Dialog for the same typography (DialogTitle =
text-lg, DialogDescription = text-sm muted) and behaviour as
the disk details modal — earlier version was a hand-rolled
overlay with text-xs/text-[10px] all over and looked
shrunken next to the rest of the modals. */}
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
{mountDetail && (() => {
const m = mountDetail
const isStale = m.status === "stale"
const isReadonly = m.status === "readonly"
const optionEntries = (m.options || "")
.split(",")
.filter(Boolean)
.map((opt) => {
const eq = opt.indexOf("=")
if (eq === -1) return { key: opt, value: null as string | null }
return { key: opt.slice(0, eq), value: opt.slice(eq + 1) }
})
const flags = optionEntries.filter((o) => o.value === null).map((o) => o.key)
const keyValues = optionEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
const fmtBytes = (b: number | null | undefined) => {
if (b == null) return "—"
const gb = b / 1024 ** 3
return formatStorage(gb)
}
const usedPct =
m.total_bytes && m.used_bytes != null && m.total_bytes > 0
? Math.round((m.used_bytes / m.total_bytes) * 100)
: null
return (
<>
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle className="flex items-center gap-2 flex-wrap">
<Database className="h-5 w-5 text-cyan-500" />
<span className="font-mono">{m.target}</span>
<Badge
className={
isStale
? "bg-red-500/10 text-red-500 border-red-500/20"
: isReadonly
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
}
>
{isStale ? "stale" : isReadonly ? "read-only" : "reachable"}
</Badge>
</DialogTitle>
<DialogDescription>
<span className="font-mono">{m.source || "—"}</span>
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6 overflow-auto space-y-5">
{/* Type + tags */}
<div className="flex items-center gap-2 flex-wrap">
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
{m.proxmox_managed && (
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">
managed by Proxmox
</Badge>
)}
{m.lxc_id && (
<Badge className="bg-purple-500/10 text-purple-400 border-purple-500/20">
CT {m.lxc_id}{m.lxc_name ? `: ${m.lxc_name}` : ""}
</Badge>
)}
{flags.map((f) => (
<Badge key={f} variant="outline" className="font-mono">
{f}
</Badge>
))}
</div>
{/* Capacity. df can hang on stale NFS so the backend
skips it and we render n/a here. Headers use the
same `<h4 className="font-semibold">` shape as
the disk-details modal in this same file (no
explicit text-sm override) so the typography
lines up — the body inherits text-base from the
Dialog content, not text-sm. */}
<div>
<h4 className="font-semibold mb-3">Capacity</h4>
{m.reachable && m.total_bytes ? (
<div className="space-y-2">
<Progress
value={usedPct ?? 0}
className={`h-2 ${
(usedPct ?? 0) > 90
? "[&>div]:bg-red-500"
: (usedPct ?? 0) > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total</p>
<p className="font-medium">{fmtBytes(m.total_bytes)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Used</p>
<p className="font-medium">
{fmtBytes(m.used_bytes)} {usedPct != null && `(${usedPct}%)`}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Available</p>
<p className="font-medium">{fmtBytes(m.available_bytes)}</p>
</div>
</div>
</div>
) : (
<p className="text-muted-foreground">
{isStale ? "df skipped: mount is stale." : "n/a"}
</p>
)}
</div>
{/* Mount options grid — readable parse of the
key=value list from /proc/mounts so the user
doesn't have to scan a 200-char string. */}
{keyValues.length > 0 && (
<div>
<h4 className="font-semibold mb-3">Mount options</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1.5">
{keyValues.map((kv) => (
<div key={kv.key} className="flex items-baseline gap-2 min-w-0">
<span className="font-mono text-muted-foreground truncate">{kv.key}</span>
<span className="font-mono text-foreground truncate">= {kv.value}</span>
</div>
))}
</div>
</div>
)}
{/* Error — only renders when something is wrong. */}
{m.error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 p-3">
<h4 className="font-semibold text-red-400 mb-2">Error</h4>
<p className="text-red-300 font-mono whitespace-pre-wrap break-all">
{m.error}
</p>
</div>
)}
</div>
</>
)
})()}
</DialogContent>
</Dialog>
{/* ZFS Pools */}
{storageData.zfs_pools && storageData.zfs_pools.length > 0 && (
<Card>
@@ -1434,6 +1764,31 @@ export function StorageOverview() {
// --- Only render if we have meaningful wear data ---
if (wearUsed === null && lifeRemaining === null) return null
// Sprint 14 honest-data fix: a `percent_used == 0` from
// firmwares like the WD CL SN720 isn't real wear data —
// the drive simply hasn't started ticking. We don't want
// to assert "100% life remaining" in that case. Show
// only Data Written, since that's the one number we
// know we can trust for these drives.
const hasReportedWear = (wearUsed !== null && wearUsed > 0)
if (!hasReportedWear) {
if (!dataWritten) return null
return (
<div className="border-t pt-4">
<h4 className="font-semibold mb-3 flex items-center gap-2">
Wear & Lifetime
</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground">Data Written</p>
<p className="text-sm font-medium">{dataWritten}</p>
</div>
</div>
</div>
)
}
const lifeColor = lifeRemaining !== null
? (lifeRemaining >= 50 ? '#22c55e' : lifeRemaining >= 20 ? '#eab308' : '#ef4444')
: '#6b7280'
@@ -1459,7 +1814,16 @@ export function StorageOverview() {
</div>
)}
<div className="flex-1 space-y-3 min-w-0">
{wearUsed !== null && (
{/*
Hide the "Wear" bar and "Est. Life" entirely when the
drive firmware reports zero wear (some NVMe families
like the WD SN720 don't tick percentage_used until
significant wear is reached). The 100% life ring + the
Avail. Spare and Data Written numbers are enough to
convey "drive is healthy without any reportable wear
data" — repeating "0%" three times is just visual noise.
*/}
{wearUsed !== null && wearUsed > 0 && (
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs text-muted-foreground">Wear</p>
@@ -1469,7 +1833,7 @@ export function StorageOverview() {
</div>
)}
<div className="grid grid-cols-2 gap-3">
{estimatedLife && (
{estimatedLife && wearUsed !== null && wearUsed > 0 && (
<div>
<p className="text-xs text-muted-foreground">Est. Life</p>
<p className="text-sm font-medium">{estimatedLife}</p>
@@ -1496,15 +1860,22 @@ export function StorageOverview() {
<div className="border-t pt-4">
<h4 className="font-semibold mb-3">SMART Attributes</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Temperature</p>
<p
className={`font-medium ${getTempColor(selectedDisk.temperature, selectedDisk.name, selectedDisk.rotation_rate)}`}
>
{selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"}
</p>
{/*
Sprint 14: temperature lives in its own full-width card
with an inline 1-hour mini chart. The remaining attributes
flow below in the same 2-col grid as before.
*/}
{selectedDisk.connection_type !== 'usb' && (
<div className="mb-4">
<DiskTemperatureCard
diskName={selectedDisk.name}
liveTemperature={selectedDisk.temperature}
diskType={getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate).label}
onOpenDetail={selectedDisk.temperature > 0 ? () => setTempHistoryDisk(selectedDisk) : undefined}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Power On Hours</p>
<p className="font-medium">
@@ -1553,6 +1924,15 @@ export function StorageOverview() {
{selectedDisk.crc_errors ?? 0}
</p>
</div>
{/* USB drives lose the chart card; show plain temperature here. */}
{selectedDisk.connection_type === 'usb' && (
<div>
<p className="text-sm text-muted-foreground">Temperature</p>
<p className={`font-medium ${getTempColor(selectedDisk.temperature, selectedDisk.name, selectedDisk.rotation_rate)}`}>
{selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"}
</p>
</div>
)}
</div>
</div>
@@ -1812,12 +2192,44 @@ export function StorageOverview() {
</div>
</DialogContent>
</Dialog>
{tempHistoryDisk && (
<DiskTemperatureDetailModal
open={!!tempHistoryDisk}
onOpenChange={(o) => { if (!o) setTempHistoryDisk(null) }}
diskName={tempHistoryDisk.name}
diskModel={tempHistoryDisk.model}
liveTemperature={tempHistoryDisk.temperature}
diskType={getDiskTypeBadge(tempHistoryDisk.name, tempHistoryDisk.rotation_rate).label}
/>
)}
</div>
)
}
// Generate SMART Report HTML and open in new window (same pattern as Lynis/Latency reports)
function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttributes: SmartAttribute[], observations: DiskObservation[] = [], lastTestDate?: string, targetWindow?: Window, isHistorical = false) {
interface DiskTempHistoryPoint { timestamp: number; value: number; min?: number; max?: number }
interface DiskTempHistoryPayload { data: DiskTempHistoryPoint[]; stats: { min: number; max: number; avg: number; current: number } }
// The report wants the broadest temperature history possible, but the
// `month` bucket (2h granularity) returns <2 points on freshly-deployed
// hosts where data only spans ~1 hour. Cascade through coarser → finer
// timeframes and use the first one that yields a renderable chart.
async function fetchTempHistoryForReport(diskName: string): Promise<DiskTempHistoryPayload | undefined> {
for (const tf of ['month', 'week', 'day', 'hour']) {
try {
const result = await fetchApi<DiskTempHistoryPayload>(
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=${tf}`,
)
if (result?.data && result.data.length >= 2) return result
} catch {
/* try next */
}
}
return undefined
}
function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttributes: SmartAttribute[], observations: DiskObservation[] = [], lastTestDate?: string, targetWindow?: Window, isHistorical = false, tempHistory?: DiskTempHistoryPayload) {
const now = new Date().toLocaleString()
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
const reportId = `SMART-${Date.now().toString(36).toUpperCase()}`
@@ -2120,48 +2532,45 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
const criticalAttrs = smartAttributes.filter(a => a.status !== 'ok')
const hasCritical = criticalAttrs.length > 0
// Temperature color based on disk type
// Temperature color and threshold strings for the printable report —
// both pulled from the user-configurable backend cache so the report
// prints whatever the operator set in Settings.
const _reportThresholds = getDiskTempThresholdsSync(diskType)
const getTempColorForReport = (temp: number): string => {
if (temp <= 0) return '#94a3b8' // gray for N/A
switch (diskType) {
case 'NVMe':
// NVMe: <=70 green, 71-80 yellow, >80 red
if (temp <= 70) return '#16a34a'
if (temp <= 80) return '#ca8a04'
return '#dc2626'
case 'SSD':
// SSD: <=59 green, 60-70 yellow, >70 red
if (temp <= 59) return '#16a34a'
if (temp <= 70) return '#ca8a04'
return '#dc2626'
case 'SAS':
// SAS enterprise: <=55 green, 56-65 yellow, >65 red
if (temp <= 55) return '#16a34a'
if (temp <= 65) return '#ca8a04'
return '#dc2626'
case 'HDD':
default:
// HDD: <=45 green, 46-55 yellow, >55 red
if (temp <= 45) return '#16a34a'
if (temp <= 55) return '#ca8a04'
return '#dc2626'
}
if (temp >= _reportThresholds.hot) return '#dc2626'
if (temp >= _reportThresholds.warn) return '#ca8a04'
return '#16a34a'
}
// Temperature thresholds for display
const tempThresholds = diskType === 'NVMe'
? { optimal: '<=70°C', warning: '71-80°C', critical: '>80°C' }
: diskType === 'SSD'
? { optimal: '<=59°C', warning: '60-70°C', critical: '>70°C' }
: diskType === 'SAS'
? { optimal: '<=55°C', warning: '56-65°C', critical: '>65°C' }
: { optimal: '<=45°C', warning: '46-55°C', critical: '>55°C' }
// Temperature thresholds for display
const tempThresholds = {
optimal: `<${_reportThresholds.warn}°C`,
warning: `${_reportThresholds.warn}-${_reportThresholds.hot - 1}°C`,
critical: `${_reportThresholds.hot}°C`,
}
const isNvmeDisk = diskType === 'NVMe'
// NVMe Wear & Lifetime data
const nvmePercentUsed = testStatus.smart_data?.nvme_raw?.percent_used ?? disk.percentage_used ?? 0
const nvmeAvailSpare = testStatus.smart_data?.nvme_raw?.avail_spare ?? 100
// NVMe Wear & Lifetime data. Sprint 14 fix: the previous code used
// `?? 0` / `?? 100` as fallbacks, which made the report invent
// "100% Life Remaining" + "100% Available Spare" for drives that
// simply don't report those metrics (some early WDC SN720, some
// Samsung OEM, etc.). The dashboard modal already hides its wear
// section in that case — we mirror the same gating here so the
// printable report doesn't lie.
const nvmePercentUsedRaw = testStatus.smart_data?.nvme_raw?.percent_used ?? disk.percentage_used
const nvmeAvailSpareRaw = testStatus.smart_data?.nvme_raw?.avail_spare
// Sprint 14 honest-data fix (refined): only render the full Wear &
// Lifetime block when the firmware has actually started ticking
// percent_used. Drives like the WD CL SN720 expose `percent_used: 0`
// until significant wear is reached — treating that as "100% life
// remaining" is misleading. In that case we fall back to a minimal
// Data-Written-only block (handled separately below).
const hasNvmeWearData = (
typeof nvmePercentUsedRaw === 'number' && nvmePercentUsedRaw > 0
)
const nvmePercentUsed = nvmePercentUsedRaw ?? 0
const nvmeAvailSpare = nvmeAvailSpareRaw ?? 100
const nvmeDataWritten = testStatus.smart_data?.nvme_raw?.data_units_written ?? 0
// Data units are in 512KB blocks, convert to TB
const nvmeDataWrittenTB = (nvmeDataWritten * 512 * 1024) / (1024 * 1024 * 1024 * 1024)
@@ -2356,7 +2765,124 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
</div>
`
}
// Per-disk temperature history chart (Sprint 14). Rendered as inline
// SVG so it survives the print-to-PDF path. Only emits markup when the
// backend has actually sampled this disk; otherwise the section is
// omitted entirely (no point printing an empty card).
let temperatureChartHtml = ''
if (tempHistory && tempHistory.data && tempHistory.data.length >= 2) {
const points = tempHistory.data
const stats = tempHistory.stats
const W = 720, H = 200
const padL = 38, padR = 14, padT = 16, padB = 28
const innerW = W - padL - padR
const innerH = H - padT - padB
const ts0 = points[0].timestamp
const ts1 = points[points.length - 1].timestamp
const span = Math.max(1, ts1 - ts0)
const vals = points.map(p => p.value)
const dataMin = Math.min(...vals)
const dataMax = Math.max(...vals)
// Pad the y-domain a couple of degrees on each side so the line
// doesn't sit flush against the chart border.
const yMin = Math.max(0, Math.floor(dataMin - 3))
const yMax = Math.ceil(dataMax + 3)
const yRange = Math.max(1, yMax - yMin)
const xFor = (t: number) => padL + ((t - ts0) / span) * innerW
const yFor = (v: number) => padT + (1 - (v - yMin) / yRange) * innerH
// Threshold reference lines pulled from the user-configurable
// backend cache. `getDiskTempThresholdsSync` reads the in-memory
// map populated by `useDiskTempThresholds` mounted on the parent
// component — no extra fetch in the print flow.
const _dt = getDiskTempThresholdsSync(diskType)
const warnAt = _dt.warn
const hotAt = _dt.hot
const linePath = points.map((p, i) => {
const cmd = i === 0 ? 'M' : 'L'
return `${cmd}${xFor(p.timestamp).toFixed(1)},${yFor(p.value).toFixed(1)}`
}).join(' ')
// Area fill below the line (closing back along the bottom).
const areaPath = `${linePath} L${xFor(ts1).toFixed(1)},${(padT + innerH).toFixed(1)} L${xFor(ts0).toFixed(1)},${(padT + innerH).toFixed(1)} Z`
const formatXLabel = (ts: number) => {
const d = new Date(ts * 1000)
if (span <= 86400 * 2) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
// Y axis ticks — 4 evenly spaced labels.
const yTicks: number[] = []
for (let i = 0; i <= 4; i++) {
yTicks.push(yMin + (yRange * i) / 4)
}
// X axis ticks — start, mid, end.
const xTicks = [ts0, ts0 + span / 2, ts1]
// Per the user's preference the report chart is blue rather than
// colour-coded. Threshold bands and reference lines below still use
// the warn/hot palette so a hot stretch is visible without changing
// the line itself.
const lineColor = '#2563eb'
const samples = points.length
// Threshold band y-coords (clamped to chart area).
const yWarnBand = Math.max(padT, yFor(hotAt))
const yHotTop = padT
const yHotHeight = Math.max(0, yWarnBand - yHotTop)
const yMidTop = Math.max(padT, yFor(hotAt))
const yMidBottom = Math.min(padT + innerH, yFor(warnAt))
const yMidHeight = Math.max(0, yMidBottom - yMidTop)
temperatureChartHtml = `
<div style="margin-top:14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:8px;">
<div>
<div style="font-size:11px;font-weight:700;color:#0f172a;text-transform:uppercase;letter-spacing:0.04em;">Temperature history</div>
<div style="font-size:10px;color:#64748b;margin-top:2px;">${samples} samples · ${formatXLabel(ts0)}${formatXLabel(ts1)}</div>
</div>
<div style="display:flex;gap:14px;font-size:11px;">
<div><span style="color:#64748b;">Min</span> <strong style="color:#16a34a;">${stats.min}°C</strong></div>
<div><span style="color:#64748b;">Avg</span> <strong style="color:#1e293b;">${stats.avg}°C</strong></div>
<div><span style="color:#64748b;">Max</span> <strong style="color:#dc2626;">${stats.max}°C</strong></div>
</div>
</div>
<svg viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="none" style="display:block;max-height:200px;">
<!-- threshold bands -->
${yHotHeight > 0 ? `<rect x="${padL}" y="${yHotTop}" width="${innerW}" height="${yHotHeight}" fill="#fee2e2" opacity="0.55"/>` : ''}
${yMidHeight > 0 ? `<rect x="${padL}" y="${yMidTop}" width="${innerW}" height="${yMidHeight}" fill="#fef3c7" opacity="0.55"/>` : ''}
<!-- chart frame -->
<rect x="${padL}" y="${padT}" width="${innerW}" height="${innerH}" fill="none" stroke="#cbd5e1" stroke-width="1"/>
<!-- y grid + labels -->
${yTicks.map(t => {
const y = yFor(t).toFixed(1)
return `<line x1="${padL}" y1="${y}" x2="${padL + innerW}" y2="${y}" stroke="#e2e8f0" stroke-width="0.6" stroke-dasharray="2,3"/>` +
`<text x="${padL - 5}" y="${y}" text-anchor="end" dominant-baseline="middle" font-size="9" fill="#64748b">${Math.round(t)}°</text>`
}).join('')}
<!-- x labels -->
${xTicks.map(t => {
const x = xFor(t).toFixed(1)
return `<text x="${x}" y="${(padT + innerH + 16).toFixed(1)}" text-anchor="middle" font-size="9" fill="#64748b">${formatXLabel(t)}</text>`
}).join('')}
<!-- threshold reference lines -->
${warnAt > yMin && warnAt < yMax ? `<line x1="${padL}" y1="${yFor(warnAt).toFixed(1)}" x2="${padL + innerW}" y2="${yFor(warnAt).toFixed(1)}" stroke="#ca8a04" stroke-width="0.7" stroke-dasharray="3,2"/>` : ''}
${hotAt > yMin && hotAt < yMax ? `<line x1="${padL}" y1="${yFor(hotAt).toFixed(1)}" x2="${padL + innerW}" y2="${yFor(hotAt).toFixed(1)}" stroke="#dc2626" stroke-width="0.7" stroke-dasharray="3,2"/>` : ''}
<!-- area + line -->
<path d="${areaPath}" fill="${lineColor}" fill-opacity="0.12"/>
<path d="${linePath}" fill="none" stroke="${lineColor}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
<div style="font-size:9px;color:#94a3b8;margin-top:4px;">Bands: amber = warn (≥${warnAt}°C), red = critical (≥${hotAt}°C). Sampled every 60s.</div>
</div>`
}
const html = `<!DOCTYPE html>
<html lang="en">
<head>
@@ -2704,11 +3230,12 @@ function pmxPrint(){
</div>
</div>
` : ''}
${temperatureChartHtml}
</div>
${isNvmeDisk ? `
${isNvmeDisk && hasNvmeWearData ? `
<!-- NVMe Wear & Lifetime (Special Section) -->
<div class="section">
<div class="section-title">3. NVMe Wear & Lifetime</div>
@@ -2812,6 +3339,35 @@ ${isNvmeDisk ? `
</div>
` : ''}
${isNvmeDisk && !hasNvmeWearData ? (() => {
// Fallback for NVMe drives whose firmware does not tick percent_used
// (e.g. WD CL SN720). Skip the misleading "100% Life Remaining /
// Excellent" gauge and only print the data we trust: total written.
const dwUnits = testStatus.smart_data?.nvme_raw?.data_units_written ?? 0
if (!dwUnits) return ''
const dwTB = (dwUnits * 512 * 1024) / (1024 ** 4)
const dwLabel = dwTB >= 1 ? dwTB.toFixed(2) + ' TB' : (dwTB * 1024).toFixed(1) + ' GB'
const pCycles = testStatus.smart_data?.nvme_raw?.power_cycles ?? disk.power_cycles ?? null
return `
<!-- NVMe wear-not-reported fallback -->
<div class="section">
<div class="section-title">3. NVMe Wear &amp; Lifetime</div>
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Data Written</div>
<div style="font-size:18px;font-weight:700;color:#1e293b;margin-top:4px;">${dwLabel}</div>
</div>
${pCycles !== null ? `<div>
<div style="font-size:10px;color:#475569;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">Power Cycles</div>
<div style="font-size:18px;font-weight:700;color:#1e293b;margin-top:4px;">${fmtNum(pCycles as number)}</div>
</div>` : ''}
</div>
</div>
</div>
`
})() : ''}
${!isNvmeDisk && diskType === 'SSD' ? (() => {
// Try to find SSD wear indicators from SMART attributes
const wearAttr = smartAttributes.find(a =>
@@ -3483,10 +4039,26 @@ function SmartTestTab({ disk, observations = [], lastTestDate }: SmartTestTabPro
{/* View Full Report Button */}
<div className="pt-4 border-t">
<Button
variant="outline"
<Button
variant="outline"
className="w-full gap-2 bg-blue-500/10 border-blue-500/30 text-blue-500 hover:bg-blue-500/20 hover:text-blue-400"
onClick={() => openSmartReport(disk, testStatus, smartAttributes, observations, lastTestDate)}
onClick={async () => {
// Open placeholder window synchronously so the popup blocker
// sees the user gesture; then fetch temp history and hand
// the populated tempHistory + targetWindow to openSmartReport.
const reportWindow = window.open('about:blank', '_blank')
if (reportWindow) {
reportWindow.document.write('<html><body style="background:#0f172a;color:#e2e8f0;font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0"><div style="text-align:center"><div style="border:3px solid transparent;border-top-color:#06b6d4;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:0 auto"></div><p style="margin-top:16px">Loading report...</p><style>@keyframes spin{to{transform:rotate(360deg)}}</style></div></body></html>')
}
// Warm the disk-temp threshold cache in parallel with the
// history fetch so openSmartReport's sync read picks up
// the user's customised values instead of stale defaults.
const [tempHistory] = await Promise.all([
fetchTempHistoryForReport(disk.name),
loadDiskTempThresholds(),
])
openSmartReport(disk, testStatus, smartAttributes, observations, lastTestDate, reportWindow || undefined, false, tempHistory)
}}
>
<FileText className="h-4 w-4" />
View Full SMART Report
@@ -3571,7 +4143,12 @@ function HistoryTab({ disk }: { disk: DiskInfo }) {
const fullStatus = await fetchApi<SmartTestStatus>(`/api/storage/smart/${disk.name}`)
const attrs = fullStatus.smart_data?.attributes || []
openSmartReport(disk, fullStatus, attrs, [], entry.timestamp, reportWindow || undefined, true)
const [tempHistory] = await Promise.all([
fetchTempHistoryForReport(disk.name),
loadDiskTempThresholds(),
])
openSmartReport(disk, fullStatus, attrs, [], entry.timestamp, reportWindow || undefined, true, tempHistory)
} catch {
if (reportWindow && !reportWindow.closed) {
reportWindow.document.body.innerHTML = '<p style="color:#ef4444;text-align:center;margin-top:40vh">Failed to load report data.</p>'
+19 -6
View File
@@ -28,7 +28,7 @@ import {
Terminal,
} from "lucide-react"
import { useState, useEffect, useMemo } from "react"
import { API_PORT, fetchApi } from "@/lib/api-config"
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
interface Backup {
volid: string
@@ -242,9 +242,22 @@ export function SystemLogs() {
const upid = extractUPID(notification.message)
if (upid) {
// Try to fetch the complete task log from Proxmox
// Try to fetch the complete task log from Proxmox.
// We use a direct fetch (not fetchApi) because the response is
// text/plain — fetchApi assumes JSON and would throw on parse,
// landing in the silent catch below. Audit residual #fetchApi-text-arg.
try {
const taskLog = await fetchApi(`/api/task-log/${encodeURIComponent(upid)}`, {}, "text")
const token = getAuthToken()
const headers: Record<string, string> = {}
if (token) headers["Authorization"] = `Bearer ${token}`
const resp = await fetch(getApiUrl(`/api/task-log/${encodeURIComponent(upid)}`), {
headers,
cache: "no-store",
})
if (!resp.ok) {
throw new Error(`task-log fetch failed: ${resp.status}`)
}
const taskLog = await resp.text()
// Download the complete task log
const blob = new Blob(
@@ -982,12 +995,12 @@ export function SystemLogs() {
>
<div className="flex-shrink-0 flex gap-2 flex-wrap">
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
{notification.type.toUpperCase()}
{(notification.type || "unknown").toUpperCase()}
</Badge>
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
{notification.source.toUpperCase()}
{(notification.source || "unknown").toUpperCase()}
</Badge>
</div>
@@ -1232,7 +1245,7 @@ export function SystemLogs() {
<div>
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div>
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
{selectedNotification.type.toUpperCase()}
{(selectedNotification.type || "unknown").toUpperCase()}
</Badge>
</div>
<div>
+87 -20
View File
@@ -3,6 +3,7 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { API_PORT, fetchApi } from "@/lib/api-config" // Unificando importaciones de api-config en una sola línea con alias @/
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import {
Activity,
Trash2,
@@ -16,7 +17,10 @@ import {
Grid2X2,
GripHorizontal,
ChevronDown,
Copy,
Clipboard,
} from "lucide-react"
import { copyTerminalSelection, pasteFromClipboard } from "@/lib/terminal-clipboard"
import {
DropdownMenu,
DropdownMenuContent,
@@ -156,6 +160,9 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
const [useOnline, setUseOnline] = useState(true)
const containerRefs = useRef<{ [key: string]: HTMLDivElement | null }>({})
// Per-terminal reconnect attempt count + last-fired timestamp for the
// exponential backoff in the visibilitychange handler.
const reconnectAttemptsRef = useRef<{ [key: string]: { attempts: number; lastAt: number } }>({})
useEffect(() => {
const updateDeviceType = () => {
@@ -184,21 +191,35 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
// Handle page visibility change for automatic reconnection when user returns
// This is especially important for mobile/tablet devices (iPad) where switching apps
// puts the browser tab in background and may close WebSocket connections
//
// Per-terminal exponential backoff (2s, 4s, 8s, ..., capped at 60s) so a
// server-side outage doesn't get hammered every time the user switches
// tabs. `reconnectAttemptsRef` survives re-renders and tracks attempts +
// last-fired timestamps. The success path in `reconnectTerminal.onopen`
// resets the counter back to 0.
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// When page becomes visible again, check all terminal connections
terminals.forEach((terminal) => {
if (terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term) {
// Terminal is disconnected, attempt to reconnect
reconnectTerminal(terminal.id)
}
})
}
if (document.visibilityState !== 'visible') return
const now = Date.now()
terminals.forEach((terminal) => {
if (!(terminal.ws && terminal.ws.readyState !== WebSocket.OPEN && terminal.term)) {
return
}
const state = reconnectAttemptsRef.current[terminal.id] || { attempts: 0, lastAt: 0 }
const backoffMs = Math.min(60000, 2000 * Math.pow(2, state.attempts))
if (now - state.lastAt < backoffMs) {
return
}
reconnectAttemptsRef.current[terminal.id] = {
attempts: state.attempts + 1,
lastAt: now,
}
reconnectTerminal(terminal.id)
})
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
@@ -269,7 +290,6 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
throw new Error("No examples found")
}
console.log("[v0] Received parsed examples from server:", data.examples.length)
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
command: example.command,
@@ -280,7 +300,6 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
setUseOnline(true)
setSearchResults(formattedResults)
} catch (error) {
console.log("[v0] Error fetching from cheat.sh proxy, using offline commands:", error)
const filtered = proxmoxCommands.filter(
(item) =>
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
@@ -314,11 +333,14 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
// Show reconnecting message
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
const wsUrl = websocketUrl || getWebSocketUrl()
const ws = new WebSocket(wsUrl)
// Append the single-use auth ticket so the backend handshake can validate.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
ws.onopen = () => {
// Successful connect — reset backoff state for this terminal.
reconnectAttemptsRef.current[terminalId] = { attempts: 0, lastAt: 0 }
// Clear any existing ping interval
if (terminal.pingInterval) {
clearInterval(terminal.pingInterval)
@@ -479,11 +501,22 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
import("xterm/css/xterm.css"),
]).then(([Terminal, FitAddon]) => [Terminal, FitAddon])
// After the (potentially slow) dynamic import, verify the container
// is still the one we were given. If the user removed the terminal
// tab while xterm was loading, the original `container` element is
// detached and `containerRefs.current[terminal.id]` is gone — bail
// out to avoid attaching to a stale DOM node + opening an orphan
// WebSocket. Audit Tier 6 — `import("xterm")` sin cancelación.
if (containerRefs.current[terminal.id] !== container) return
const fontSize = window.innerWidth < 768 ? 12 : 16
const term = new TerminalClass({
rendererType: "dom",
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
// Issue #182: prepend common Nerd Font families so users who already
// have one installed see Starship/atuin/ble.sh icons render. Falls
// back to Courier if no NF is present.
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,
@@ -524,12 +557,13 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
fitAddon.fit()
const wsUrl = websocketUrl || getWebSocketUrl()
// Connection with timeout for VPN/mobile (15 seconds)
const connectionTimeout = 15000
let connectionTimedOut = false
const ws = new WebSocket(wsUrl)
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
// Set connection timeout
const timeoutId = setTimeout(() => {
@@ -724,12 +758,35 @@ const handleClose = () => {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
activeTerminal.ws.send(seq)
}
}
// Mobile clipboard helpers — desktop users have ctrl/cmd shortcuts via xterm,
// but on touch devices xterm's selection / clipboard isn't reachable from the
// OS clipboard manager so we expose explicit Copy / Paste buttons.
const handleCopy = async (e?: React.MouseEvent | React.TouchEvent) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
await copyTerminalSelection(activeTerminal?.term)
}
const handlePaste = async (e?: React.MouseEvent | React.TouchEvent) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
if (!activeTerminal?.ws || activeTerminal.ws.readyState !== WebSocket.OPEN) return
const ws = activeTerminal.ws
await pasteFromClipboard((text) => ws.send(text))
}
const getLayoutClass = () => {
const count = terminals.length
@@ -1015,7 +1072,7 @@ const handleClose = () => {
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
@@ -1030,6 +1087,16 @@ const handleClose = () => {
<span className="font-mono text-xs mr-2">Ctrl+R</span>
<span className="text-muted-foreground text-xs">Search history</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
<Copy className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Copy selection</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
<Clipboard className="h-3.5 w-3.5 mr-2" />
<span className="text-xs">Paste</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
-2
View File
@@ -14,9 +14,7 @@ export function ThemeToggle() {
}, [])
const handleThemeToggle = () => {
console.log("[v0] Current theme:", theme)
const newTheme = theme === "light" ? "dark" : "light"
console.log("[v0] Switching to theme:", newTheme)
setTheme(newTheme)
}
+512 -96
View File
@@ -148,6 +148,30 @@ interface VMBackup {
notes?: string
}
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
// next to VMBackup since both are LXC-modal data structures.
interface LxcMountPoint {
mp_index: string // "mp0", "mp1", "" for ad-hoc
source: string
target: string
type: "pve_volume" | "pve_storage_bind" | "host_bind" | "ad_hoc"
origin_storage: string
origin_storage_type: string
origin_label: string
config_options: Record<string, string>
config_flags: string[]
total_bytes: number | null
used_bytes: number | null
available_bytes: number | null
runtime_mounted?: boolean | null
runtime_source?: string
runtime_fstype?: string
runtime_options?: string
runtime_readonly?: boolean
runtime_reachable?: boolean
runtime_error?: string | null
}
const fetcher = async (url: string) => {
return fetchApi(url)
}
@@ -288,6 +312,241 @@ const getOSIcon = (osInfo: VMDetails["os_info"] | undefined, vmType: string): Re
}
}
// Sprint 13.29: render a single LXC mount point row.
// Lifted out of the main component so the Mount Points tab renders
// uniformly for both configured mpX entries and ad-hoc inside-CT
// remote mounts. Capacity displays whatever the backend resolved —
// PVE storage stats, `df` of host path, or n/a for ad-hoc.
function MountPointCard({ mp }: { mp: LxcMountPoint }) {
const isStale = mp.runtime_reachable === false
const isReadonly = !isStale && mp.runtime_readonly === true
const isDivergent = mp.runtime_mounted === false // configured but not actually mounted
const cardClasses = isStale
? "border-red-500/50 bg-red-500/5"
: isDivergent
? "border-amber-500/40 bg-amber-500/5"
: isReadonly
? "border-amber-500/30 bg-amber-500/5"
: "border border-white/10 sm:border-border bg-white/5 sm:bg-card"
const typeBadgeClass: Record<LxcMountPoint["type"], string> = {
pve_volume: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20",
pve_storage_bind: "bg-blue-500/10 text-blue-400 border-blue-500/20",
host_bind: "bg-purple-500/10 text-purple-400 border-purple-500/20",
ad_hoc: "bg-amber-500/10 text-amber-400 border-amber-500/20",
}
const typeLabel: Record<LxcMountPoint["type"], string> = {
pve_volume: "PVE volume",
pve_storage_bind: "bind from PVE storage",
host_bind: "bind from host",
ad_hoc: "ad-hoc inside CT",
}
const fmtBytes = (b: number | null | undefined) => {
if (b == null) return "—"
const gb = b / 1024 ** 3
if (gb < 1) return `${(gb * 1024).toFixed(1)} MB`
if (gb >= 1000) return `${(gb / 1024).toFixed(2)} TB`
return `${gb.toFixed(2)} GB`
}
const usedPct =
mp.total_bytes && mp.used_bytes != null && mp.total_bytes > 0
? Math.round((mp.used_bytes / mp.total_bytes) * 100)
: null
// Parse mount options (runtime if available, else config flags) into
// flag chips + key=value pairs. Same UX as the Remote Mounts modal.
const optsString = mp.runtime_options || (mp.config_flags || []).join(",")
const optsEntries = (optsString || "")
.split(",")
.filter(Boolean)
.map((o) => {
const eq = o.indexOf("=")
return eq === -1
? { key: o, value: null as string | null }
: { key: o.slice(0, eq), value: o.slice(eq + 1) }
})
const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key)
const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }>
return (
<div className={`rounded-lg p-4 ${cardClasses}`}>
<div className="flex items-center justify-between gap-3 flex-wrap mb-2">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isStale ? "bg-red-500" : isDivergent ? "bg-amber-500" : "bg-green-500"
}`}
/>
<h3 className="font-mono font-semibold truncate">{mp.target}</h3>
{mp.mp_index && (
<Badge variant="outline" className="font-mono text-xs">
{mp.mp_index}
</Badge>
)}
<Badge className={typeBadgeClass[mp.type]}>{typeLabel[mp.type]}</Badge>
{mp.runtime_fstype && (
<Badge variant="outline" className="font-mono text-xs">
{mp.runtime_fstype}
</Badge>
)}
</div>
<Badge
className={
isStale
? "bg-red-500/10 text-red-500 border-red-500/20"
: isDivergent
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: isReadonly
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: mp.runtime_mounted === null
? "bg-gray-500/10 text-gray-400 border-gray-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
}
>
{isStale
? "stale"
: isDivergent
? "not mounted"
: isReadonly
? "read-only"
: mp.runtime_mounted === null
? "stopped"
: "mounted"}
</Badge>
</div>
{/* Source / Mounted-at info — what host resource backs the
mount, and where it shows up inside the CT. The header
already shows the target but it's worth surfacing the
source/target relationship explicitly here so the user
gets the full host→container path at a glance. */}
<div className="text-sm space-y-1">
<div>
<span className="text-muted-foreground">Source (host):</span>{" "}
<span className="font-mono">{mp.origin_label || mp.source}</span>
{mp.origin_storage && mp.origin_storage_type && (
<span className="text-muted-foreground ml-2">
({mp.origin_storage_type} storage)
</span>
)}
</div>
<div>
<span className="text-muted-foreground">Mounted at (CT):</span>{" "}
<span className="font-mono">{mp.target}</span>
</div>
</div>
{/* Capacity — total/used/available with progress bar. Available
even when CT is stopped because numbers come from the host. */}
{mp.total_bytes != null && (
<div className="mt-3 space-y-2">
<Progress
value={usedPct ?? 0}
className={`h-2 ${
(usedPct ?? 0) > 90
? "[&>div]:bg-red-500"
: (usedPct ?? 0) > 75
? "[&>div]:bg-yellow-500"
: "[&>div]:bg-blue-500"
}`}
/>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Total</p>
<p className="font-medium">{fmtBytes(mp.total_bytes)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Used</p>
<p className="font-medium">
{fmtBytes(mp.used_bytes)} {usedPct != null && `(${usedPct}%)`}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Available</p>
<p className="font-medium">{fmtBytes(mp.available_bytes)}</p>
</div>
</div>
</div>
)}
{/* Mount attributes — config_options/flags from the mpX line in
the LXC config (backup=0, shared=1, ro, replicate, etc.).
Hidden when there's nothing to show. */}
{(() => {
const configEntries: Array<{ key: string; value: string | null }> = []
for (const k of Object.keys(mp.config_options || {})) {
configEntries.push({ key: k, value: mp.config_options[k] })
}
for (const f of mp.config_flags || []) {
configEntries.push({ key: f, value: null })
}
if (configEntries.length === 0) return null
return (
<div className="mt-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">
Mount attributes (LXC config)
</p>
<div className="flex flex-wrap gap-1.5">
{configEntries.map((e) => (
<Badge key={e.key} variant="outline" className="font-mono text-xs">
{e.key}{e.value !== null ? `=${e.value}` : ""}
</Badge>
))}
</div>
</div>
)
})()}
{/* Runtime mount options — what the kernel actually uses
(vers, rsize, hard, sec, ...). Only meaningful when the CT
is running; for stopped CTs we hide this section because
the values would just repeat the config flags above.
Sprint 13.29 detail: we already render the runtime fstype
as a badge in the header, so it's fine to leave this
unlabelled-for-state — only show "(declared)" suffix in
the rare case where there's no runtime data but flags do
exist. */}
{(mp.runtime_mounted === true) && (keyValues.length > 0 || flags.length > 0) && (
<div className="mt-3">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">
Runtime mount options
</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{flags.map((f) => (
<Badge key={f} variant="outline" className="font-mono text-xs">
{f}
</Badge>
))}
</div>
{keyValues.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-sm">
{keyValues.map((kv) => (
<div key={kv.key} className="min-w-0">
<span className="font-mono text-muted-foreground">{kv.key}</span>
<span className="font-mono text-foreground"> = {kv.value}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Error / divergence note. */}
{mp.runtime_error && (
<p
className={`mt-3 text-sm ${
isStale ? "text-red-400" : "text-amber-400"
}`}
>
{mp.runtime_error}
</p>
)}
</div>
)
}
export function VirtualMachines() {
const {
data: vmData,
@@ -305,6 +564,15 @@ export function VirtualMachines() {
const [selectedVM, setSelectedVM] = useState<VMData | null>(null)
const [vmDetails, setVMDetails] = useState<VMDetails | null>(null)
const [controlLoading, setControlLoading] = useState(false)
// Destructive control confirmation. `Force Stop` and `Reboot` skip the OS
// shutdown sequence and can corrupt running guests; gate them behind a
// typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17.
const [confirmDestructive, setConfirmDestructive] = useState<{
action: "stop" | "reboot"
vmid: number
vmName: string
} | null>(null)
const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("")
const [detailsLoading, setDetailsLoading] = useState(false)
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalVmid, setTerminalVmid] = useState<number | null>(null)
@@ -337,7 +605,14 @@ export function VirtualMachines() {
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
// Tab state for modal
const [activeModalTab, setActiveModalTab] = useState<"status" | "backups">("status")
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups">("status")
// Sprint 13.29: per-LXC mount points lazy-loaded when the user opens
// the LXC modal. We fetch alongside backups (one-shot) so switching
// tabs is instantaneous; the cost is small (parses one config file
// + pvesm status which the kernel already caches).
const [mountPoints, setMountPoints] = useState<LxcMountPoint[]>([])
const [adHocMounts, setAdHocMounts] = useState<LxcMountPoint[]>([])
const [loadingMounts, setLoadingMounts] = useState(false)
// Detect standalone mode (webapp vs browser)
const [isStandalone, setIsStandalone] = useState(false)
@@ -356,14 +631,19 @@ export function VirtualMachines() {
}, [])
useEffect(() => {
// `cancelled` short-circuits setState calls if the component unmounts
// mid-fetch (user navigates away while we're still iterating LXCs in
// batches). Without it, React logs "state update on unmounted
// component" and we leak the closure that holds the configs map.
let cancelled = false
const fetchLXCIPs = async () => {
// Only fetch if data exists, not already loaded, and not currently loading
if (!vmData || ipsLoaded || loadingIPs) return
const lxcs = vmData.filter((vm) => vm.type === "lxc")
if (lxcs.length === 0) {
setIpsLoaded(true)
if (!cancelled) setIpsLoaded(true)
return
}
@@ -372,6 +652,7 @@ export function VirtualMachines() {
const batchSize = 5
for (let i = 0; i < lxcs.length; i += batchSize) {
if (cancelled) return
const batch = lxcs.slice(i, i + batchSize)
await Promise.all(
@@ -396,14 +677,19 @@ export function VirtualMachines() {
}),
)
if (cancelled) return
setVmConfigs((prev) => ({ ...prev, ...configs }))
}
if (cancelled) return
setLoadingIPs(false)
setIpsLoaded(true)
}
fetchLXCIPs()
return () => {
cancelled = true
}
}, [vmData, ipsLoaded, loadingIPs])
// Load initial network unit and listen for changes
@@ -441,11 +727,24 @@ export function VirtualMachines() {
setIsEditingNotes(false)
setEditedNotes("")
setDetailsLoading(true)
setActiveModalTab("status")
// Reset Sprint 13.29 mount-points state from any previous selection
// so the new modal doesn't briefly flash data from another LXC.
setMountPoints([])
setAdHocMounts([])
// Load backups immediately (independent of config)
fetchBackupStorages()
fetchVmBackups(vm.vmid)
// Sprint 13.29: load LXC mount points alongside backups so
// switching to that tab is instant. Only LXCs have mpX entries —
// qemu VMs use disks, not mount points, so we skip the request
// and simply hide the tab below.
if (vm.type === "lxc") {
fetchMountPoints(vm.vmid)
}
try {
const details = await fetchApi(`/api/vms/${vm.vmid}`)
setVMDetails(details)
@@ -456,6 +755,31 @@ export function VirtualMachines() {
}
}
const fetchMountPoints = async (vmid: number) => {
setLoadingMounts(true)
try {
const response = await fetchApi<{
ok: boolean
running: boolean
mount_points: LxcMountPoint[]
ad_hoc: LxcMountPoint[]
}>(`/api/lxc/${vmid}/mount-points`)
if (response?.ok) {
setMountPoints(response.mount_points || [])
setAdHocMounts(response.ad_hoc || [])
} else {
setMountPoints([])
setAdHocMounts([])
}
} catch (error) {
console.error("Error fetching LXC mount points:", error)
setMountPoints([])
setAdHocMounts([])
} finally {
setLoadingMounts(false)
}
}
const handleMetricsClick = () => {
setCurrentView("metrics")
}
@@ -517,7 +841,7 @@ export function VirtualMachines() {
try {
await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, {
method: "POST",
body: JSON.stringify({
body: JSON.stringify({
storage: selectedBackupStorage,
mode: backupMode,
compress: "zstd",
@@ -530,6 +854,11 @@ export function VirtualMachines() {
setTimeout(() => fetchVmBackups(selectedVM.vmid), 2000)
} catch (error) {
console.error("Error creating backup:", error)
// Surface the failure to the user. Previous behaviour silently swallowed
// backend errors so the user thought the backup started fine; in reality
// the request had 4xx/5xx'd and nothing was scheduled.
const msg = error instanceof Error ? error.message : "Unknown error"
alert(`Failed to start backup: ${msg}`)
} finally {
setCreatingBackup(false)
}
@@ -547,7 +876,11 @@ export function VirtualMachines() {
setSelectedVM(null)
setVMDetails(null)
} catch (error) {
console.error("Failed to control VM")
console.error(`Failed to ${action} VM ${vmid}:`, error)
// Same UX issue as handleCreateBackup: a silent console.error left the
// user looking at a "Stop"/"Start" button that just never reacted.
const msg = error instanceof Error ? error.message : "Unknown error"
alert(`Failed to ${action} VM ${vmid}: ${msg}`)
} finally {
setControlLoading(false)
}
@@ -700,87 +1033,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)
}
const isHTML = (str: string): boolean => {
const htmlRegex = /<\/?[a-z][\s\S]*>/i
return htmlRegex.test(str)
}
const decodeRecursively = (str: string, maxIterations = 5): string => {
let decoded = str
let iteration = 0
while (iteration < maxIterations) {
try {
const nextDecoded = decodeURIComponent(decoded.replace(/%0A/g, "\n"))
// If decoding didn't change anything, we're done
if (nextDecoded === decoded) {
break
}
decoded = nextDecoded
// If there are no more encoded characters, we're done
if (!/(%[0-9A-F]{2})/i.test(decoded)) {
break
}
iteration++
} catch (e) {
// If decoding fails, try manual decoding of common sequences
try {
decoded = decoded
.replace(/%0A/g, "\n")
.replace(/%20/g, " ")
.replace(/%3A/g, ":")
.replace(/%2F/g, "/")
.replace(/%3D/g, "=")
.replace(/%3C/g, "<")
.replace(/%3E/g, ">")
.replace(/%22/g, '"')
.replace(/%27/g, "'")
.replace(/%26/g, "&")
.replace(/%23/g, "#")
.replace(/%25/g, "%")
.replace(/%2B/g, "+")
.replace(/%2C/g, ",")
.replace(/%3B/g, ";")
.replace(/%3F/g, "?")
.replace(/%40/g, "@")
.replace(/%5B/g, "[")
.replace(/%5D/g, "]")
.replace(/%7B/g, "{")
.replace(/%7D/g, "}")
.replace(/%7C/g, "|")
.replace(/%5C/g, "\\")
.replace(/%5E/g, "^")
.replace(/%60/g, "`")
break
} catch (manualError) {
// If manual decoding also fails, return what we have
break
}
}
}
return decoded
}
const processDescription = (description: string): { html: string; isHtml: boolean; error: boolean } => {
// Single-pass decode. Proxmox URL-encodes notes exactly once when storing
// them in `config.description`, so a single `decodeURIComponent` is the
// correct round-trip. The previous loop decoded up to 5 times, which made
// it possible to ship a payload like `%253Cscript%253E` past one-pass
// filters (`%25` → `%` → second decode produces `<script>`). With the
// dangerouslySetInnerHTML render path already removed (Sprint 4.1) the
// immediate XSS is gone, but keeping the loop on the editor path keeps
// the same evasion vector available for future use sites.
const decodeRecursively = (str: string): string => {
try {
const decoded = decodeRecursively(description)
// Check if it contains HTML
if (isHTML(decoded)) {
return { html: decoded, isHtml: true, error: false }
}
// If it's plain text, convert \n to <br>
return { html: decoded.replace(/\n/g, "<br>"), isHtml: false, error: false }
} catch (error) {
// If all decoding fails, return error
console.error("Error decoding description:", error)
return { html: "", isHtml: false, error: true }
return decodeURIComponent(str.replace(/%0A/g, "\n"))
} catch {
return str
}
}
@@ -799,7 +1064,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setSavingNotes(true)
try {
await fetchApi(`/api/vms/${selectedVM.vmid}/config`, {
await fetchApi(`/api/vms/${selectedVM.vmid}/description`, {
method: "PUT",
body: JSON.stringify({
description: editedNotes, // Send as-is, pvesh will handle encoding
@@ -1337,6 +1602,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Activity className="h-4 w-4" />
Status
</button>
{/* Sprint 13.29: Mount Points tab — LXC only, and only
when at least one mp / ad-hoc remote mount exists.
A CT without mounts gets no empty tab.
Label is "Mounts" (single word) so the trigger
fits in one line on mobile next to Status and
Backups; "Mount Points" wrapped on narrow viewports. */}
{selectedVM?.type === "lxc" && (mountPoints.length > 0 || adHocMounts.length > 0) && (
<button
onClick={() => setActiveModalTab("mounts")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeModalTab === "mounts"
? "border-blue-500 text-blue-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<HardDrive className="h-4 w-4" />
Mounts
<Badge variant="secondary" className="text-xs h-5 ml-1">
{mountPoints.length + adHocMounts.length}
</Badge>
</button>
)}
<button
onClick={() => setActiveModalTab("backups")}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -1625,8 +1912,18 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : vmDetails.config.description ? (
<>
{(() => {
const processed = processDescription(vmDetails.config.description)
if (processed.error) {
// VM/CT notes are operator-controlled but historically were
// rendered via `dangerouslySetInnerHTML` — a stored XSS sink
// for any user with write access to the VM config (a
// non-admin user with PVE permissions, or another admin in
// a multi-admin deployment). We now render the decoded
// notes as plain text inside a <pre> with `white-space:
// pre-wrap` so newlines and indentation are preserved
// without interpreting any HTML. See audit Tier 2 #13.
let decoded: string
try {
decoded = decodeRecursively(vmDetails.config.description)
} catch {
return (
<div className="text-sm text-red-500">
Error decoding notes. Please edit to fix.
@@ -1634,10 +1931,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)
}
return (
<div
className={`text-sm text-foreground ${processed.isHtml ? "proxmenux-notes" : "proxmenux-notes-plaintext"}`}
dangerouslySetInnerHTML={{ __html: processed.html }}
/>
<pre
className="text-sm text-foreground proxmenux-notes-plaintext font-sans whitespace-pre-wrap break-words m-0"
>
{decoded}
</pre>
)
})()}
</>
@@ -2030,6 +2328,39 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
)}
{/* Sprint 13.29: Mount Points Tab — LXC only.
Renders configured mpX entries first, then any
ad-hoc NFS/CIFS/SMB mounts found inside the
container. Capacity comes from the host-side
source (PVE storage or `df`) so it's available
even when the CT is stopped. */}
{activeModalTab === "mounts" && selectedVM?.type === "lxc" && (
<div className="space-y-4">
{loadingMounts ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading mount points
</div>
) : (
<>
{mountPoints.map((mp) => (
<MountPointCard key={mp.mp_index || mp.target} mp={mp} />
))}
{adHocMounts.length > 0 && (
<>
<div className="text-sm font-semibold text-muted-foreground pt-2 border-t border-border">
Mounted from inside the container
</div>
{adHocMounts.map((mp) => (
<MountPointCard key={`adhoc-${mp.target}`} mp={mp} />
))}
</>
)}
</>
)}
</div>
)}
{/* Backups Tab */}
{activeModalTab === "backups" && (
<div className="space-y-4">
@@ -2141,7 +2472,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Button
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
disabled={selectedVM?.status !== "running" || controlLoading}
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "reboot")}
onClick={() => selectedVM && setConfirmDestructive({
action: "reboot",
vmid: selectedVM.vmid,
vmName: selectedVM.name,
})}
>
<RotateCcw className="h-4 w-4 mr-2" />
Reboot
@@ -2149,7 +2484,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Button
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
disabled={selectedVM?.status !== "running" || controlLoading}
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "stop")}
onClick={() => selectedVM && setConfirmDestructive({
action: "stop",
vmid: selectedVM.vmid,
vmName: selectedVM.name,
})}
>
<StopCircle className="h-4 w-4 mr-2" />
Force Stop
@@ -2170,6 +2509,83 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</DialogContent>
</Dialog>
{/* Destructive control confirmation (Force Stop / Reboot) */}
<Dialog
open={confirmDestructive !== null}
onOpenChange={(open) => {
if (!open) {
setConfirmDestructive(null)
setConfirmDestructiveTyped("")
}
}}
>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500">
<StopCircle className="h-5 w-5" />
{confirmDestructive?.action === "stop" ? "Force Stop" : "Reboot"}{" "}
VMID {confirmDestructive?.vmid}
</DialogTitle>
<DialogDescription>
{confirmDestructive?.action === "stop"
? "This skips the guest OS shutdown sequence and can corrupt running databases or filesystems. The guest is killed immediately."
: "This forces a reboot without waiting for the guest OS to flush pending writes. Use a graceful Shutdown when possible."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm">
Type <span className="font-mono font-bold">{confirmDestructive?.vmid}</span> to confirm:
</p>
<input
type="text"
autoFocus
autoComplete="off"
inputMode="numeric"
value={confirmDestructiveTyped}
onChange={(e) => setConfirmDestructiveTyped(e.target.value)}
placeholder={String(confirmDestructive?.vmid ?? "")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-500"
/>
<p className="text-xs text-muted-foreground">
Guest: <span className="font-medium">{confirmDestructive?.vmName}</span>
</p>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => {
setConfirmDestructive(null)
setConfirmDestructiveTyped("")
}}
disabled={controlLoading}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={
controlLoading ||
!confirmDestructive ||
confirmDestructiveTyped.trim() !== String(confirmDestructive.vmid)
}
onClick={async () => {
if (!confirmDestructive) return
const { vmid, action } = confirmDestructive
setConfirmDestructive(null)
setConfirmDestructiveTyped("")
await handleVMControl(vmid, action)
}}
>
{controlLoading
? "Working..."
: confirmDestructive?.action === "stop"
? "Force Stop"
: "Reboot"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Backup Configuration Modal */}
<Dialog open={showBackupModal} onOpenChange={setShowBackupModal}>
<DialogContent className="sm:max-w-[500px]">