feat(oci): run official container images as native LXC containers

Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-22 18:24:59 +02:00
co-authored by Claude Opus 5
parent b36498f215
commit bcabcb618c
670 changed files with 221410 additions and 215 deletions
+42 -7
View File
@@ -4,8 +4,8 @@ import { useCallback, useEffect, useState } from "react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import {
ChevronDown, ChevronRight, Flag, Loader2, MinusCircle,
PlusCircle, ShieldOff, TrendingUp,
ArrowDownRight, ArrowUpRight, ChevronDown, ChevronRight, Flag, Loader2,
MinusCircle, PlusCircle, ShieldOff, TrendingUp,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT, useI18n } from "../lib/i18n/provider"
@@ -24,6 +24,12 @@ import { useT, useI18n } from "../lib/i18n/provider"
* only the first is progress, and merging them would tell the reader a
* problem went away when the decision was to live with it.
*
* The same care applies to a finding that is still reported. One that
* was already failing has not appeared now, so it is shown as having got
* worse or better with where it came from, rather than as new — reading
* "new" against work that lowered a critical to a warning would punish
* exactly the reader who fixed something.
*
* It sits inside the assessment rather than in a view of its own,
* because "what changed since last time" is context for the run being
* read, not a separate place to visit.
@@ -33,12 +39,18 @@ interface Finding {
check_id: string
area: string
classification: string
// Only the findings that moved carry where they came from.
previous_classification?: string
previous_affected?: number
affected_count?: number
}
interface Comparison {
from: string
to: string
new: Finding[]
worse: Finding[]
better: Finding[]
resolved: Finding[]
accepted: Finding[]
unchanged: Finding[]
@@ -46,8 +58,23 @@ interface Comparison {
unverified: Finding[]
}
/** What moved, in the reader's terms: the gravity when that is what
* changed, otherwise how many objects the finding now covers. */
function movement(f: Finding, t: (k: string) => string): string | null {
if (!f.previous_classification) return null
if (f.previous_classification !== f.classification) {
return `${t(`audit.classifications.${f.previous_classification}`)} → ${t(
`audit.classifications.${f.classification}`,
)}`
}
if (f.previous_affected === undefined || f.affected_count === undefined) return null
return `${f.previous_affected} → ${f.affected_count}`
}
const GROUPS = [
{ key: "new", Icon: PlusCircle, tone: "text-amber-500" },
{ key: "worse", Icon: ArrowUpRight, tone: "text-red-400" },
{ key: "better", Icon: ArrowDownRight, tone: "text-emerald-400" },
{ key: "resolved", Icon: MinusCircle, tone: "text-green-500" },
{ key: "accepted", Icon: ShieldOff, tone: "text-indigo-400" },
{ key: "retired", Icon: Flag, tone: "text-muted-foreground" },
@@ -199,11 +226,19 @@ export function AuditComparison({ runId, isBaseline, onBaselineSet }: {
</span>
</p>
<div className="flex flex-wrap gap-1.5">
{(comparison[key] || []).map((f) => (
<Badge key={f.check_id} variant="outline" className="text-xs">
{t(`audit.checks.${f.check_id}.title`)}
</Badge>
))}
{(comparison[key] || []).map((f) => {
const moved = movement(f, t)
return (
<Badge key={f.check_id} variant="outline" className="text-xs">
{t(`audit.checks.${f.check_id}.title`)}
{moved && (
<span className="ml-1.5 font-normal text-muted-foreground tabular-nums">
{moved}
</span>
)}
</Badge>
)
})}
</div>
</div>
),
+98 -5
View File
@@ -44,7 +44,14 @@ interface Finding {
collected_at?: number
check_version?: number
sources?: Array<{ source: string; collected_at: number; error?: string }>
exception?: { reason: string; accepted_by: string; accepted_at: number; expires_at?: number | null } | null
exception?: {
reason: string; accepted_by: string; accepted_at: number
expires_at?: number | null
// Asks for the decision to be looked at again on this date. It does
// not withdraw it: an acceptance can stand indefinitely and still
// come back for review.
review_at?: number | null
} | null
}
interface Run {
@@ -117,6 +124,7 @@ export function AuditReport() {
const [accepting, setAccepting] = useState<Finding | null>(null)
const [reason, setReason] = useState("")
const [expiryDays, setExpiryDays] = useState<string>("")
const [reviewDays, setReviewDays] = useState<string>("")
const [saving, setSaving] = useState(false)
const [progress, setProgress] = useState({ completed: 0, total: 0 })
// The profile decides which question the page answers, so it governs
@@ -165,9 +173,11 @@ export function AuditReport() {
// Expiry changes a decision, not the assessment. One local timer and
// a focus refresh keep it current without periodic scans or idle polling.
useEffect(() => {
const expiry = findings.flatMap((f) => f.exception?.expires_at ? [f.exception.expires_at] : [])
if (!expiry.length) return
const delay = Math.max(100, Math.min(2147483647, Math.min(...expiry) * 1000 - Date.now() + 100))
const now = Date.now() / 1000
const due = findings.flatMap((f) => [f.exception?.expires_at, f.exception?.review_at]
.filter((t): t is number => !!t && t > now))
if (!due.length) return
const delay = Math.max(100, Math.min(2147483647, Math.min(...due) * 1000 - Date.now() + 100))
const id = setTimeout(refresh, delay)
return () => clearTimeout(id)
}, [findings, refresh])
@@ -235,6 +245,7 @@ export function AuditReport() {
reason: reason.trim(),
}
if (expiryDays) body.expires_in_days = Number(expiryDays)
if (reviewDays) body.review_in_days = Number(reviewDays)
const data: any = await fetchApi("/api/audit/exceptions", {
method: "POST",
body: JSON.stringify(body),
@@ -243,6 +254,7 @@ export function AuditReport() {
setAccepting(null)
setReason("")
setExpiryDays("")
setReviewDays("")
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
@@ -277,6 +289,9 @@ export function AuditReport() {
[findings, areaFilter])
const acceptedCount = summary.accepted || 0
const reviewDueCount = findings.filter(
(f) => f.exception?.review_at && f.exception.review_at * 1000 <= Date.now(),
).length
const unverifiedChecks = findings.filter(
(f) => f.classification === "unverified" || f.incomplete)
const ageDays = latest?.finished_at
@@ -299,6 +314,26 @@ export function AuditReport() {
return text === key ? t("audit.summaryFallback") : text
}
// A check's plain-language texts are optional. Those that do not carry
// them yet render nothing, rather than the raw key a missing lookup
// returns, so the section can gain them one area at a time.
const optional = (key: string) => {
const text = t(key)
return text === key ? null : text
}
// What to do about a finding depends on what was found, not on what was
// checked: an outcome that is not a problem has no next step, and one
// that could not be evaluated has nothing to act on either.
const nextStepOf = (f: Finding) => {
if (f.classification === "not_applicable") return null
if (f.classification === "unverified" || f.incomplete) return t("audit.couldNotEvaluate")
if (f.classification !== "critical" && f.classification !== "warning") {
return t("audit.noActionNeeded")
}
return f.summary_key ? optional(`audit.checks.${f.check_id}.nextStep.${f.summary_key}`) : null
}
const notApplicableText = (f: Finding) => {
if (f.summary_key) return ""
return f.classification === "not_applicable" ? t("audit.notApplicableScope") : ""
@@ -594,6 +629,15 @@ export function AuditReport() {
{t("audit.acceptedNotice", { count: String(acceptedCount) })}
</p>
)}
{/* A decision that asked to be revisited says so here, where it
is read without going to look for it. The acceptance still
stands; this is a reminder, not a lapse. */}
{reviewDueCount > 0 && (
<p className="text-xs text-amber-500">
{t("audit.reviewDueNotice", { count: String(reviewDueCount) })}
</p>
)}
</CardContent>
)}
</Card>
@@ -671,6 +715,17 @@ export function AuditReport() {
{open && (
<CardContent className="pt-0 pl-11 space-y-4">
{optional(`audit.checks.${f.check_id}.explanation`) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatItMeans")}
</p>
<p className="text-sm text-foreground">
{optional(`audit.checks.${f.check_id}.explanation`)}
</p>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.why")}
@@ -680,6 +735,15 @@ export function AuditReport() {
</p>
</div>
{nextStepOf(f) && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
{t("audit.detail.whatToDo")}
</p>
<p className="text-sm text-foreground">{nextStepOf(f)}</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">
@@ -692,7 +756,13 @@ export function AuditReport() {
{f.exception.expires_at && <> · {t("audit.expires", {
when: new Date(f.exception.expires_at * 1000).toLocaleString(),
})}</>}
{f.exception.review_at && <> · {t("audit.reviewOn", {
when: new Date(f.exception.review_at * 1000).toLocaleDateString(),
})}</>}
</p>
{!!f.exception.review_at && f.exception.review_at * 1000 <= Date.now() && (
<p className="text-xs text-amber-500 mt-1">{t("audit.reviewDue")}</p>
)}
</div>
)}
@@ -728,7 +798,7 @@ export function AuditReport() {
<Button
variant="outline"
size="sm"
onClick={() => { setAccepting(f); setReason(""); setExpiryDays("") }}
onClick={() => { setAccepting(f); setReason(""); setExpiryDays(""); setReviewDays("") }}
>
<ShieldOff className="h-4 w-4 mr-2" />
{t("audit.acceptRisk.action")}
@@ -823,6 +893,29 @@ export function AuditReport() {
</SelectContent>
</Select>
</div>
{/* Separate from the expiry on purpose: this one asks to look at
the decision again without withdrawing it, so "remind me in a
year" no longer has to be spelled as "stop accepting this in a
year". */}
<div>
<label htmlFor="audit-review" className="text-sm font-medium text-foreground">
{t("audit.acceptRisk.reviewLabel")}
</label>
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
{t("audit.acceptRisk.reviewHelp")}
</p>
<Select value={reviewDays || "none"}
onValueChange={(v) => setReviewDays(v === "none" ? "" : v)}>
<SelectTrigger id="audit-review" className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("audit.acceptRisk.reviewNever")}</SelectItem>
<SelectItem value="90">{t("audit.acceptRisk.expiry90")}</SelectItem>
<SelectItem value="180">{t("audit.acceptRisk.expiry180")}</SelectItem>
<SelectItem value="365">{t("audit.acceptRisk.expiry365")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
+264
View File
@@ -0,0 +1,264 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { Check, HardDrive, Info, Loader2, Settings2 } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Switch } from "./ui/switch"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface DiskEntry {
name: string
key: string
model: string
serial: string
size_bytes: number
transport: string
rotational: boolean
excluded: boolean
excluded_at: string | null
idle: boolean
present: boolean
}
function formatSize(bytes: number): string {
if (!bytes) return ""
const units = ["B", "KB", "MB", "GB", "TB", "PB"]
let value = bytes
let i = 0
while (value >= 1000 && i < units.length - 1) {
value /= 1000
i++
}
return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
}
// Disks the user wants left alone. An excluded disk is never read on a
// schedule — no temperature, no SMART refresh, not even a power-mode query —
// so it can spin down on its own timer. Rotational disks with no I/O are
// already left alone automatically; this is for the ones that should never
// be touched at all, such as a drive handed whole to a VM.
export function DiskExclusions() {
const t = useT()
const [disks, setDisks] = useState<DiskEntry[]>([])
const [loading, setLoading] = useState(true)
const [editMode, setEditMode] = useState(false)
const [pending, setPending] = useState<Map<string, boolean>>(new Map())
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState("")
const load = useCallback(async () => {
try {
const data = await fetchApi<{ disks: DiskEntry[] }>("/api/health/disks")
setDisks(data.disks || [])
} catch {
setDisks([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const cancel = () => {
setPending(new Map())
setError("")
setEditMode(false)
}
const save = async () => {
if (pending.size === 0) {
setEditMode(false)
return
}
setSaving(true)
setError("")
try {
for (const [key, excluded] of pending.entries()) {
const disk = disks.find((d) => d.key === key)
if (!disk) continue
if (excluded) {
await fetchApi("/api/health/disk-exclusions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
disk_key: disk.key,
disk_name: disk.name,
model: disk.model,
serial: disk.serial,
}),
})
} else {
await fetchApi(`/api/health/disk-exclusions/${encodeURIComponent(disk.key)}`, {
method: "DELETE",
})
}
}
setPending(new Map())
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
await load()
} catch {
setError(t("settings.diskExclusions.saveFailed"))
} finally {
setSaving(false)
}
}
const transportLabel = (disk: DiskEntry) => {
const tr = disk.transport.toLowerCase()
if (tr === "usb") return "USB"
if (tr === "nvme") return "NVMe"
if (tr === "sata" || tr === "ata") return disk.rotational ? "HDD" : "SSD"
if (tr) return tr.toUpperCase()
return disk.rotational ? "HDD" : "SSD"
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-500" />
<CardTitle>{t("settings.diskExclusions.title")}</CardTitle>
</div>
{!loading && disks.length > 0 && (
<div className="flex items-center gap-2">
{saved && (
<span className="flex items-center gap-1 text-xs text-green-500">
<Check className="h-3.5 w-3.5" />
{t("status.saved")}
</span>
)}
{editMode ? (
<>
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={cancel}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
onClick={save}
disabled={saving || pending.size === 0}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
</div>
)}
</div>
<CardDescription>{t("settings.diskExclusions.description")}</CardDescription>
</CardHeader>
<CardContent className={editMode ? "bg-accent" : undefined}>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
</div>
) : disks.length === 0 ? (
<div className="text-center py-8">
<HardDrive className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">{t("settings.diskExclusions.empty")}</p>
</div>
) : (
<div className="space-y-0">
<div className="grid grid-cols-[1fr_auto] gap-4 pb-2 mb-1 border-b border-border">
<span className="text-xs font-medium text-muted-foreground">
{t("settings.diskExclusions.disk")}
</span>
<span className="text-xs font-medium text-muted-foreground text-center w-24">
{t("settings.diskExclusions.periodicReads")}
</span>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-border/50">
{disks.map((disk) => {
const excluded = pending.has(disk.key) ? pending.get(disk.key)! : disk.excluded
return (
<div key={disk.key} className="grid grid-cols-[1fr_auto] gap-4 py-3 items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`font-medium ${excluded ? "text-muted-foreground" : ""}`}>
{disk.name || "—"}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{transportLabel(disk)}
</Badge>
{excluded && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
{t("settings.diskExclusions.excluded")}
</Badge>
)}
{!excluded && disk.idle && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
title={t("storage.idleTitle")}
>
{t("storage.idle")}
</Badge>
)}
{!disk.present && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
{t("settings.diskExclusions.notConnected")}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground break-all">
{[disk.model, formatSize(disk.size_bytes), disk.serial].filter(Boolean).join(" · ")}
</span>
</div>
<div className="flex justify-center w-24">
<Switch
checked={!excluded}
disabled={!editMode || saving}
onCheckedChange={(checked) => {
setPending((m) => {
const next = new Map(m)
if (!checked === disk.excluded) next.delete(disk.key)
else next.set(disk.key, !checked)
return next
})
}}
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
/>
</div>
</div>
)
})}
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("settings.diskExclusions.help")}
<br />
{t("settings.diskExclusions.idleHelp")}
</p>
</div>
</div>
)}
</CardContent>
</Card>
)
}
+7 -1
View File
@@ -6177,7 +6177,13 @@ function AddDestinationDialog({
// back to the URL match; default 22 when neither is set.
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
setBorgSshPort(String(port || 22))
setBorgSshRemotePath(`/${ssh[4]}`)
// `./path` (relative to the SSH user's home) and `~/path` are Borg
// path forms of their own — only an absolute path gets the slash
// the URL dropped.
const remotePath = ssh[4]
setBorgSshRemotePath(
remotePath.startsWith("./") || remotePath.startsWith("~/") ? remotePath : `/${remotePath}`,
)
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
setBorgRepo("")
} else {
+3
View File
@@ -8,6 +8,7 @@ import { Button } from "./ui/button"
import { NotificationSettings } from "./notification-settings"
import { HealthThresholds } from "./health-thresholds"
import { LxcUpdateDetection } from "./lxc-update-detection"
import { DiskExclusions } from "./disk-exclusions"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Switch } from "./ui/switch"
@@ -1862,6 +1863,8 @@ export function Settings() {
</CardContent>
</Card>
<DiskExclusions />
{/* Health Monitor Thresholds — placed above Notifications because the
values configured here drive what triggers the notifications below. */}
<HealthThresholds />
+37 -10
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power } from "lucide-react"
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -70,6 +70,8 @@ interface DiskInfo {
// badge AND to suppress the (stale) temperature value, so the
// operator understands the graph is frozen on purpose — issue #232.
standby?: boolean
idle?: boolean
excluded?: boolean
health: string
power_on_hours?: number
smart_status?: string
@@ -434,7 +436,21 @@ export function StorageOverview() {
// spun-down drive. Centralised here because the same pattern shows up
// in 4 different disk-list views (system / data / pool / other) and we
// want them all to behave identically — issue #232 fix.
const renderDiskTempOrStandby = (disk: DiskInfo) => {
// Why a disk shows no live temperature, when it doesn't: excluded by the
// user, parked, or idle and deliberately not read. Shared by every view
// that paints a disk's temperature, so they cannot disagree.
const renderNoReadingBadge = (disk: DiskInfo) => {
if (disk.excluded) {
return (
<Badge
className="bg-muted text-muted-foreground border-border gap-1"
title={t("storage.diskExcludedTitle")}
>
<EyeOff className="h-3 w-3" />
{t("storage.diskExcluded")}
</Badge>
)
}
if (disk.standby) {
return (
<Badge
@@ -446,6 +462,23 @@ export function StorageOverview() {
</Badge>
)
}
if (disk.idle) {
return (
<Badge
className="bg-muted text-muted-foreground border-border gap-1"
title={t("storage.idleTitle")}
>
<Moon className="h-3 w-3" />
{t("storage.idle")}
</Badge>
)
}
return null
}
const renderDiskTempOrStandby = (disk: DiskInfo) => {
const noReading = renderNoReadingBadge(disk)
if (noReading) return noReading
if (disk.temperature > 0) {
return (
<div className="flex items-center gap-1">
@@ -533,14 +566,8 @@ export function StorageOverview() {
{/* Header line 2: size + temperature/standby. */}
<div className="flex items-center justify-between gap-3 mt-1">
<span className="text-sm text-muted-foreground">{disk.size_formatted}</span>
{disk.standby ? (
<Badge
className="bg-blue-500/10 text-blue-300 border-blue-500/30 gap-1"
title={t("storage.standbyTitle")}
>
<Power className="h-3 w-3" />
{t("storage.standby")}
</Badge>
{renderNoReadingBadge(disk) ? (
renderNoReadingBadge(disk)
) : disk.temperature > 0 ? (
<span
className={`text-base font-semibold ${getTempColor(
+23 -4
View File
@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Tag as TagIcon } from 'lucide-react'
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox"
import { Switch } from "./ui/switch"
@@ -100,6 +100,7 @@ interface LxcAppWatch {
// once, through the image.
docker_available_version?: string | null
docker_update_available?: boolean | null
docker_pinned?: boolean | null
docker_image_reference?: string | null
docker_binding_error?: string | null
ports?: LxcAppPort[]
@@ -179,6 +180,7 @@ interface LxcDockerImageUpdate {
update_targets?: LxcDockerComposeTarget[]
standalone_containers?: string[]
update_available: boolean | null
pinned?: boolean
error: string | null
}
@@ -5740,7 +5742,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : (
<span className={image.update_available === false ? "text-green-500" : undefined}>
{t("vmLxc.updates.imageInstalledTag")} {" "}
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.tag}</code>
<code className={image.update_available === false ? "text-green-500" : "text-foreground/80"}>{image.tag || image.local_digest?.slice(0, 19)}</code>
</span>
)}
</div>
@@ -5758,11 +5760,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.imageUpToDate")}
</span>
)}
{image.update_available === null && (
{image.update_available === null && !image.pinned && (
<span className="sm:hidden mt-1 text-xs text-muted-foreground inline-flex" title={image.error || undefined}>
{t("vmLxc.updates.imageDigestUnknown")}
</span>
)}
{image.pinned && (
<span className="sm:hidden mt-1 text-xs text-muted-foreground inline-flex items-center gap-1.5" title={t("vmLxc.updates.imagePinnedTitle")}>
<Pin className="h-3.5 w-3.5 flex-shrink-0" />
{t("vmLxc.updates.imagePinned")}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
@@ -5772,11 +5780,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.imageUpToDate")}
</span>
)}
{image.update_available === null && (
{image.update_available === null && !image.pinned && (
<span className="hidden sm:inline-flex text-xs text-muted-foreground flex-shrink-0" title={image.error || undefined}>
{t("vmLxc.updates.imageDigestUnknown")}
</span>
)}
{image.pinned && (
<span className="hidden sm:inline-flex items-center gap-1.5 text-xs text-muted-foreground flex-shrink-0" title={t("vmLxc.updates.imagePinnedTitle")}>
<Pin className="h-3.5 w-3.5 flex-shrink-0" />
{t("vmLxc.updates.imagePinned")}
</span>
)}
{image.update_available === true && (image.update_targets || []).map((target) => (
<Button
key={`${image.reference}-${target.project}`}
@@ -6066,6 +6080,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imageUpToDate")}</span>
</div>
) : aw.docker_pinned ? (
<div className="flex items-center gap-2" title={t("vmLxc.updates.imagePinnedTitle")}>
<Pin className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imagePinned")}</span>
</div>
) : null
) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>