mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-05-15 13:25:01 +00:00
update beta ProxMenux 1.2.1.1-beta
This commit is contained in:
@@ -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 & License
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
|
||||
you, a one-off contribution helps keep it that way.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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 "sync hookscripts" 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 & 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>'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user