mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-23 15:16:44 +00:00
Merge develop into PR #337 and resolve shared app cache conflict
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user