Merge develop into PR #337 and resolve shared app cache conflict

This commit is contained in:
MacRimi
2026-09-05 16:51:41 +02:00
37 changed files with 5970 additions and 797 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client"
import { useId, useState } from "react"
import { Info, ExternalLink, Check, Loader2, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Textarea } from "./ui/textarea"
import { Label } from "./ui/label"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
export type AppUpdateMethod = "none" | "helper" | "custom"
export function AppUpdaterEditor({ method, command, helperAvailable, helperSlug, configured, saving, changed,
onMethodChange, onCommandChange, onSave, onCancel, onRemove }: {
method: AppUpdateMethod
command: string
helperAvailable: boolean
helperSlug?: string
configured: boolean
saving: boolean
changed: boolean
onMethodChange: (method: AppUpdateMethod) => void
onCommandChange: (command: string) => void
onSave: () => void
onCancel: () => void
onRemove: () => void
}) {
const t = useT()
const commandId = useId()
const [help, setHelp] = useState<"helper" | "custom" | null>(null)
const scriptUrl = helperSlug && /^[a-z0-9][a-z0-9._-]*$/.test(helperSlug)
? `https://github.com/community-scripts/ProxmoxVE/blob/main/ct/${helperSlug}.sh` : null
// Keep the editable command readable. Download guards belong to the runner,
// which also protects this literal launcher when saved as a custom command.
const helperCommand = scriptUrl
? `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${helperSlug}.sh)"`
: null
const valid = method === "helper" ? helperAvailable && !!helperCommand : method === "custom" && !!command.trim()
const displayedCommand = method === "helper" ? (helperCommand || "") : command
const editCommand = (value: string) => {
// Edited launchers belong to the existing custom-command execution path.
// Never leave the method as "helper": saving it would discard the edits.
if (method === "helper") {
if (value.trim() === (helperCommand || "").trim()) return
onMethodChange("custom")
}
onCommandChange(value)
}
const customExamples = [
{ title: "customScriptTitle", description: "customScriptDescription", command: "/opt/my-app/update.sh" },
{ title: "customPackageTitle", description: "customPackageDescription", command: "apt-get update &&\napt-get install -y --only-upgrade my-package" },
{ title: "customBinaryTitle", description: "customBinaryDescription", command: "install -b -m 0755 /tmp/my-app.new /opt/my-app/my-app &&\nsystemctl restart my-app" },
]
const onlineExampleCommand = `script=$(mktemp) || exit 1
trap 'rm -f "$script"' EXIT
curl -fsSL 'https://example.com/my-app/update.sh' -o "$script" &&
bash "$script"`
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.updaterChoiceHint")}</p>
<div className="flex flex-wrap gap-2" role="group" aria-label={t("vmLxc.updates.updaterMethodLabel")}>
{([...(helperAvailable ? ["helper" as const] : []), "custom" as const]).map((choice) => (
<div key={choice} className="flex items-center gap-1">
<Button type="button" size="sm" variant={method === choice ? "default" : "outline"}
aria-pressed={method === choice} disabled={saving} onClick={() => onMethodChange(choice)}>
{t(`vmLxc.updates.${choice === "helper" ? "helperMethod" : "customMethod"}`)}
</Button>
<Button type="button" size="icon" variant="ghost" className="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400" onClick={() => setHelp(choice)}
aria-label={t(`vmLxc.updates.${choice === "helper" ? "helperMethodHelp" : "customMethodHelp"}`)}>
<Info className="h-4 w-4" />
</Button>
</div>
))}
</div>
{helperAvailable && <p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperDetectedChoice")}</p>}
{method === "helper" && (!helperAvailable || !helperCommand) && (
<p className="text-xs text-amber-500">{t("vmLxc.updates.helperUnavailableChoice")}</p>
)}
{(method === "helper" || method === "custom") && (
<div>
<Label htmlFor={commandId} className="text-xs uppercase tracking-wider text-muted-foreground">
{t(`vmLxc.updates.${method === "helper" ? "helperCommandLabel" : "customCommandLabel"}`)}
</Label>
<Textarea id={commandId} value={displayedCommand} onChange={(event) => editCommand(event.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")} disabled={saving}
className="font-mono text-xs mt-2 min-h-[100px]" maxLength={4096} />
{method === "helper" && helperCommand && (
<p className="mt-2 text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandEditHint")}</p>
)}
</div>
)}
<div className="flex items-center justify-between gap-2">
<div>{configured && (
<Button type="button" size="sm" variant="outline" className="text-red-400" disabled={saving} onClick={onRemove}>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />{t("vmLxc.updates.disableUpdater")}
</Button>
)}</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="outline" disabled={saving} onClick={onCancel}>{t("vmLxc.updates.cancelButton")}</Button>
<Button type="button" size="sm" className="bg-blue-500 hover:bg-blue-600 text-white" disabled={saving || !valid || !changed} onClick={onSave}>
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Check className="h-3.5 w-3.5 mr-1.5" />}
{t("vmLxc.updates.saveButton")}
</Button>
</div>
</div>
<Dialog open={help !== null} onOpenChange={(open) => { if (!open) setHelp(null) }}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t(`vmLxc.updates.${help === "helper" ? "helperMethod" : "customMethod"}`)}</DialogTitle>
<DialogDescription>{t(`vmLxc.updates.${help === "helper" ? "helperMethodDescription" : "customMethodDescription"}`)}</DialogDescription>
</DialogHeader>
{help === "helper" ? (
<div className="space-y-3 text-sm">
{helperCommand && <div className="min-w-0 space-y-2">
<p className="font-medium">{t("vmLxc.updates.helperCommandLabel")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{helperCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.helperCommandFallback")}</p>
</div>}
<p>{t("vmLxc.updates.updaterInstructions")}</p>
<a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href="https://community-scripts.org/docs/tools/pve/update-apps" target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperDocumentation")}<ExternalLink className="h-4 w-4" />
</a>
{scriptUrl && <div><a className="text-blue-400 hover:text-blue-300 inline-flex items-center gap-1" href={scriptUrl} target="_blank" rel="noopener noreferrer">
{t("vmLxc.updates.helperSource")}<ExternalLink className="h-4 w-4" />
</a></div>}
</div>
) : (
<div className="space-y-4 text-sm">
<p className="font-medium">{t("vmLxc.updates.customExamplesHeading")}</p>
{customExamples.map((example) => <div key={example.title} className="min-w-0 space-y-2">
<p className="font-medium">{t(`vmLxc.updates.${example.title}`)}</p>
<p className="text-muted-foreground">{t(`vmLxc.updates.${example.description}`)}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{example.command}</code></pre>
{example.title === "customScriptTitle" && <>
<p className="text-muted-foreground">{t("vmLxc.updates.customOnlineDescription")}</p>
<pre className="rounded-md bg-muted p-3 text-xs whitespace-pre-wrap break-all"><code>{onlineExampleCommand}</code></pre>
<p className="text-xs text-muted-foreground">{t("vmLxc.updates.customOnlineExampleNote")}</p>
</>}
</div>)}
<p className="text-muted-foreground">{t("vmLxc.updates.customMethodExample")}</p>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}
+519
View File
@@ -0,0 +1,519 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader,
DialogTitle,
} from "./ui/dialog"
import {
AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, ClipboardCheck,
Loader2, MinusCircle, Play, RotateCcw, ShieldOff, XCircle,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Finding {
check_id: string
area: string
severity: string
state: string
summary_key: string | null
summary_params: Record<string, string | number>
affected: Array<Record<string, unknown>>
evidence: string | null
remediable_by: string | null
exception?: { reason: string; accepted_by: string; accepted_at: number } | null
}
interface Run {
run_id: string
profile: string
started_at: number
finished_at: number | null
status: string
checks_total: number
}
// Findings are ordered by how much they demand attention, not by area.
// Someone triaging wants the worst thing first regardless of where it
// lives; grouping by area is the reading order of the printed document.
const STATE_RANK: Record<string, number> = {
fail: 0, warn: 1, accepted: 2, pass: 3, not_applicable: 4,
}
const STATE_STYLE: Record<string, { chip: string; Icon: typeof XCircle }> = {
fail: { chip: "bg-red-500/10 text-red-500 border-red-500/20", Icon: XCircle },
warn: { chip: "bg-amber-500/10 text-amber-500 border-amber-500/20", Icon: AlertTriangle },
accepted: { chip: "bg-muted text-muted-foreground border-border", Icon: ShieldOff },
pass: { chip: "bg-green-500/10 text-green-500 border-green-500/20", Icon: CheckCircle2 },
not_applicable: { chip: "bg-muted text-muted-foreground border-border", Icon: MinusCircle },
}
// An assessment older than this stops describing the current system, so
// the age is surfaced before any count rather than as a footnote.
const STALE_AFTER_DAYS = 30
export function AuditReport() {
const t = useT()
const [running, setRunning] = useState(false)
const [latest, setLatest] = useState<Run | null>(null)
const [findings, setFindings] = useState<Finding[]>([])
const [summary, setSummary] = useState<Record<string, number>>({})
const [areaFilter, setAreaFilter] = useState<string>("all")
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [showResolved, setShowResolved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [accepting, setAccepting] = useState<Finding | null>(null)
const [reason, setReason] = useState("")
const [expiryDays, setExpiryDays] = useState<string>("")
const [saving, setSaving] = useState(false)
const loadRun = useCallback(async (runId: string) => {
try {
const data: any = await fetchApi(`/api/audit/runs/${runId}`)
if (data?.success) setFindings(data.findings || [])
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const refresh = useCallback(async () => {
try {
const data: any = await fetchApi("/api/audit/status")
if (!data?.success) return
setRunning(Boolean(data.running))
setSummary(data.summary || {})
setLatest(data.latest || null)
if (data.latest?.run_id) await loadRun(data.latest.run_id)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [loadRun])
useEffect(() => { refresh() }, [refresh])
// While an assessment is in flight the page polls; once it settles the
// interval is dropped so an idle tab does not keep waking the backend.
useEffect(() => {
if (!running) return
const id = setInterval(refresh, 2000)
return () => clearInterval(id)
}, [running, refresh])
const startRun = async () => {
setError(null)
try {
const data: any = await fetchApi("/api/audit/run", {
method: "POST",
body: JSON.stringify({ profile: "full" }),
})
if (data?.success) setRunning(true)
else setError(data?.message || t("audit.errors.runFailed"))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
// Accepting or revoking changes which findings are active, so the run
// is re-read afterwards rather than patched in place: the stored
// finding is what the next report will show.
const submitAcceptance = async () => {
if (!accepting || !reason.trim()) return
setSaving(true)
try {
const body: Record<string, unknown> = {
check_id: accepting.check_id,
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
const data: any = await fetchApi("/api/audit/exceptions", {
method: "POST",
body: JSON.stringify(body),
})
if (!data?.success) throw new Error(data?.message || "")
setAccepting(null)
setReason("")
setExpiryDays("")
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const revokeAcceptance = async (checkId: string) => {
try {
await fetchApi(`/api/audit/exceptions/${checkId}`, { method: "DELETE" })
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const areas = useMemo(
() => Array.from(new Set(findings.map((f) => f.area))).sort(),
[findings],
)
const visible = useMemo(() => {
const quiet = new Set(["pass", "not_applicable"])
return findings
.filter((f) => areaFilter === "all" || f.area === areaFilter)
.filter((f) => showResolved || !quiet.has(f.state))
.sort((a, b) =>
(STATE_RANK[a.state] ?? 9) - (STATE_RANK[b.state] ?? 9) ||
a.check_id.localeCompare(b.check_id))
}, [findings, areaFilter, showResolved])
const acceptedCount = summary.accepted || 0
const ageDays = latest?.finished_at
? Math.floor((Date.now() / 1000 - latest.finished_at) / 86400)
: null
const stale = ageDays !== null && ageDays >= STALE_AFTER_DAYS
// The backend stores which sentence applies and its numbers, not the
// sentence itself, so a finding recorded under one language still reads
// correctly under another. A check that failed to evaluate has no
// per-check entry, hence the shared fallback.
const summaryOf = (f: Finding) => {
if (!f.summary_key) return ""
const params = Object.fromEntries(
Object.entries(f.summary_params || {}).map(([k, v]) => [k, String(v)]),
)
const key = `audit.checks.${f.check_id}.summary.${f.summary_key}`
const text = t(key, params)
return text === key ? t("audit.summaryFallback") : text
}
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
if (loading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
{t("audit.loading")}
</div>
)
}
return (
<div className="space-y-4">
<Card className="bg-card border-border">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-xl lg:text-2xl font-bold text-foreground">
<ClipboardCheck className="h-6 w-6" />
{t("audit.title")}
</CardTitle>
{/* Stated before any count: an assessment nobody has run, or
one run months ago, does not describe this host today. */}
{!latest ? (
<p className="text-sm text-muted-foreground">{t("audit.neverRun")}</p>
) : (
<p className={`text-sm ${stale ? "text-amber-500" : "text-muted-foreground"}`}>
{t("audit.lastRun", {
when: new Date((latest.finished_at || latest.started_at) * 1000)
.toLocaleString(),
})}
{stale && `${t("audit.stale", { days: String(ageDays) })}`}
</p>
)}
<p className="text-xs text-muted-foreground">{t("audit.readOnlyNotice")}</p>
</div>
<Button
onClick={startRun}
disabled={running}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{running
? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t("audit.running")}</>
: <><Play className="h-4 w-4 mr-2" />{t("audit.run")}</>}
</Button>
</CardHeader>
{latest && (
<CardContent className="pt-0 space-y-3">
<div className="flex flex-wrap gap-2">
{(["fail", "warn", "accepted", "pass", "not_applicable"] as const)
.filter((s) => summary[s])
.map((s) => {
const { chip, Icon } = STATE_STYLE[s]
return (
<Badge key={s} variant="outline" className={`${chip} gap-1.5`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.states.${s}`)}
<span className="tabular-nums font-semibold">{summary[s]}</span>
</Badge>
)
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setAreaFilter("all")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === "all"
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t("audit.areas.all")}
</button>
{areas.map((a) => (
<button
key={a}
type="button"
onClick={() => setAreaFilter(a)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
areaFilter === a
? "bg-blue-500 text-white"
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`audit.areas.${a}`)}
</button>
))}
<button
type="button"
onClick={() => setShowResolved((v) => !v)}
className="ml-auto text-sm text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showResolved ? t("audit.hidePassing") : t("audit.showPassing")}
</button>
</div>
{/* The count of accepted risks stays visible even when the
findings themselves are filtered out of the list, so a
decision to live with something is never silently lost. */}
{acceptedCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
</p>
)}
</CardContent>
)}
</Card>
{error && (
<p className="text-sm text-red-400 px-1">{error}</p>
)}
{latest && visible.length === 0 && (
<Card className="bg-card border-border">
<CardContent className="py-10 text-center text-muted-foreground">
{t("audit.noFindings")}
</CardContent>
</Card>
)}
<div className="space-y-2">
{visible.map((f) => {
const { chip, Icon } = STATE_STYLE[f.state] || STATE_STYLE.not_applicable
const open = expanded.has(f.check_id)
const muted = f.state === "accepted" || f.state === "not_applicable"
return (
<Card
key={f.check_id}
className={`bg-card border-border ${muted ? "opacity-70" : ""}`}
>
<button
type="button"
onClick={() => toggle(f.check_id)}
aria-expanded={open}
className="w-full text-left p-4 flex items-start gap-3 hover:bg-background/40 transition-colors rounded-lg"
>
{open
? <ChevronDown className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />}
<Badge variant="outline" className={`${chip} gap-1.5 shrink-0`}>
<Icon className="h-3.5 w-3.5" />
{t(`audit.states.${f.state}`)}
</Badge>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">
{t(`audit.checks.${f.check_id}.title`)}
</span>
<Badge variant="outline" className="text-xs">
{t(`audit.areas.${f.area}`)}
</Badge>
{f.affected.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
{t("audit.affectedCount", { count: String(f.affected.length) })}
</Badge>
)}
</div>
{f.summary_key && (
<p className="text-sm text-muted-foreground mt-1">
{summaryOf(f)}
</p>
)}
</div>
</button>
{open && (
<CardContent className="pt-0 pl-11 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.why")}
</p>
<p className="text-sm text-foreground">
{t(`audit.checks.${f.check_id}.rationale`)}
</p>
</div>
{f.exception && (
<div className="rounded-md border border-border bg-background p-3">
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.acceptedRisk")}
</p>
<p className="text-sm text-foreground">{f.exception.reason}</p>
<p className="text-xs text-muted-foreground mt-1">
{f.exception.accepted_by} ·{" "}
{new Date(f.exception.accepted_at * 1000).toLocaleDateString()}
</p>
</div>
)}
{f.affected.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.affected")}
</p>
<div className="flex flex-wrap gap-1.5">
{f.affected.map((o, i) => (
<Badge key={i} variant="outline" className="text-xs font-mono">
{Object.values(o).filter(Boolean).join(" · ")}
</Badge>
))}
</div>
</div>
)}
{f.evidence && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.evidence")}
</p>
{/* Wide command output scrolls inside its own box so
the page itself never scrolls sideways. */}
<pre className="text-xs font-mono bg-background border border-border rounded-md p-3 overflow-x-auto whitespace-pre">
{f.evidence}
</pre>
</div>
)}
{/* Only an active finding can be accepted, and only an
accepted one can be returned to the active set. */}
{(f.state === "fail" || f.state === "warn") && (
<Button
variant="outline"
size="sm"
onClick={() => { setAccepting(f); setReason(""); setExpiryDays("") }}
>
<ShieldOff className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.action")}
</Button>
)}
{f.state === "accepted" && (
<Button
variant="outline"
size="sm"
onClick={() => revokeAcceptance(f.check_id)}
>
<RotateCcw className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.revoke")}
</Button>
)}
</CardContent>
)}
</Card>
)
})}
</div>
<Dialog open={accepting !== null} onOpenChange={(o) => !o && setAccepting(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("audit.acceptRisk.title")}</DialogTitle>
<DialogDescription>
{accepting && t(`audit.checks.${accepting.check_id}.title`)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label htmlFor="audit-reason" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reasonLabel")}
</label>
{/* The reason is required, not encouraged. An acceptance
without one cannot be told apart later from having
silenced the check. */}
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reasonHelp")}
</p>
<textarea
id="audit-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("audit.acceptRisk.reasonPlaceholder")}
/>
</div>
<div>
<label htmlFor="audit-expiry" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.expiryLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.expiryHelp")}
</p>
<select
id="audit-expiry"
value={expiryDays}
onChange={(e) => setExpiryDays(e.target.value)}
className="w-full rounded-md border border-border bg-background p-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("audit.acceptRisk.expiryNever")}</option>
<option value="90">{t("audit.acceptRisk.expiry90")}</option>
<option value="180">{t("audit.acceptRisk.expiry180")}</option>
<option value="365">{t("audit.acceptRisk.expiry365")}</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAccepting(null)}>
{t("audit.acceptRisk.cancel")}
</Button>
<Button
onClick={submitAcceptance}
disabled={!reason.trim() || saving}
className="bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60"
>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("audit.acceptRisk.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+11 -1
View File
@@ -33,7 +33,7 @@ import { Label } from "./ui/label"
import { Badge } from "./ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { useT } from "@/lib/i18n/provider"
@@ -99,6 +99,7 @@ interface AppConfig {
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
update_command?: string
update_method?: "none" | "helper" | "custom"
hide_no_updater_notice?: boolean
// Per-app opt-out for the `app_update_available` notification.
// Absent / true = notify; false = silenced. Set from the bell
@@ -481,6 +482,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
useEffect(() => {
if (managed) return
return subscribeLxcApps(vmid, bundle => {
setSidecar(bundle.sidecar)
setSuggestions(bundle.suggestions)
})
}, [vmid, managed])
useEffect(() => { load() }, [load])
// Live Docker Hub preview. Debounced so typing an image/regex does not
@@ -726,6 +735,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// edits one of its ports.
update_via: (existing as { update_via?: string }).update_via || "",
update_command: existing.update_command || "",
update_method: existing.update_method || (existing.update_command ? "custom" : "none"),
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
exclude_from_badge: existing.exclude_from_badge === true,
@@ -0,0 +1,16 @@
"use client"
import { useI18n } from "../lib/i18n/provider"
export function MetricsCacheNotice({ lastChecked }: { lastChecked: number | null }) {
const { language, t } = useI18n()
if (lastChecked === null) return null
return (
<p role="status" className="text-xs text-amber-600 dark:text-amber-400 mb-2">
{t("overview.metricsCachedWarning", {
time: new Date(lastChecked * 1000).toLocaleString(language),
})}
</p>
)
}
+127 -108
View File
@@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react'
import { fetchApi } from "../lib/api-config"
import { getNetworkUnit } from "../lib/format-network"
import { useT } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
interface NetworkMetricsData {
time: string
@@ -55,12 +56,13 @@ export function NetworkTrafficChart({
const [data, setData] = useState<NetworkMetricsData[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const [isInitialLoad, setIsInitialLoad] = useState(true)
const [visibleLines, setVisibleLines] = useState({
netIn: true,
netOut: true,
})
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
networkUnitProp || getNetworkUnit()
)
@@ -87,133 +89,147 @@ export function NetworkTrafficChart({
}, [networkUnitProp])
useEffect(() => {
let active = true
let hasData = false
let lastChecked: number | null = null
let refreshTimer: ReturnType<typeof setTimeout> | undefined
setIsInitialLoad(true)
fetchMetrics()
}, [timeframe, interfaceName, networkUnit])
useEffect(() => {
if (refreshInterval > 0) {
const interval = setInterval(() => {
fetchMetrics()
}, refreshInterval)
return () => clearInterval(interval)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
const fetchMetrics = async () => {
if (isInitialLoad) {
setLoading(true)
}
setLoading(true)
setError(null)
setStaleAt(null)
setData([])
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const fetchMetrics = async () => {
try {
const apiPath = interfaceName
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
: `/api/node/metrics?timeframe=${timeframe}`
const result = await fetchApi<any>(apiPath)
const result = await fetchApi<any>(apiPath)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
throw new Error(t("network.chart.invalidDataFormat"))
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
hasData = result.data.length > 0
if (result.data.length === 0) {
setData([])
setLoading(false)
return
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
const transformedData = result.data.map((item: any, index: number) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else if (timeframe === "year") {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} else {
timeLabel = date.toLocaleString("en-US", {
month: "short",
day: "numeric",
})
}
let intervalSeconds = 60
if (index > 0) {
intervalSeconds = item.time - result.data[index - 1].time
}
const netInBytes = (item.netin || 0) * intervalSeconds
const netOutBytes = (item.netout || 0) * intervalSeconds
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
}
}
if (networkUnit === "Bits") {
return {
time: timeLabel,
timestamp: item.time,
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
return {
time: timeLabel,
timestamp: item.time,
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
} catch (err: any) {
if (!active) return
console.error("Error fetching network metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
} else {
setError(err?.body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: err.message || t("network.chart.loadError"))
}
} finally {
if (active) {
setLoading(false)
setIsInitialLoad(false)
if (refreshInterval > 0) refreshTimer = setTimeout(fetchMetrics, refreshInterval)
}
})
setData(transformedData)
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netInBytes = (item.netin || 0) * intervalSeconds
return sum + (netInBytes / 1024 / 1024 / 1024)
}, 0)
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
const netOutBytes = (item.netout || 0) * intervalSeconds
return sum + (netOutBytes / 1024 / 1024 / 1024)
}, 0)
if (onTotalsCalculated) {
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
}
if (isInitialLoad) {
setIsInitialLoad(false)
}
} catch (err: any) {
console.error("Error fetching network metrics:", err)
setError(err.message || t("network.chart.loadError"))
} finally {
setLoading(false)
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(refreshTimer)
}
}, [timeframe, interfaceName, refreshInterval, networkUnit])
const tickInterval = Math.ceil(data.length / 8)
@@ -272,6 +288,8 @@ export function NetworkTrafficChart({
}
return (
<>
<MetricsCacheNotice lastChecked={staleAt} />
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data} margin={{ bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
@@ -327,5 +345,6 @@ export function NetworkTrafficChart({
/>
</AreaChart>
</ResponsiveContainer>
</>
)
}
+112 -83
View File
@@ -8,6 +8,7 @@ import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
import { useIsMobile } from "../hooks/use-mobile"
import { fetchApi } from "@/lib/api-config"
import { useI18n } from "../lib/i18n/provider"
import { MetricsCacheNotice } from "./metrics-cache-notice"
const TIMEFRAME_OPTIONS = [
{ value: "hour", labelKey: "overview.timeframes.hour" },
@@ -132,6 +133,7 @@ export function NodeMetricsCharts() {
}>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<MetricsError | null>(null)
const [staleAt, setStaleAt] = useState<number | null>(null)
const isMobile = useIsMobile()
const [visibleLines, setVisibleLines] = useState({
@@ -144,98 +146,124 @@ export function NodeMetricsCharts() {
const hasMemoryFree = data.some(d => d.memoryFree > 0)
useEffect(() => {
fetchMetrics()
}, [timeframe, language])
const fetchMetrics = async () => {
let active = true
let hasData = false
let lastChecked: number | null = null
let retryTimer: ReturnType<typeof setTimeout> | undefined
setLoading(true)
setError(null)
setStaleAt(null)
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
const fetchMetrics = async () => {
let retry = false
try {
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
if (!active) return
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
if (result.data[0]?.loadavg) {
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
if (!result.data || !Array.isArray(result.data)) {
console.error("Invalid data format - data is not an array:", result)
throw new Error(t("overview.invalidMetricsData"))
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setError(null)
lastChecked = result.last_checked || Date.now() / 1000
hasData = result.data.length > 0
setStaleAt(result.cache_status === "stale" ? lastChecked : null)
retry = result.cache_status === "stale"
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
console.error("Error fetching node metrics:", err)
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.details,
suggestion: body?.suggestion,
})
} finally {
setLoading(false)
if (result.data.length === 0) {
console.warn("No data points received")
setData([])
setLoading(false)
return
}
const transformedData = result.data.map((item: any) => {
const date = new Date(item.time * 1000)
let timeLabel = ""
if (timeframe === "hour") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "day") {
timeLabel = date.toLocaleString(language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
} else if (timeframe === "week") {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
hour: "2-digit",
hour12: false,
})
} else {
timeLabel = date.toLocaleString(language, {
month: "short",
day: "numeric",
})
}
return {
time: timeLabel,
timestamp: item.time,
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
load: item.loadavg
? typeof item.loadavg === "number"
? Number(item.loadavg.toFixed(2))
: Array.isArray(item.loadavg) && item.loadavg.length > 0
? Number(item.loadavg[0].toFixed(2))
: 0
: 0,
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
}
})
setData(transformedData)
setPeriodStats(result.period_stats || {})
} catch (err: any) {
if (!active) return
retry = true
console.error("Error fetching node metrics:", err)
if (hasData) {
setStaleAt(lastChecked)
return
}
// fetchApi attaches the parsed JSON body to err.body. The metrics
// endpoint enriches 503 responses with `details` (Proxmox-side
// diagnostic) and `suggestion` (how to fix). Pull them through so
// the user sees actionable text instead of a bare "503".
const body = err?.body
setError({
headline: body?.code === "metrics_timeout"
? t("overview.metricsTimeout")
: body?.error || err?.message || t("overview.metricsLoadError"),
details: body?.code === "metrics_timeout" ? undefined : body?.details,
suggestion: body?.suggestion,
})
} finally {
if (active) {
setLoading(false)
if (retry) retryTimer = setTimeout(fetchMetrics, 60000)
}
}
}
}
void fetchMetrics()
return () => {
active = false
clearTimeout(retryTimer)
}
}, [timeframe, language])
const tickInterval = Math.ceil(data.length / 8)
@@ -358,6 +386,7 @@ export function NodeMetricsCharts() {
return (
<div className="space-y-6">
<MetricsCacheNotice lastChecked={staleAt} />
{/* Timeframe Selector */}
<div className="flex justify-end">
<Select value={timeframe} onValueChange={setTimeframe}>
@@ -12,6 +12,7 @@ import { VirtualMachines } from "./virtual-machines"
import { AppsDashboard } from "./apps-dashboard"
import Hardware from "./hardware"
import { SystemLogs } from "./system-logs"
import { AuditReport } from "./audit-report"
import { Settings } from "./settings"
import { Security } from "./security"
import { Profile } from "./profile"
@@ -41,6 +42,7 @@ import {
Settings2,
Terminal,
ShieldCheck,
ClipboardCheck,
Info,
DatabaseBackup,
ChevronDown,
@@ -399,6 +401,7 @@ export function ProxmoxDashboard() {
case "terminal": return t("navigation.terminal")
case "logs": return t("navigation.systemLogs")
case "security": return t("navigation.security")
case "audit": return t("navigation.audit")
case "settings": return t("navigation.settings")
case "about": return t("navigation.about")
case "profile": return t("navigation.profile")
@@ -618,6 +621,7 @@ export function ProxmoxDashboard() {
const ADMIN_ITEMS = [
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
@@ -840,6 +844,10 @@ export function ProxmoxDashboard() {
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<HostBackup key={`backup-${componentKey}`} />
</TabsContent>
+68 -75
View File
@@ -3,7 +3,7 @@
import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached } from "../lib/lxc-apps-cache"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
@@ -22,6 +22,7 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo } from "./lxc-app-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
@@ -104,6 +105,8 @@ interface LxcAppWatch {
health_path?: string | null
installed_version: string | null
latest_version: string | null
latest_published_at?: string | null
state_revision?: number
update_available: boolean | null
error: string | null
checked_at: string | null
@@ -117,6 +120,7 @@ interface LxcAppWatch {
// update method. When set, the Updates tab renders an "Apply {app}"
// button that runs `pct exec vmid -- sh -c "$update_command"`.
update_command?: string
update_method?: AppUpdateMethod
// Updates tab: per-app dismiss for the "no update method defined"
// notice. Only hides the notice — the App tab still shows purple ⬆
// when an update is available upstream.
@@ -232,13 +236,21 @@ function normalizeVmSearchValue(value: unknown): string {
.toLocaleLowerCase()
}
function matchesVmSearch(vm: VMData, terms: string[]): boolean {
// Type synonyms come from the active locale so a user can search by the
// word they'd naturally use ("contenedor", "machine virtuelle", …) rather
// than only the internal type token. "lxc" / "qemu" already match through
// vm.type, so the catalog entries only carry the natural-language terms.
function matchesVmSearch(
vm: VMData,
terms: string[],
typeSynonyms: { lxc: string; qemu: string },
): boolean {
if (terms.length === 0) return true
const searchable = normalizeVmSearchValue([
vm.name,
vm.vmid,
vm.type,
vm.type === "lxc" ? "container kontajner" : "virtual machine virtualny stroj",
vm.type === "lxc" ? typeSynonyms.lxc : typeSynonyms.qemu,
vm.tags,
vm.description,
vm.ip,
@@ -624,6 +636,9 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
})
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 }>
const runtimeError = mp.runtime_error === "configured but not mounted"
? t("vmLxc.details.mountErrors.configuredButNotMounted")
: mp.runtime_error
return (
<div className={`rounded-lg p-4 ${cardClasses}`}>
@@ -792,13 +807,13 @@ function MountPointCard({ mp }: { mp: LxcMountPoint }) {
)}
{/* Error / divergence note. */}
{mp.runtime_error && (
{runtimeError && (
<p
className={`mt-3 text-sm ${
isStale ? "text-red-400" : "text-amber-400"
}`}
>
{mp.runtime_error}
{runtimeError}
</p>
)}
</div>
@@ -1120,6 +1135,12 @@ export function VirtualMachines() {
setSelectedVM(updated)
}, [vmData])
useEffect(() => {
for (const vm of vmData || []) {
if (vm.type === "lxc") syncLxcAppsState(vm.vmid, vm.app_watches || [])
}
}, [vmData])
// Backend lifecycle refreshes are asynchronous: a start response returns
// immediately, then the server waits for the guest (and Docker, for LXCs)
// before publishing a complete new snapshot. When its revision changes,
@@ -1821,15 +1842,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
updates: safeVMData.filter(hasLxcPendingUpdates).length,
}), [safeVMData])
const vmTypeSynonyms = useMemo(() => ({
lxc: t("vmLxc.filters.typeSynonyms.lxc"),
qemu: t("vmLxc.filters.typeSynonyms.qemu"),
}), [t])
const filteredVMs = useMemo(() => {
const statusMatched = statusFilter === "all"
? safeVMData
: safeVMData.filter((vm) => vm.status === statusFilter)
return statusMatched.filter((vm) => (
(!updatesOnly || hasLxcPendingUpdates(vm))
&& matchesVmSearch(vm, vmSearchTerms)
&& matchesVmSearch(vm, vmSearchTerms, vmTypeSynonyms)
))
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms])
}, [safeVMData, statusFilter, updatesOnly, vmSearchTerms, vmTypeSynonyms])
// ── LXC update apply flow (Phase 2a/b) ────────────────────────────
// Users pick a target (OS, App, both) + backup / restart options,
@@ -1872,15 +1898,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// Case-3a "no method" cards the user previously dismissed.
const [customCmdEditingApp, setCustomCmdEditingApp] = useState<string | null>(null)
const [customCmdDraft, setCustomCmdDraft] = useState<string>("")
const [updaterMethodDraft, setUpdaterMethodDraft] = useState<AppUpdateMethod>("none")
const [customCmdSaving, setCustomCmdSaving] = useState(false)
const [showHiddenNotices, setShowHiddenNotices] = useState(false)
const canonicalHelperUpdateCommand = (slug?: string | null) => {
const cleanSlug = (slug || "").trim()
if (!/^[A-Za-z0-9._-]+$/.test(cleanSlug)) return ""
return `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${cleanSlug}.sh)"`
}
// Docker Engine is updated by a protected host-side runner rather
// than by an arbitrary command inside the CT. Surface the exact
// command in the same editor used by every other app, but recognise
@@ -1890,6 +1911,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openCustomCmdEditor = (app: LxcAppWatch, initialCommand = "") => {
setCustomCmdEditingApp(app.id)
setCustomCmdDraft(app.update_command || initialCommand)
setUpdaterMethodDraft(app.update_method || (app.update_command || initialCommand ? "custom" : "none"))
}
// ── Options card unified state ──────────────────────────────────
@@ -2389,7 +2411,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, {
update_command: customCmdDraft.trim(),
update_command: updaterMethodDraft === "helper" ? "" : customCmdDraft.trim(),
update_method: app.helper_slug === "docker" ? "custom" : updaterMethodDraft,
// Saving a command implicitly re-enables the notice (moot —
// the notice only shows when there is no command).
hide_no_updater_notice: false,
@@ -2402,10 +2425,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
}
const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => {
if (!confirm(t("vmLxc.errors.removeCustomCommandConfirm", { name: app.name }))) return
const confirmKey = app.helper_slug === "docker"
? "vmLxc.errors.removeCustomCommandConfirm"
: "vmLxc.updates.disableUpdaterConfirm"
if (!confirm(t(confirmKey, { name: app.name || "" }))) return
setCustomCmdSaving(true)
try {
await patchAppWatch(vmid, app, { update_command: "" })
await patchAppWatch(vmid, app, { update_command: "", update_method: "none" })
closeCustomCmdEditor()
} catch (e) {
alert(t("vmLxc.errors.removeCustomCommandFailed", { message: (e as any)?.message || String(e) }))
@@ -5206,7 +5232,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const scheduledAppChoices = registeredApps.filter((app) => {
if (app.helper_slug === "docker" || app.helper_slug === "adguard") return false
if (app.update_command?.trim()) return true
return helperExists && app.helper_slug === uc?.helper_slug
return helperExists && app.update_method === "helper" && app.helper_slug === uc?.helper_slug
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
const versionTrackedScheduleAppIds = new Set(
registeredApps
@@ -5275,6 +5301,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (app.helper_slug === "docker") return false
if (app.update_command?.trim()) return true
return helperExists
&& app.update_method === "helper"
&& app.helper_slug === uc?.helper_slug
&& app.helper_slug !== "adguard"
}).map((app) => {
@@ -5801,9 +5828,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{helperSectionDetected && (() => {
const matchApp = helperOnlyApps[0] || null
if (!matchApp || customCmdEditingApp === matchApp.id) return null
const helperEditorCommand = helperExists && !helperUsesWebUpdater
? canonicalHelperUpdateCommand(uc?.helper_slug)
: ""
const helperSelected = matchApp.update_method === "helper"
const appWebUrl = helperUsesWebUpdater
? buildRegisteredAppUrl(selectedVM, matchApp.ports?.[0])
: null
@@ -5823,12 +5848,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
type="button"
onClick={() => openCustomCmdEditor(
matchApp,
helperEditorCommand,
)}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
>
<Settings2 className="h-3.5 w-3.5" />
{helperEditorCommand
{helperSelected
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5887,7 +5911,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.versionTrackingNotConfigured")}
</p>
) : null}
{helperExists && !helperUsesWebUpdater && (() => {
{helperExists && !helperUsesWebUpdater && !helperSelected && (
<p className="mt-3 text-xs text-muted-foreground">{t("vmLxc.updates.noUpdaterSelected")}</p>
)}
{helperExists && !helperUsesWebUpdater && helperSelected && (() => {
// Button state uses the matched
// App Watch entry when present.
// Without it we DON'T know the
@@ -5904,7 +5931,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return (
<div className="mt-3 flex justify-end">
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "" })} className={cls}>
<Button size="sm" onClick={() => openApplyTerminal(selectedVM.vmid, "app", { runHelper: true, appName: helperName || "", targetIds: [`app:${matchApp.id}`] })} className={cls}>
{hasUpd && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{noState && <RefreshCw className="h-4 w-4 mr-1.5" />}
{label}
@@ -5965,7 +5992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 flex-shrink-0"
>
<Settings2 className="h-3.5 w-3.5" />
{hasCmd
{hasCmd || aw.update_method === "helper"
? t("vmLxc.updates.editApp")
: t("vmLxc.updates.configureUpdater")}
</button>
@@ -5977,56 +6004,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
)}
{editing ? (
<div className="space-y-3">
<div>
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.updates.customCommandLabel")}
</Label>
<Textarea
value={customCmdDraft}
onChange={(e) => setCustomCmdDraft(e.target.value)}
placeholder={t("vmLxc.updates.customCommandPlaceholder")}
className="font-mono text-xs mt-2 min-h-[100px]"
maxLength={4096}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div>
{hasCmd && (
<button
type="button"
onClick={() => removeCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving}
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="h-3.5 w-3.5" />
{t("vmLxc.updates.removeButton")}
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={closeCustomCmdEditor}
disabled={customCmdSaving}
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
>
{t("vmLxc.updates.cancelButton")}
</button>
<button
type="button"
onClick={() => saveCustomCommand(selectedVM.vmid, aw)}
disabled={customCmdSaving || !customCmdDraft.trim() || (
customCmdDraft.trim() === (aw.update_command || "").trim()
)}
className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{customCmdSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("vmLxc.updates.saveButton")}
</button>
</div>
</div>
</div>
<AppUpdaterEditor
method={updaterMethodDraft}
command={customCmdDraft}
helperAvailable={helperExists && !helperUsesWebUpdater && aw.helper_slug === uc?.helper_slug}
helperSlug={aw.helper_slug}
configured={hasCmd || aw.update_method === "helper"}
saving={customCmdSaving}
changed={updaterMethodDraft !== (aw.update_method || (hasCmd ? "custom" : "none")) || (
updaterMethodDraft === "custom" && customCmdDraft.trim() !== (aw.update_command || "").trim()
)}
onMethodChange={setUpdaterMethodDraft}
onCommandChange={setCustomCmdDraft}
onSave={() => saveCustomCommand(selectedVM.vmid, aw)}
onCancel={closeCustomCmdEditor}
onRemove={() => removeCustomCommand(selectedVM.vmid, aw)}
/>
) : (
<>
{hasUpdate ? (