mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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:
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user