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>
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"notApplicable": "n / A",
|
||||
"error": "Fehler",
|
||||
"system": "System",
|
||||
"standby": "Stehen zu",
|
||||
"standby": "Standby",
|
||||
"standbyTitle": "Das Laufwerk befindet sich im Standby-Modus – Smartctl wurde übersprungen, um es im Ruhezustand zu halten",
|
||||
"filesystemCorruption": "Dateisystembeschädigung erkannt",
|
||||
"ioErrorOne": "{count} E/A-Fehler in 5 Min",
|
||||
@@ -2206,7 +2206,7 @@
|
||||
"password": "Passwort",
|
||||
"usernamePlaceholder": "Geben Sie Ihren Benutzernamen ein",
|
||||
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
|
||||
"rememberMe": "Erinnere dich an mich",
|
||||
"rememberMe": "Anmeldedaten merken",
|
||||
"missingCredentials": "Bitte geben Sie Benutzernamen und Passwort ein",
|
||||
"missingTotp": "Bitte geben Sie Ihren 2FA-Code ein",
|
||||
"invalidCredentials": "Falscher Benutzername oder Passwort",
|
||||
@@ -3396,7 +3396,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"active": "aktiv",
|
||||
"standby": "stehen zu",
|
||||
"standby": "Standby",
|
||||
"down": "runter"
|
||||
},
|
||||
"empty": {
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"reviewOn": "Überprüfen am: {when}",
|
||||
"reviewDue": "Zur Überprüfung fällig — die Entscheidung gilt weiter",
|
||||
"reviewDueNotice": "{count} akzeptierte Entscheidung(en) stehen zur Überprüfung an.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "Was das bedeutet",
|
||||
"whatToDo": "Was zu tun ist",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5674,20 +5679,23 @@
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
"action": "Risiko akzeptieren",
|
||||
"revoke": "Wieder aktivieren",
|
||||
"title": "Dieses Risiko akzeptieren",
|
||||
"reasonLabel": "Begründung",
|
||||
"reasonHelp": "Erforderlich. Sie wird zusammen mit Urheber und Datum festgehalten.",
|
||||
"reasonPlaceholder": "z. B. Labor-Container, absichtlich nicht abgedeckt",
|
||||
"expiryLabel": "Gilt nicht mehr nach",
|
||||
"expiryHelp": "Nach Ablauf des Zeitraums wird der Befund wieder aktiv.",
|
||||
"expiryNever": "Läuft nicht ab",
|
||||
"reviewLabel": "An Überprüfung erinnern",
|
||||
"reviewHelp": "Die Entscheidung gilt weiter; sie wird nur wieder in Erinnerung gerufen.",
|
||||
"reviewNever": "Keine Erinnerung",
|
||||
"expiry90": "90 Tage",
|
||||
"expiry180": "180 Tage",
|
||||
"expiry365": "1 Jahr",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Risiko akzeptieren"
|
||||
},
|
||||
"incomplete": "Unvollständige Nachweise",
|
||||
"progress": "{completed} von {total} geprüft",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} Prüfungen ergaben dasselbe wie zuvor.",
|
||||
"new": "Neu",
|
||||
"newNote": "jetzt gemeldet, vorher nicht",
|
||||
"worse": "Verschlechtert",
|
||||
"worseNote": "weiterhin gemeldet, und schwerwiegender oder weiter reichend als zuvor",
|
||||
"better": "Verbessert",
|
||||
"betterNote": "weiterhin gemeldet, aber weniger schwerwiegend oder weniger weit reichend als zuvor",
|
||||
"resolved": "Behoben",
|
||||
"resolvedNote": "nicht mehr gemeldet, und niemand hat sie akzeptiert",
|
||||
"accepted": "Akzeptiert",
|
||||
|
||||
@@ -807,7 +807,11 @@
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday"
|
||||
}
|
||||
}
|
||||
},
|
||||
"idle": "Idle",
|
||||
"idleTitle": "Not read while nothing is using it, so it can spin down. Its temperature is shown again as soon as the disk is in use.",
|
||||
"diskExcluded": "Excluded",
|
||||
"diskExcludedTitle": "Excluded from periodic reads in Settings."
|
||||
},
|
||||
"details": {
|
||||
"temperature": {
|
||||
@@ -1404,6 +1408,8 @@
|
||||
"imageUpToDate": "Up to date",
|
||||
"imageInstalledTag": "installed tag",
|
||||
"imageDigestUnknown": "Digest unavailable",
|
||||
"imagePinned": "Pinned to a digest",
|
||||
"imagePinnedTitle": "This container runs the exact image its digest names. A newer tag does not change it; editing the reference in its configuration does.",
|
||||
"dockerPendingSummary": "{count} Docker image update(s) detected. Update with the owning Docker or Compose workflow.",
|
||||
"postApplyChecking": "Verifying update result…",
|
||||
"postApplyAllOk": "{count} package(s) applied successfully — nothing pending.",
|
||||
@@ -2197,6 +2203,18 @@
|
||||
"reset": "Restore default",
|
||||
"hint": "Drag to reorder · On touch, long-press first",
|
||||
"customActive": "Using custom navigation order."
|
||||
},
|
||||
"diskExclusions": {
|
||||
"title": "Disk exclusions",
|
||||
"description": "Disks that are never read on a schedule, so they can spin down on their own timer.",
|
||||
"empty": "No physical disks found.",
|
||||
"disk": "Disk",
|
||||
"periodicReads": "Periodic reads",
|
||||
"excluded": "Excluded",
|
||||
"notConnected": "Not connected",
|
||||
"saveFailed": "Could not save the disk exclusions.",
|
||||
"help": "An excluded disk gets no scheduled temperature or SMART reads — not even a power-mode query — so it is left entirely alone. Opening its SMART details still reads it.",
|
||||
"idleHelp": "Mechanical disks with no activity are already left alone automatically until something uses them again."
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -5208,6 +5226,11 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"noActionNeeded": "Nothing to do. This check found what it expects to find.",
|
||||
"couldNotEvaluate": "This check could not be evaluated, so it reports nothing either way. The evidence records what it was unable to read.",
|
||||
"reviewOn": "Review on: {when}",
|
||||
"reviewDue": "Due for review — the decision still stands",
|
||||
"reviewDueNotice": "{count} accepted decision(s) are due for review.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5251,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "What this means",
|
||||
"whatToDo": "What to do",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5240,6 +5265,13 @@
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Backup coverage",
|
||||
"explanation": "A backup protects only what a job actually selects. This check pairs the guests on this node with the jobs that run here, so a guest nobody backs up appears as such instead of being assumed to be covered by something else.",
|
||||
"nextStep": {
|
||||
"noJobs": "Create a backup job on this node and select the guests holding data you would not want to rebuild by hand. A job that exists but is disabled selects nothing.",
|
||||
"uncovered": "Decide, for each of these guests, whether it holds anything worth keeping. Add the ones that do to a job; for the ones that do not, declare that in the policy so they stop being counted here.",
|
||||
"excludedData": "Open each job's exclusion list and confirm that what it leaves out is data you can afford to lose. An exclusion added to make a job faster protects nothing it skips.",
|
||||
"uncoveredExpected": "The policy declares these guests as requiring a backup and no enabled job selects them. Either add them to a job or change what the policy declares, so the two agree."
|
||||
},
|
||||
"rationale": "Enabled backup jobs on this node, the guests each one selects, and guest data excluded from them. Configured coverage does not prove that a usable backup exists. Whether an unselected guest was meant to be protected comes from the declared policy.",
|
||||
"summary": {
|
||||
"noJobs": "No backup job is defined on this node for the {total} guests it holds",
|
||||
@@ -5252,6 +5284,12 @@
|
||||
},
|
||||
"last_backup_age": {
|
||||
"title": "Age of stored backups",
|
||||
"explanation": "A job that exists is not the same as a copy that exists. This check reads the newest stored copy of each guest and how long ago it was written, which is how far back you would have to go if you had to restore today.",
|
||||
"nextStep": {
|
||||
"stale": "Find out why the job stopped producing copies for these guests: it may have been disabled, its schedule may never fire, or its runs may be failing. The run results check reports how each job last ended.",
|
||||
"noBackups": "No stored copy matches any guest on this node. If the jobs write to a destination this node cannot read, that is expected; otherwise they are producing nothing.",
|
||||
"attention": "Review the guests listed. Each one either has no recent copy or has one older than the age in use."
|
||||
},
|
||||
"rationale": "Age: time elapsed since the latest stored backup. Limit used: the reference age against which that backup is compared.",
|
||||
"summary": {
|
||||
"recent": "All {total} guest/destination checks meet the stated age policy",
|
||||
@@ -5263,6 +5301,12 @@
|
||||
},
|
||||
"retention_defined": {
|
||||
"title": "Backup retention",
|
||||
"explanation": "Retention decides how many copies are kept and for how long. Without it a destination fills until it stops accepting new copies, and a backup that cannot be written is the one you find out about on the day you need it.",
|
||||
"nextStep": {
|
||||
"missing": "Set a retention on these jobs, or on the storage they write to. Proxmox takes the job's setting first, then the storage's, then the node default.",
|
||||
"notDeclared": "These jobs keep every copy they make. That is a deliberate choice for some destinations, but confirm the destination has room to keep growing.",
|
||||
"onServer": "These jobs write to a backup server, which prunes them under its own rules. What it keeps cannot be read from this node, so check the retention there."
|
||||
},
|
||||
"rationale": "Retention as Proxmox resolves it: the job's setting, then the storage's, then the node default. Retention applied by a backup server is not readable from this node.",
|
||||
"summary": {
|
||||
"allDefined": "All {total} jobs resolve a retention setting",
|
||||
@@ -5274,6 +5318,11 @@
|
||||
},
|
||||
"verification_state": {
|
||||
"title": "Backup verification",
|
||||
"explanation": "Verification reads a stored copy back and confirms it is intact. It is the difference between a copy that exists and a copy that can be read — a distinction that only matters on the day you need it.",
|
||||
"nextStep": {
|
||||
"failed": "A copy that fails verification cannot be relied on. Check whether an earlier copy of the same guest verified, and look at the storage the failed copies live on.",
|
||||
"notVerified": "Nothing has confirmed that these copies can be read back. A backup server can verify on a schedule of its own, separately from the job that wrote them."
|
||||
},
|
||||
"rationale": "The verification result Proxmox Backup Server records for each guest's newest copy, and whether an earlier copy of the same guest verified. Verification reads a stored copy back; it is not a restore.",
|
||||
"summary": {
|
||||
"allVerified": "The newest copy of all {total} guests has been verified intact",
|
||||
@@ -5284,6 +5333,11 @@
|
||||
},
|
||||
"job_results": {
|
||||
"title": "Backup run results",
|
||||
"explanation": "How each job's most recent run ended, as this node recorded it. A job can be configured perfectly and still fail every night, and this is where that shows.",
|
||||
"nextStep": {
|
||||
"someFailed": "Read the error in the task log for these runs. A run that ends with an error produced no usable copy for the guests it covers.",
|
||||
"recovered": "These guests failed an earlier run and have succeeded since. The earlier error is still worth reading: a failure that resolved itself often returns."
|
||||
},
|
||||
"rationale": "How each guest's most recent recorded run ended, from the node's task log. Only the latest run is graded. The log is retained for a limited period.",
|
||||
"summary": {
|
||||
"allSucceeded": "All {total} recorded backup runs ended without error",
|
||||
@@ -5294,6 +5348,12 @@
|
||||
},
|
||||
"host_recovery": {
|
||||
"title": "Host recovery",
|
||||
"explanation": "Backing up the guests does not restore the node. This check looks at whether the node's own configuration — its storage definitions, its network, its users — is stored anywhere, which is what rebuilding the host itself depends on.",
|
||||
"nextStep": {
|
||||
"noHostBackup": "Nothing stores this node's own configuration. Rebuilding it would mean reconstructing the storage, network and user definitions by hand, from whatever notes exist.",
|
||||
"attention": "Review the host configuration records listed. Each has something worth looking at: a run that failed, a destination that is gone, or a copy older than the age in use.",
|
||||
"scheduledOnly": "A timer will produce host configuration backups, but none is stored yet. Until the first one runs, the node's own configuration is not protected."
|
||||
},
|
||||
"rationale": "Host backups as ProxMenux records them: each job it ran, when, whether it succeeded, the destination it wrote to and whether that copy is still there. A job writing to a backup server names no local path. Encryption keys are reported by count and recorded escrow mode only.",
|
||||
"summary": {
|
||||
"noHostBackup": "No host configuration backup is stored and no timer produces one",
|
||||
@@ -5680,9 +5740,12 @@
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryLabel": "Stops applying after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"reviewLabel": "Remind me to review",
|
||||
"reviewHelp": "The decision stays in force; it is only brought back to your attention.",
|
||||
"reviewNever": "No reminder",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
@@ -6085,6 +6148,10 @@
|
||||
"unchanged": "{count} checks reported the same result as before.",
|
||||
"new": "New",
|
||||
"newNote": "reported now and not before",
|
||||
"worse": "Worse",
|
||||
"worseNote": "still reported, and graver or reaching further than before",
|
||||
"better": "Better",
|
||||
"betterNote": "still reported, but less grave or reaching less far than before",
|
||||
"resolved": "Resolved",
|
||||
"resolvedNote": "no longer reported, and nobody accepted them",
|
||||
"accepted": "Accepted",
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"notApplicable": "n / A",
|
||||
"error": "Error",
|
||||
"system": "Sistema",
|
||||
"standby": "Apoyar",
|
||||
"standby": "En reposo",
|
||||
"standbyTitle": "La unidad está en espera: se omitió smartctl para mantenerla apagada",
|
||||
"filesystemCorruption": "Se detectó corrupción en el sistema de archivos",
|
||||
"ioErrorOne": "{count} Error de E/S en 5 minutos",
|
||||
@@ -808,6 +808,10 @@
|
||||
"saturday": "Sábado"
|
||||
}
|
||||
},
|
||||
"idle": "En reposo",
|
||||
"idleTitle": "No se lee mientras nada lo usa, para que pueda apagarse. Su temperatura vuelve a mostrarse en cuanto el disco se usa.",
|
||||
"diskExcluded": "Excluido",
|
||||
"diskExcludedTitle": "Excluido de las lecturas periódicas en Ajustes.",
|
||||
"savedSmartData": "datos SMART guardados"
|
||||
},
|
||||
"details": {
|
||||
@@ -1383,6 +1387,8 @@
|
||||
"imageUpToDate": "Actualizada",
|
||||
"imageInstalledTag": "etiqueta instalada",
|
||||
"imageDigestUnknown": "Digest no disponible",
|
||||
"imagePinned": "Anclada a un digest",
|
||||
"imagePinnedTitle": "Este contenedor ejecuta exactamente la imagen que indica su digest. Un tag más reciente no la cambia; para cambiarla hay que editar la referencia en su configuración.",
|
||||
"dockerPendingSummary": "Se detectaron {count} actualización(es) de imágenes Docker. Actualízalas con su flujo de Docker o Compose.",
|
||||
"postApplyChecking": "Comprobando resultado de la actualización…",
|
||||
"postApplyAllOk": "{count} paquete(s) aplicados correctamente — nada pendiente.",
|
||||
@@ -2198,6 +2204,18 @@
|
||||
"reset": "Restaurar por defecto",
|
||||
"hint": "Arrastra para reordenar · En táctil, mantén pulsado primero",
|
||||
"customActive": "Usando orden de navegación personalizado."
|
||||
},
|
||||
"diskExclusions": {
|
||||
"title": "Exclusiones de discos",
|
||||
"description": "Discos que nunca se leen de forma periódica, para que puedan apagarse con su propio temporizador.",
|
||||
"empty": "No se han encontrado discos físicos.",
|
||||
"disk": "Disco",
|
||||
"periodicReads": "Lecturas periódicas",
|
||||
"excluded": "Excluido",
|
||||
"notConnected": "No conectado",
|
||||
"saveFailed": "No se pudieron guardar las exclusiones de discos.",
|
||||
"help": "Un disco excluido no recibe lecturas periódicas de temperatura ni de SMART —ni siquiera la consulta de su estado de energía—, así que el Monitor no lo consulta en absoluto. Abrir sus detalles SMART sí lo lee.",
|
||||
"idleHelp": "Los discos mecánicos sin actividad ya no se consultan automáticamente, para no sacarlos del reposo, hasta que algo vuelve a usarlos."
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -2206,7 +2224,7 @@
|
||||
"password": "Contraseña",
|
||||
"usernamePlaceholder": "Ingrese su nombre de usuario",
|
||||
"passwordPlaceholder": "Introduce tu contraseña",
|
||||
"rememberMe": "Acuérdate de mí",
|
||||
"rememberMe": "Recordar",
|
||||
"missingCredentials": "Por favor ingrese nombre de usuario y contraseña",
|
||||
"missingTotp": "Por favor ingresa tu código 2FA",
|
||||
"invalidCredentials": "Nombre de usuario o contraseña incorrectos",
|
||||
@@ -3396,7 +3414,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"active": "activo",
|
||||
"standby": "apoyar",
|
||||
"standby": "en espera",
|
||||
"down": "abajo"
|
||||
},
|
||||
"empty": {
|
||||
@@ -5208,6 +5226,11 @@
|
||||
"noFindings": "Ningún hallazgo coincide con el filtro actual.",
|
||||
"affectedCount": "{count} afectados",
|
||||
"acceptedNotice": "{count} riesgo(s) aceptado(s) registrados en este host.",
|
||||
"noActionNeeded": "No hay nada que hacer. Esta comprobación ha encontrado lo que espera encontrar.",
|
||||
"couldNotEvaluate": "Esta comprobación no se ha podido evaluar, así que no afirma nada en ningún sentido. La evidencia recoge qué no pudo leer.",
|
||||
"reviewOn": "Revisar el: {when}",
|
||||
"reviewDue": "Toca revisarla — la decisión sigue en vigor",
|
||||
"reviewDueNotice": "{count} decisión(es) aceptada(s) esperan revisión.",
|
||||
"states": {
|
||||
"fail": "Fallo",
|
||||
"warn": "Aviso",
|
||||
@@ -5228,6 +5251,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Contexto",
|
||||
"whatItMeans": "Qué significa",
|
||||
"whatToDo": "Qué hacer",
|
||||
"evidence": "Evidencia",
|
||||
"affected": "Afectados",
|
||||
"acceptedRisk": "Riesgo aceptado",
|
||||
@@ -5240,6 +5265,13 @@
|
||||
"backup": {
|
||||
"guest_coverage": {
|
||||
"title": "Cobertura de backups",
|
||||
"explanation": "Un backup solo protege lo que un trabajo selecciona de verdad. Esta comprobación cruza los invitados de este nodo con los trabajos que se ejecutan aquí, de modo que un invitado al que nadie respalda aparece como tal en lugar de darse por cubierto por algo.",
|
||||
"nextStep": {
|
||||
"noJobs": "Crea un trabajo de backup en este nodo y selecciona los invitados que guarden datos que no querrías rehacer a mano. Un trabajo que existe pero está deshabilitado no selecciona nada.",
|
||||
"uncovered": "Decide, para cada uno de estos invitados, si guarda algo que merezca conservarse. Añade a un trabajo los que sí; para los que no, decláralo en la política y dejarán de contarse aquí.",
|
||||
"excludedData": "Abre la lista de exclusiones de cada trabajo y confirma que lo que deja fuera son datos que puedes permitirte perder. Una exclusión añadida para acelerar un trabajo no protege nada de lo que omite.",
|
||||
"uncoveredExpected": "La política declara que estos invitados requieren backup y ningún trabajo activo los selecciona. Añádelos a un trabajo o cambia lo que declara la política, para que ambas cosas coincidan."
|
||||
},
|
||||
"rationale": "Trabajos de backup habilitados en este nodo, los invitados que selecciona cada uno y los datos del invitado excluidos de ellos. La cobertura configurada no demuestra que exista una copia utilizable. Si un invitado no seleccionado debía protegerse lo indica la política declarada.",
|
||||
"summary": {
|
||||
"noJobs": "No hay ningún trabajo de backup definido en este nodo para los {total} invitados que alberga",
|
||||
@@ -5252,6 +5284,12 @@
|
||||
},
|
||||
"last_backup_age": {
|
||||
"title": "Antigüedad de las copias almacenadas",
|
||||
"explanation": "Que exista un trabajo no es lo mismo que exista una copia. Esta comprobación lee la copia más reciente de cada invitado y cuánto hace que se escribió, que es hasta dónde tendrías que retroceder si hoy hubiera que restaurar.",
|
||||
"nextStep": {
|
||||
"stale": "Averigua por qué el trabajo dejó de producir copias de estos invitados: puede estar deshabilitado, su programación puede no dispararse nunca, o sus ejecuciones pueden estar fallando. La comprobación de resultados indica cómo terminó cada trabajo.",
|
||||
"noBackups": "Ninguna copia almacenada corresponde a un invitado de este nodo. Si los trabajos escriben en un destino que este nodo no puede leer, es lo esperable; si no, no están produciendo nada.",
|
||||
"attention": "Revisa los invitados de la lista. Cada uno no tiene copia reciente o la que tiene supera la antigüedad en uso."
|
||||
},
|
||||
"rationale": "Antigüedad: tiempo transcurrido desde la última copia almacenada. Límite utilizado: antigüedad de referencia con la que se compara esa copia.",
|
||||
"summary": {
|
||||
"recent": "Las {total} comprobaciones de máquina/destino cumplen el criterio de antigüedad indicado",
|
||||
@@ -5263,6 +5301,12 @@
|
||||
},
|
||||
"retention_defined": {
|
||||
"title": "Retención de backups",
|
||||
"explanation": "La retención decide cuántas copias se conservan y durante cuánto tiempo. Sin ella un destino se llena hasta dejar de admitir copias nuevas, y un backup que no se puede escribir es justo del que te enteras el día que lo necesitas.",
|
||||
"nextStep": {
|
||||
"missing": "Define una retención en estos trabajos, o en el almacenamiento donde escriben. Proxmox toma primero el ajuste del trabajo, después el del almacenamiento y por último el del nodo.",
|
||||
"notDeclared": "Estos trabajos conservan todas las copias que hacen. En algunos destinos es una decisión deliberada, pero confirma que el destino tiene sitio para seguir creciendo.",
|
||||
"onServer": "Estos trabajos escriben en un servidor de backup, que las poda con sus propias reglas. Lo que conserva no se puede leer desde este nodo, así que comprueba la retención allí."
|
||||
},
|
||||
"rationale": "La retención tal como la resuelve Proxmox: el ajuste del trabajo, después el del almacenamiento y después el valor por defecto del nodo. La retención que aplica un servidor de backup no se puede leer desde este nodo.",
|
||||
"summary": {
|
||||
"allDefined": "Los {total} trabajos resuelven un ajuste de retención",
|
||||
@@ -5274,6 +5318,11 @@
|
||||
},
|
||||
"verification_state": {
|
||||
"title": "Verificación de las copias",
|
||||
"explanation": "La verificación vuelve a leer una copia almacenada y confirma que está íntegra. Es la diferencia entre una copia que existe y una copia que se puede leer, una distinción que solo importa el día que la necesitas.",
|
||||
"nextStep": {
|
||||
"failed": "No se puede confiar en una copia que no supera la verificación. Comprueba si una copia anterior del mismo invitado sí la superó, y revisa el almacenamiento donde residen las que han fallado.",
|
||||
"notVerified": "Nada ha confirmado que estas copias se puedan volver a leer. Un servidor de backup puede verificarlas con una programación propia, independiente del trabajo que las escribió."
|
||||
},
|
||||
"rationale": "El resultado de verificación que Proxmox Backup Server registra para la copia más reciente de cada invitado, y si una copia anterior del mismo invitado se verificó. La verificación lee una copia almacenada; no es una restauración.",
|
||||
"summary": {
|
||||
"allVerified": "La copia más reciente de los {total} invitados se ha verificado íntegra",
|
||||
@@ -5284,6 +5333,11 @@
|
||||
},
|
||||
"job_results": {
|
||||
"title": "Resultado de las ejecuciones de backup",
|
||||
"explanation": "Cómo terminó la última ejecución de cada trabajo, según lo registró este nodo. Un trabajo puede estar perfectamente configurado y aun así fallar todas las noches, y es aquí donde eso se ve.",
|
||||
"nextStep": {
|
||||
"someFailed": "Lee el error en el registro de tareas de estas ejecuciones. Una ejecución que termina con error no ha producido ninguna copia utilizable de los invitados que cubre.",
|
||||
"recovered": "Estos invitados fallaron en una ejecución anterior y desde entonces han terminado bien. El error anterior merece leerse igualmente: un fallo que se arregló solo suele volver."
|
||||
},
|
||||
"rationale": "Cómo terminó la ejecución más reciente de cada invitado, según el registro de tareas del nodo. Solo se gradúa la última. El registro se conserva un tiempo limitado.",
|
||||
"summary": {
|
||||
"allSucceeded": "Las {total} ejecuciones de backup registradas terminaron sin error",
|
||||
@@ -5294,6 +5348,12 @@
|
||||
},
|
||||
"host_recovery": {
|
||||
"title": "Recuperación del host",
|
||||
"explanation": "Respaldar los invitados no restaura el nodo. Esta comprobación mira si la configuración del propio nodo —sus definiciones de almacenamiento, su red, sus usuarios— está guardada en algún sitio, que es de lo que depende poder reconstruir el host.",
|
||||
"nextStep": {
|
||||
"noHostBackup": "Nada guarda la configuración de este nodo. Reconstruirlo supondría rehacer a mano las definiciones de almacenamiento, red y usuarios, a partir de las notas que haya.",
|
||||
"attention": "Revisa los registros de configuración del host de la lista. Cada uno tiene algo que mirar: una ejecución que falló, un destino que ya no está, o una copia más antigua que el límite en uso.",
|
||||
"scheduledOnly": "Un temporizador producirá backups de la configuración del host, pero todavía no hay ninguno guardado. Hasta que se ejecute el primero, la configuración del nodo no está protegida."
|
||||
},
|
||||
"rationale": "Backups del host tal como los registra ProxMenux: cada trabajo que ejecutó, cuándo, si terminó bien, el destino donde escribió y si esa copia sigue ahí. Un trabajo que escribe en un servidor de backup no nombra ninguna ruta local. Las claves de cifrado se exponen solo por recuento y modo de custodia registrado.",
|
||||
"summary": {
|
||||
"noHostBackup": "No hay ningún backup de la configuración del host almacenado ni temporizador que lo genere",
|
||||
@@ -5680,9 +5740,12 @@
|
||||
"reasonLabel": "Motivo",
|
||||
"reasonHelp": "Obligatorio. Queda registrado junto al autor y la fecha.",
|
||||
"reasonPlaceholder": "p. ej. Contenedores de laboratorio, sin cobertura a propósito",
|
||||
"expiryLabel": "Revisar dentro de",
|
||||
"expiryLabel": "Deja de aplicarse tras",
|
||||
"expiryHelp": "Al cumplirse el plazo el hallazgo vuelve a estado activo.",
|
||||
"expiryNever": "No caduca",
|
||||
"reviewLabel": "Recordarme revisarla",
|
||||
"reviewHelp": "La decisión sigue en vigor; solo vuelve a tu atención.",
|
||||
"reviewNever": "Sin recordatorio",
|
||||
"expiry90": "90 días",
|
||||
"expiry180": "180 días",
|
||||
"expiry365": "1 año",
|
||||
@@ -6085,6 +6148,10 @@
|
||||
"unchanged": "{count} comprobaciones dieron el mismo resultado que antes.",
|
||||
"new": "Nuevos",
|
||||
"newNote": "se informan ahora y antes no",
|
||||
"worse": "Empeoraron",
|
||||
"worseNote": "se siguen informando, y son más graves o alcanzan a más que antes",
|
||||
"better": "Mejoraron",
|
||||
"betterNote": "se siguen informando, pero son menos graves o alcanzan a menos que antes",
|
||||
"resolved": "Resueltos",
|
||||
"resolvedNote": "ya no se informan, y nadie los aceptó",
|
||||
"accepted": "Aceptados",
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
"notApplicable": "n / A",
|
||||
"error": "Erreur",
|
||||
"system": "Système",
|
||||
"standby": "Attendre",
|
||||
"standby": "Veille",
|
||||
"standbyTitle": "Le lecteur est en veille - smartctl a été ignoré pour le maintenir en veille",
|
||||
"filesystemCorruption": "Corruption du système de fichiers détectée",
|
||||
"ioErrorOne": "{count} Erreur d'E/S dans 5 min",
|
||||
@@ -2206,7 +2206,7 @@
|
||||
"password": "Mot de passe",
|
||||
"usernamePlaceholder": "Entrez votre nom d'utilisateur",
|
||||
"passwordPlaceholder": "Entrez votre mot de passe",
|
||||
"rememberMe": "Souviens-toi de moi",
|
||||
"rememberMe": "Mémoriser",
|
||||
"missingCredentials": "Veuillez entrer votre nom d'utilisateur et votre mot de passe",
|
||||
"missingTotp": "Veuillez entrer votre code 2FA",
|
||||
"invalidCredentials": "Nom d'utilisateur ou mot de passe incorrect",
|
||||
@@ -3396,7 +3396,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"active": "actif",
|
||||
"standby": "attendre",
|
||||
"standby": "secours",
|
||||
"down": "vers le bas"
|
||||
},
|
||||
"empty": {
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"reviewOn": "À revoir le : {when}",
|
||||
"reviewDue": "À revoir — la décision reste en vigueur",
|
||||
"reviewDueNotice": "{count} décision(s) acceptée(s) sont à revoir.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "Ce que cela signifie",
|
||||
"whatToDo": "Que faire",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5674,20 +5679,23 @@
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
"action": "Accepter le risque",
|
||||
"revoke": "Remettre en actif",
|
||||
"title": "Accepter ce risque",
|
||||
"reasonLabel": "Motif",
|
||||
"reasonHelp": "Obligatoire. Il est enregistré avec son auteur et la date.",
|
||||
"reasonPlaceholder": "ex. Conteneurs de laboratoire, non couverts volontairement",
|
||||
"expiryLabel": "Cesse de s'appliquer après",
|
||||
"expiryHelp": "À la fin de la période, le constat redevient actif.",
|
||||
"expiryNever": "N'expire pas",
|
||||
"reviewLabel": "Me rappeler de la revoir",
|
||||
"reviewHelp": "La décision reste en vigueur ; elle revient seulement à votre attention.",
|
||||
"reviewNever": "Pas de rappel",
|
||||
"expiry90": "90 jours",
|
||||
"expiry180": "180 jours",
|
||||
"expiry365": "1 an",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Accepter le risque"
|
||||
},
|
||||
"incomplete": "Preuves incomplètes",
|
||||
"progress": "{completed} sur {total} vérifiés",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} contrôles ont donné le même résultat qu'avant.",
|
||||
"new": "Nouveaux",
|
||||
"newNote": "signalés maintenant et pas avant",
|
||||
"worse": "Aggravés",
|
||||
"worseNote": "toujours signalés, et plus graves ou plus étendus qu'avant",
|
||||
"better": "Améliorés",
|
||||
"betterNote": "toujours signalés, mais moins graves ou moins étendus qu'avant",
|
||||
"resolved": "Résolus",
|
||||
"resolvedNote": "plus signalés, et personne ne les a acceptés",
|
||||
"accepted": "Acceptés",
|
||||
|
||||
@@ -2206,7 +2206,7 @@
|
||||
"password": "Password",
|
||||
"usernamePlaceholder": "Inserisci il tuo nome utente",
|
||||
"passwordPlaceholder": "Inserisci la tua password",
|
||||
"rememberMe": "Ricordati di me",
|
||||
"rememberMe": "Ricorda",
|
||||
"missingCredentials": "Inserisci nome utente e password",
|
||||
"missingTotp": "Inserisci il tuo codice 2FA",
|
||||
"invalidCredentials": "Nome utente o password errati",
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"reviewOn": "Da rivedere il: {when}",
|
||||
"reviewDue": "Da rivedere — la decisione resta valida",
|
||||
"reviewDueNotice": "{count} decisione/i accettata/e da rivedere.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "Che cosa significa",
|
||||
"whatToDo": "Cosa fare",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5674,20 +5679,23 @@
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
"action": "Accetta il rischio",
|
||||
"revoke": "Riporta tra gli attivi",
|
||||
"title": "Accetta questo rischio",
|
||||
"reasonLabel": "Motivo",
|
||||
"reasonHelp": "Obbligatorio. Viene registrato insieme all'autore e alla data.",
|
||||
"reasonPlaceholder": "es. Container di laboratorio, non coperti di proposito",
|
||||
"expiryLabel": "Smette di applicarsi dopo",
|
||||
"expiryHelp": "Alla fine del periodo il rilievo torna attivo.",
|
||||
"expiryNever": "Non scade",
|
||||
"reviewLabel": "Ricordami di rivederla",
|
||||
"reviewHelp": "La decisione resta valida; torna solo alla tua attenzione.",
|
||||
"reviewNever": "Nessun promemoria",
|
||||
"expiry90": "90 giorni",
|
||||
"expiry180": "180 giorni",
|
||||
"expiry365": "1 anno",
|
||||
"cancel": "Annulla",
|
||||
"confirm": "Accetta il rischio"
|
||||
},
|
||||
"incomplete": "Evidenze incomplete",
|
||||
"progress": "Verificati {completed} di {total}",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} controlli hanno dato lo stesso risultato di prima.",
|
||||
"new": "Nuovi",
|
||||
"newNote": "segnalati ora e prima no",
|
||||
"worse": "Peggiorati",
|
||||
"worseNote": "ancora segnalati, e più gravi o più estesi di prima",
|
||||
"better": "Migliorati",
|
||||
"betterNote": "ancora segnalati, ma meno gravi o meno estesi di prima",
|
||||
"resolved": "Risolti",
|
||||
"resolvedNote": "non più segnalati, e nessuno li ha accettati",
|
||||
"accepted": "Accettati",
|
||||
|
||||
@@ -2206,7 +2206,7 @@
|
||||
"password": "Senha",
|
||||
"usernamePlaceholder": "Digite seu nome de usuário",
|
||||
"passwordPlaceholder": "Digite sua senha",
|
||||
"rememberMe": "Lembre de mim",
|
||||
"rememberMe": "Lembrar",
|
||||
"missingCredentials": "Por favor insira nome de usuário e senha",
|
||||
"missingTotp": "Por favor, insira seu código 2FA",
|
||||
"invalidCredentials": "Nome de usuário ou senha incorretos",
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"reviewOn": "Rever em: {when}",
|
||||
"reviewDue": "A rever — a decisão continua em vigor",
|
||||
"reviewDueNotice": "{count} decisão(ões) aceite(s) aguardam revisão.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "O que isto significa",
|
||||
"whatToDo": "O que fazer",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5674,20 +5679,23 @@
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
"action": "Aceitar risco",
|
||||
"revoke": "Voltar a ativo",
|
||||
"title": "Aceitar este risco",
|
||||
"reasonLabel": "Motivo",
|
||||
"reasonHelp": "Obrigatório. É registado junto com o autor e a data.",
|
||||
"reasonPlaceholder": "ex. Contentores de laboratório, não cobertos de propósito",
|
||||
"expiryLabel": "Deixa de aplicar-se após",
|
||||
"expiryHelp": "No fim do período o achado volta a ficar ativo.",
|
||||
"expiryNever": "Não expira",
|
||||
"reviewLabel": "Lembrar-me de rever",
|
||||
"reviewHelp": "A decisão continua em vigor; apenas volta à sua atenção.",
|
||||
"reviewNever": "Sem lembrete",
|
||||
"expiry90": "90 dias",
|
||||
"expiry180": "180 dias",
|
||||
"expiry365": "1 ano",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Aceitar risco"
|
||||
},
|
||||
"incomplete": "Evidência incompleta",
|
||||
"progress": "Verificadas {completed} de {total}",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} verificações deram o mesmo resultado que antes.",
|
||||
"new": "Novos",
|
||||
"newNote": "reportados agora e antes não",
|
||||
"worse": "Pioraram",
|
||||
"worseNote": "continuam a ser reportados, e são mais graves ou mais abrangentes do que antes",
|
||||
"better": "Melhoraram",
|
||||
"betterNote": "continuam a ser reportados, mas são menos graves ou menos abrangentes do que antes",
|
||||
"resolved": "Resolvidos",
|
||||
"resolvedNote": "já não são reportados, e ninguém os aceitou",
|
||||
"accepted": "Aceites",
|
||||
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "Aktuálnemu filtru nezodpovedajú žiadne zistenia.",
|
||||
"affectedCount": "ovplyvnené: {count}",
|
||||
"acceptedNotice": "Na tomto serveri je zaznamenaných {count} prijatých rizík.",
|
||||
"reviewOn": "Skontrolovať dňa: {when}",
|
||||
"reviewDue": "Čaká na kontrolu — rozhodnutie stále platí",
|
||||
"reviewDueNotice": "{count} prijaté rozhodnutie/a čaká na kontrolu.",
|
||||
"states": {
|
||||
"fail": "Zlyhalo",
|
||||
"warn": "Upozornenie",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Súvislosti",
|
||||
"whatItMeans": "Čo to znamená",
|
||||
"whatToDo": "Čo urobiť",
|
||||
"evidence": "Podklady",
|
||||
"affected": "Ovplyvnené",
|
||||
"acceptedRisk": "Prijaté riziko",
|
||||
@@ -5683,6 +5688,9 @@
|
||||
"expiryLabel": "Znova preveriť po",
|
||||
"expiryHelp": "Po uplynutí obdobia bude zistenie opäť aktívne.",
|
||||
"expiryNever": "Bez vypršania",
|
||||
"reviewLabel": "Pripomenúť kontrolu",
|
||||
"reviewHelp": "Rozhodnutie stále platí; iba sa znova dostane do pozornosti.",
|
||||
"reviewNever": "Bez pripomienky",
|
||||
"expiry90": "90 dní",
|
||||
"expiry180": "180 dní",
|
||||
"expiry365": "1 rok",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} kontrol dalo rovnaký výsledok ako predtým.",
|
||||
"new": "Nové",
|
||||
"newNote": "hlásené teraz a predtým nie",
|
||||
"worse": "Zhoršené",
|
||||
"worseNote": "stále sa hlásia a sú závažnejšie alebo majú väčší rozsah než predtým",
|
||||
"better": "Zlepšené",
|
||||
"betterNote": "stále sa hlásia, ale sú menej závažné alebo majú menší rozsah než predtým",
|
||||
"resolved": "Vyriešené",
|
||||
"resolvedNote": "už sa nehlásia a nikto ich neprijal",
|
||||
"accepted": "Prijaté",
|
||||
|
||||
@@ -5208,6 +5208,9 @@
|
||||
"noFindings": "No findings match the current filter.",
|
||||
"affectedCount": "{count} affected",
|
||||
"acceptedNotice": "{count} accepted risk(s) recorded on this host.",
|
||||
"reviewOn": "Granska den: {when}",
|
||||
"reviewDue": "Dags att granska — beslutet gäller fortfarande",
|
||||
"reviewDueNotice": "{count} accepterade beslut väntar på granskning.",
|
||||
"states": {
|
||||
"fail": "Failed",
|
||||
"warn": "Warning",
|
||||
@@ -5228,6 +5231,8 @@
|
||||
},
|
||||
"detail": {
|
||||
"why": "Context",
|
||||
"whatItMeans": "Vad detta betyder",
|
||||
"whatToDo": "Vad du gör",
|
||||
"evidence": "Evidence",
|
||||
"affected": "Affected",
|
||||
"acceptedRisk": "Accepted risk",
|
||||
@@ -5674,20 +5679,23 @@
|
||||
},
|
||||
"summaryFallback": "The check could not be evaluated",
|
||||
"acceptRisk": {
|
||||
"action": "Accept risk",
|
||||
"revoke": "Return to active",
|
||||
"title": "Accept this risk",
|
||||
"reasonLabel": "Reason",
|
||||
"reasonHelp": "Required. It is recorded together with the author and the date.",
|
||||
"reasonPlaceholder": "e.g. Lab containers, not covered on purpose",
|
||||
"expiryLabel": "Review after",
|
||||
"expiryHelp": "When the period ends the finding becomes active again.",
|
||||
"expiryNever": "Does not expire",
|
||||
"expiry90": "90 days",
|
||||
"expiry180": "180 days",
|
||||
"expiry365": "1 year",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Accept risk"
|
||||
"action": "Acceptera risken",
|
||||
"revoke": "Återför till aktiva",
|
||||
"title": "Acceptera den här risken",
|
||||
"reasonLabel": "Orsak",
|
||||
"reasonHelp": "Obligatorisk. Den sparas tillsammans med upphovsperson och datum.",
|
||||
"reasonPlaceholder": "t.ex. Labbcontainrar, medvetet inte täckta",
|
||||
"expiryLabel": "Slutar gälla efter",
|
||||
"expiryHelp": "När perioden tar slut blir fyndet aktivt igen.",
|
||||
"expiryNever": "Upphör inte",
|
||||
"reviewLabel": "Påminn mig om att granska",
|
||||
"reviewHelp": "Beslutet gäller fortfarande; det lyfts bara fram igen.",
|
||||
"reviewNever": "Ingen påminnelse",
|
||||
"expiry90": "90 dagar",
|
||||
"expiry180": "180 dagar",
|
||||
"expiry365": "1 år",
|
||||
"cancel": "Avbryt",
|
||||
"confirm": "Acceptera risken"
|
||||
},
|
||||
"incomplete": "Ofullständiga underlag",
|
||||
"progress": "Kontrollerat {completed} av {total}",
|
||||
@@ -6085,6 +6093,10 @@
|
||||
"unchanged": "{count} kontroller gav samma resultat som förut.",
|
||||
"new": "Nya",
|
||||
"newNote": "rapporteras nu men inte förut",
|
||||
"worse": "Försämrade",
|
||||
"worseNote": "rapporteras fortfarande, och är allvarligare eller når längre än förut",
|
||||
"better": "Förbättrade",
|
||||
"betterNote": "rapporteras fortfarande, men är mindre allvarliga eller når kortare än förut",
|
||||
"resolved": "Åtgärdade",
|
||||
"resolvedNote": "rapporteras inte längre, och ingen accepterade dem",
|
||||
"accepted": "Accepterade",
|
||||
|
||||
@@ -691,6 +691,37 @@ def run_assessment(profile: str = "full",
|
||||
return run_id
|
||||
|
||||
|
||||
def _scope(finding: dict) -> int:
|
||||
"""How many objects a finding covers. A check that named three guests
|
||||
and now names nine describes a larger problem, even at the same
|
||||
gravity."""
|
||||
return len(finding.get("affected") or [])
|
||||
|
||||
|
||||
def _movement(previous: dict, current: dict) -> int:
|
||||
"""Whether a finding present in both runs got worse (1), better (-1) or
|
||||
held (0). Gravity decides; scope only breaks a tie, because a finding
|
||||
takes the gravity of its gravest object and dropping from critical to
|
||||
warning is progress however many objects it now names."""
|
||||
before = audit_store.CLASS_ORDER.get(previous["classification"])
|
||||
after = audit_store.CLASS_ORDER.get(current["classification"])
|
||||
if before is not None and after is not None and before != after:
|
||||
return 1 if after < before else -1
|
||||
before_scope, after_scope = _scope(previous), _scope(current)
|
||||
if after_scope != before_scope:
|
||||
return 1 if after_scope > before_scope else -1
|
||||
return 0
|
||||
|
||||
|
||||
def _against(current: dict, previous: dict) -> dict:
|
||||
"""A finding carrying where it came from, so the reader is told what
|
||||
moved instead of only what it is now."""
|
||||
return {**current,
|
||||
"previous_classification": previous["classification"],
|
||||
"previous_affected": _scope(previous),
|
||||
"affected_count": _scope(current)}
|
||||
|
||||
|
||||
def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
"""Classify how findings moved between two runs.
|
||||
|
||||
@@ -700,6 +731,12 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
that merges them would tell its reader the problem went away when the
|
||||
decision was to live with it.
|
||||
|
||||
A finding that was already failing and still fails is never new. It
|
||||
either got worse, got better without being resolved, or held: reporting
|
||||
a warning that became critical as new hides that it was already there,
|
||||
and reporting a critical that dropped to a warning as new tells the
|
||||
reader their work created a problem.
|
||||
|
||||
``unchanged`` is kept so a report can state that the rest of the
|
||||
surface held steady rather than leaving it unaccounted for.
|
||||
"""
|
||||
@@ -708,6 +745,7 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
other = {f["check_id"]: f for f in audit_store.get_findings(other_run)}
|
||||
|
||||
new, resolved, accepted, unchanged, unverified = [], [], [], [], []
|
||||
worse, better = [], []
|
||||
for check_id, current in other.items():
|
||||
previous = base.get(check_id)
|
||||
was = previous["classification"] in problems if previous else False
|
||||
@@ -718,8 +756,16 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
unverified.append(current)
|
||||
elif now and current.get("decision") == audit_store.DECISION_ACCEPTED:
|
||||
accepted.append(current)
|
||||
elif now and (not was or previous["classification"] != current["classification"]):
|
||||
elif now and not was:
|
||||
new.append(current)
|
||||
elif now and was:
|
||||
moved = _movement(previous, current)
|
||||
if moved > 0:
|
||||
worse.append(_against(current, previous))
|
||||
elif moved < 0:
|
||||
better.append(_against(current, previous))
|
||||
else:
|
||||
unchanged.append(current)
|
||||
elif was and not now:
|
||||
if current.get("decision") == audit_store.DECISION_ACCEPTED:
|
||||
accepted.append(current)
|
||||
@@ -738,6 +784,8 @@ def compare_runs(base_run: str, other_run: str) -> dict[str, list[dict]]:
|
||||
|
||||
return {
|
||||
"new": new,
|
||||
"worse": worse,
|
||||
"better": better,
|
||||
"resolved": resolved,
|
||||
"accepted": accepted,
|
||||
"unchanged": unchanged,
|
||||
|
||||
@@ -229,12 +229,19 @@ def init_db() -> None:
|
||||
-- Accepted risks outlive the run that surfaced them, so they
|
||||
-- are keyed by check rather than by finding. expires_at NULL
|
||||
-- means the acceptance does not lapse on its own.
|
||||
--
|
||||
-- review_at is deliberately not expires_at. Expiry withdraws
|
||||
-- the decision and the finding becomes a problem again on its
|
||||
-- own; a review date leaves the decision standing and only
|
||||
-- brings it back to the reader, so "remind me in a year" no
|
||||
-- longer has to be spelled as "stop accepting this in a year".
|
||||
CREATE TABLE IF NOT EXISTS audit_exceptions (
|
||||
check_id TEXT PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
accepted_by TEXT NOT NULL,
|
||||
accepted_at INTEGER NOT NULL,
|
||||
expires_at INTEGER
|
||||
expires_at INTEGER,
|
||||
review_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_findings_run
|
||||
@@ -250,7 +257,7 @@ def init_db() -> None:
|
||||
"audit_findings": {"raw_state": "TEXT", "exception_snapshot": "TEXT",
|
||||
"scope": "TEXT", "details": "TEXT", "classification": "TEXT",
|
||||
"raw_classification": "TEXT", "decision": "TEXT"},
|
||||
"audit_exceptions": {"scope": "TEXT"},
|
||||
"audit_exceptions": {"scope": "TEXT", "review_at": "INTEGER"},
|
||||
}.items():
|
||||
present = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
for name, kind in columns.items():
|
||||
@@ -503,12 +510,17 @@ def check_history(check_id: str, limit: int = 30) -> list[dict[str, Any]]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def accept_risk(check_id: str, reason: str, accepted_by: str,
|
||||
expires_at: Optional[int] = None, *, scope: str) -> None:
|
||||
expires_at: Optional[int] = None, *, scope: str,
|
||||
review_at: Optional[int] = None) -> None:
|
||||
"""Record a deliberate decision to leave a finding unresolved.
|
||||
|
||||
A reason is mandatory: an acceptance without one is indistinguishable
|
||||
from having silenced the check, which is what this register exists to
|
||||
prevent.
|
||||
|
||||
``review_at`` asks to be reminded of the decision on a date without
|
||||
withdrawing it. It is independent of ``expires_at``: an acceptance
|
||||
can stand indefinitely and still come back for review.
|
||||
"""
|
||||
if not (reason or "").strip():
|
||||
raise ValueError("an accepted risk requires a reason")
|
||||
@@ -516,18 +528,21 @@ def accept_risk(check_id: str, reason: str, accepted_by: str,
|
||||
raise ValueError("an accepted risk requires an assessed scope")
|
||||
if expires_at is not None and expires_at <= time.time():
|
||||
raise ValueError("expiry must be in the future")
|
||||
if review_at is not None and review_at <= time.time():
|
||||
raise ValueError("the review date must be in the future")
|
||||
init_db()
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
decision = dict(check_id=check_id, reason=reason.strip(), accepted_by=accepted_by,
|
||||
accepted_at=int(time.time()), expires_at=expires_at, scope=scope)
|
||||
accepted_at=int(time.time()), expires_at=expires_at, scope=scope,
|
||||
review_at=review_at)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO audit_exceptions "
|
||||
"(check_id, reason, accepted_by, accepted_at, expires_at, scope) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
"(check_id, reason, accepted_by, accepted_at, expires_at, scope, review_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(check_id, reason.strip(), accepted_by, int(time.time()),
|
||||
expires_at, scope),
|
||||
expires_at, scope, review_at),
|
||||
)
|
||||
conn.execute("INSERT INTO audit_exception_events (check_id, action, happened_at, decision) "
|
||||
"VALUES (?, 'accepted', ?, ?)",
|
||||
@@ -643,6 +658,13 @@ def all_exceptions() -> list[dict[str, Any]]:
|
||||
item["lapsed"] = bool(
|
||||
item["expires_at"] is not None and item["expires_at"] <= now
|
||||
)
|
||||
# Due for review, and still in force: the decision holds, it is
|
||||
# only asking to be looked at again.
|
||||
item["review_due"] = bool(
|
||||
item.get("review_at") is not None
|
||||
and item["review_at"] <= now
|
||||
and not item["lapsed"]
|
||||
)
|
||||
out.append(item)
|
||||
return out
|
||||
finally:
|
||||
|
||||
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
|
||||
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
|
||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
||||
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
|
||||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
|
||||
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
||||
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Physical disks and a stable identity for each, read without touching them.
|
||||
|
||||
Everything here comes from udev's database and /sys through lsblk, which
|
||||
reads these columns without opening the block device. A drive that is
|
||||
asleep, or idle and about to be, is not disturbed by being listed.
|
||||
|
||||
The identity matters because a kernel name is not one: a USB drive can be
|
||||
sda on one boot and sdb on the next, so anything the user attaches to a
|
||||
disk has to follow the disk, not the letter it happened to get.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
_LSBLK_TIMEOUT = 5
|
||||
_FIELD_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||
_SKIP_PREFIXES = ("loop", "zd", "nbd", "ram", "sr")
|
||||
|
||||
|
||||
def disk_key(serial: str, wwn: str, name: str) -> str:
|
||||
"""Stable identity: the serial where udev knows one, then the WWN,
|
||||
and only as a last resort the kernel name."""
|
||||
serial = (serial or "").strip()
|
||||
wwn = (wwn or "").strip()
|
||||
if serial:
|
||||
return f"serial:{serial}"
|
||||
if wwn:
|
||||
return f"wwn:{wwn}"
|
||||
return f"name:{name}"
|
||||
|
||||
|
||||
def list_physical_disks() -> List[Dict[str, Any]]:
|
||||
"""Every physical disk with its identity and the facts the interface
|
||||
shows about it. Returns an empty list if lsblk cannot be read."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["lsblk", "-d", "-n", "-P", "-b", "-o",
|
||||
"NAME,TYPE,MODEL,SERIAL,WWN,SIZE,TRAN,ROTA"],
|
||||
capture_output=True, text=True, timeout=_LSBLK_TIMEOUT,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return []
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
|
||||
disks: List[Dict[str, Any]] = []
|
||||
for line in proc.stdout.splitlines():
|
||||
fields = dict(_FIELD_RE.findall(line))
|
||||
name = fields.get("NAME", "")
|
||||
if fields.get("TYPE") != "disk" or not name or name.startswith(_SKIP_PREFIXES):
|
||||
continue
|
||||
serial = fields.get("SERIAL", "").strip()
|
||||
wwn = fields.get("WWN", "").strip()
|
||||
try:
|
||||
size = int(fields.get("SIZE") or 0)
|
||||
except ValueError:
|
||||
size = 0
|
||||
disks.append({
|
||||
"name": name,
|
||||
"key": disk_key(serial, wwn, name),
|
||||
"model": fields.get("MODEL", "").strip(),
|
||||
"serial": serial,
|
||||
"size_bytes": size,
|
||||
"transport": fields.get("TRAN", "").strip(),
|
||||
"rotational": fields.get("ROTA", "").strip() == "1",
|
||||
})
|
||||
return disks
|
||||
|
||||
|
||||
def names_for_keys(keys) -> set:
|
||||
"""Current kernel names of the disks whose identity is in ``keys``."""
|
||||
if not keys:
|
||||
return set()
|
||||
return {d["name"] for d in list_physical_disks() if d["key"] in keys}
|
||||
@@ -410,8 +410,133 @@ def _extract_temperature(data: dict[str, Any]) -> Optional[float]:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ── Leaving idle and excluded disks alone ──────────────────────────
|
||||
#
|
||||
# `-n standby` keeps a periodic reader from waking a disk that is asleep.
|
||||
# It does not let an awake one fall asleep: a drive's spin-down timer
|
||||
# counts time without commands, and a read every minute resets it, so an
|
||||
# unused drive whose timer is longer than that never spins down — and a
|
||||
# drive that parks its heads when idle loads them again on every read.
|
||||
#
|
||||
# So a rotational disk with no I/O since it was last looked at is not read
|
||||
# at all, not even asked for its power mode. The counters come from
|
||||
# /proc/diskstats, which costs no disk access, and a SMART query does not
|
||||
# move them — passthrough commands are not accounted as reads or writes —
|
||||
# so any change means something else used the disk. A disk in use is
|
||||
# already awake, and reading it then costs nothing. Solid-state disks have
|
||||
# no spindle and no heads, and keep their reading.
|
||||
#
|
||||
# A disk seen for the first time is read once, so a Monitor that has just
|
||||
# started still has values to show; after that it is left alone for as
|
||||
# long as nothing uses it.
|
||||
|
||||
READ = "read"
|
||||
IDLE = "idle"
|
||||
EXCLUDED = "excluded"
|
||||
|
||||
_last_io: dict[str, tuple[int, int]] = {}
|
||||
_idle_state: dict[str, float] = {}
|
||||
_IDLE_TTL = 600 # same horizon as the standby badge
|
||||
|
||||
|
||||
def _read_diskstats() -> dict[str, tuple[int, int]]:
|
||||
"""Reads and writes completed per device, from /proc/diskstats."""
|
||||
out: dict[str, tuple[int, int]] = {}
|
||||
try:
|
||||
with open("/proc/diskstats") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 8:
|
||||
continue
|
||||
try:
|
||||
out[parts[2]] = (int(parts[3]), int(parts[7]))
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _is_rotational(disk_name: str) -> bool:
|
||||
try:
|
||||
with open(f"/sys/block/{disk_name}/queue/rotational") as f:
|
||||
return f.read().strip() == "1"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
_EXCLUDED_TTL = 15
|
||||
_excluded_cache: Optional[tuple[float, set]] = None
|
||||
|
||||
|
||||
def excluded_disk_names() -> set:
|
||||
"""Kernel names of the disks the user excluded. Fails open: if the
|
||||
list cannot be read, nothing is excluded rather than everything.
|
||||
Held for a few seconds, since every reader asks for every disk."""
|
||||
global _excluded_cache
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
if _excluded_cache is not None and _excluded_cache[0] > now:
|
||||
return set(_excluded_cache[1])
|
||||
names: set = set()
|
||||
try:
|
||||
from health_persistence import health_persistence
|
||||
keys = health_persistence.get_excluded_disk_keys()
|
||||
if keys:
|
||||
from disk_identity import names_for_keys
|
||||
names = names_for_keys(keys)
|
||||
except Exception:
|
||||
names = set()
|
||||
with _cache_lock:
|
||||
_excluded_cache = (now + _EXCLUDED_TTL, set(names))
|
||||
return names
|
||||
|
||||
|
||||
def invalidate_disk_exclusions() -> None:
|
||||
"""Apply a change to the exclusion list on the next read."""
|
||||
global _excluded_cache
|
||||
with _cache_lock:
|
||||
_excluded_cache = None
|
||||
|
||||
|
||||
_excluded_disk_names = excluded_disk_names
|
||||
|
||||
|
||||
def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
|
||||
"""Whether a periodic reader may touch this disk now: READ, IDLE or
|
||||
EXCLUDED. Shared by every reader that runs on its own, so the
|
||||
temperature poller and the storage view cannot disagree about a disk.
|
||||
``excluded`` lets a caller that checks many disks pass the list once."""
|
||||
if excluded is None:
|
||||
excluded = _excluded_disk_names()
|
||||
if disk_name in excluded:
|
||||
_idle_state.pop(disk_name, None)
|
||||
return EXCLUDED
|
||||
if not _is_rotational(disk_name):
|
||||
return READ
|
||||
current = _read_diskstats().get(disk_name)
|
||||
with _cache_lock:
|
||||
previous = _last_io.get(disk_name)
|
||||
if current is not None:
|
||||
_last_io[disk_name] = current
|
||||
if current is None or previous is None or current != previous:
|
||||
_idle_state.pop(disk_name, None)
|
||||
return READ
|
||||
_idle_state[disk_name] = time.time()
|
||||
return IDLE
|
||||
|
||||
|
||||
def is_disk_idle(disk_name: str) -> bool:
|
||||
"""True while the disk is being left alone for having no I/O."""
|
||||
ts = _idle_state.get(disk_name)
|
||||
return ts is not None and (time.time() - ts) < _IDLE_TTL
|
||||
|
||||
|
||||
def record_all_disk_temperatures() -> int:
|
||||
"""Sample every non-USB disk and persist its temperature.
|
||||
"""Sample the disks that may be read now and persist their temperature.
|
||||
|
||||
USB disks are included. A disk the user excluded, or a rotational one
|
||||
with no I/O since the last cycle, is skipped — see ``disk_read_policy``.
|
||||
|
||||
Sampling fans out across a thread pool so a host with N disks pays
|
||||
roughly the time of the slowest single ``smartctl`` call instead of
|
||||
@@ -419,7 +544,8 @@ def record_all_disk_temperatures() -> int:
|
||||
threading is enough — no need for asyncio. Returns the number of
|
||||
rows actually written.
|
||||
"""
|
||||
disks = _list_target_disks()
|
||||
excluded = _excluded_disk_names()
|
||||
disks = [d for d in _list_target_disks() if disk_read_policy(d, excluded) == READ]
|
||||
if not disks:
|
||||
return 0
|
||||
now = int(time.time())
|
||||
|
||||
@@ -309,22 +309,29 @@ def accept_exception():
|
||||
finding.get('incomplete') or not finding.get('scope')):
|
||||
return jsonify(success=False, message="This finding cannot be accepted"), 400
|
||||
|
||||
expires_at = None
|
||||
days = data.get('expires_in_days')
|
||||
if days is not None:
|
||||
try:
|
||||
if isinstance(days, bool) or int(days) != float(days) or not 1 <= int(days) <= 3650:
|
||||
raise ValueError("invalid expiry")
|
||||
expires_at = int(time.time()) + int(days) * 86400
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"success": False,
|
||||
"message": "Invalid expiry"}), 400
|
||||
def _in_days(value, label):
|
||||
"""A day count from now, or None. Same bounds as the expiry so a
|
||||
reminder cannot be set further out than a decision can last."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or int(value) != float(value) or not 1 <= int(value) <= 3650:
|
||||
raise ValueError(f"invalid {label}")
|
||||
return int(time.time()) + int(value) * 86400
|
||||
|
||||
try:
|
||||
expires_at = _in_days(data.get('expires_in_days'), 'expiry')
|
||||
# Independent of the expiry: it brings the decision back to the
|
||||
# reader on that date without withdrawing it.
|
||||
review_at = _in_days(data.get('review_in_days'), 'review date')
|
||||
except (TypeError, ValueError) as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 400
|
||||
|
||||
audit_store.accept_risk(
|
||||
check_id, reason,
|
||||
accepted_by=_actor(),
|
||||
expires_at=expires_at,
|
||||
scope=finding['scope'],
|
||||
review_at=review_at,
|
||||
)
|
||||
return jsonify({"success": True})
|
||||
except ValueError as e:
|
||||
|
||||
@@ -5,6 +5,7 @@ Flask routes for health monitoring with persistence support
|
||||
from flask import Blueprint, jsonify, request
|
||||
from health_monitor import health_monitor
|
||||
from health_persistence import health_persistence
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
|
||||
# Sprint 13: remote-mount monitor (NFS/CIFS/SMB) — separate module so a
|
||||
# missing helper doesn't crash the health blueprint.
|
||||
@@ -17,6 +18,7 @@ except ImportError:
|
||||
health_bp = Blueprint('health', __name__)
|
||||
|
||||
@health_bp.route('/api/health/status', methods=['GET'])
|
||||
@require_auth
|
||||
def get_health_status():
|
||||
"""Get overall health status summary"""
|
||||
try:
|
||||
@@ -26,6 +28,7 @@ def get_health_status():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/details', methods=['GET'])
|
||||
@require_auth
|
||||
def get_health_details():
|
||||
"""Get detailed health status with all checks"""
|
||||
try:
|
||||
@@ -58,6 +61,7 @@ def get_system_info():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/acknowledge', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def acknowledge_error():
|
||||
"""
|
||||
Acknowledge/dismiss an error manually.
|
||||
@@ -156,6 +160,7 @@ def acknowledge_error():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/un-acknowledge', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def unacknowledge_error():
|
||||
"""
|
||||
Re-enable a previously dismissed error.
|
||||
@@ -203,6 +208,7 @@ def unacknowledge_error():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/active-errors', methods=['GET'])
|
||||
@require_auth
|
||||
def get_active_errors():
|
||||
"""Get all active persistent errors"""
|
||||
try:
|
||||
@@ -213,6 +219,7 @@ def get_active_errors():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/dismissed', methods=['GET'])
|
||||
@require_auth
|
||||
def get_dismissed_errors():
|
||||
"""
|
||||
Get dismissed errors that are still within their suppression period.
|
||||
@@ -225,6 +232,7 @@ def get_dismissed_errors():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/full', methods=['GET'])
|
||||
@require_auth
|
||||
def get_full_health():
|
||||
"""
|
||||
Get complete health data in a single request: detailed status + active errors + dismissed.
|
||||
@@ -271,6 +279,7 @@ def get_full_health():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/cleanup-orphans', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def cleanup_orphan_errors():
|
||||
"""
|
||||
Clean up errors for devices that no longer exist in the system.
|
||||
@@ -331,6 +340,7 @@ def cleanup_orphan_errors():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/pending-notifications', methods=['GET'])
|
||||
@require_auth
|
||||
def get_pending_notifications():
|
||||
"""
|
||||
Get events pending notification (for future Telegram/Gotify/Discord integration).
|
||||
@@ -343,6 +353,7 @@ def get_pending_notifications():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@health_bp.route('/api/health/mark-notified', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def mark_events_notified():
|
||||
"""
|
||||
Mark events as notified after notification was sent successfully.
|
||||
@@ -364,6 +375,7 @@ def mark_events_notified():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/settings', methods=['GET'])
|
||||
@require_auth
|
||||
def get_health_settings():
|
||||
"""
|
||||
Get per-category suppression duration settings.
|
||||
@@ -377,6 +389,7 @@ def get_health_settings():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/settings', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def save_health_settings():
|
||||
"""
|
||||
Save per-category suppression duration settings.
|
||||
@@ -422,6 +435,7 @@ def save_health_settings():
|
||||
# ── Remote Storage Exclusions Endpoints ──
|
||||
|
||||
@health_bp.route('/api/health/remote-storages', methods=['GET'])
|
||||
@require_auth
|
||||
def get_remote_storages():
|
||||
"""
|
||||
Get list of all remote storages with their exclusion status.
|
||||
@@ -472,6 +486,7 @@ def get_remote_storages():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions', methods=['GET'])
|
||||
@require_auth
|
||||
def get_storage_exclusions():
|
||||
"""Get all storage exclusions."""
|
||||
try:
|
||||
@@ -482,6 +497,7 @@ def get_storage_exclusions():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def save_storage_exclusion():
|
||||
"""
|
||||
Add or update a storage exclusion.
|
||||
@@ -535,6 +551,7 @@ def save_storage_exclusion():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/storage-exclusions/<storage_name>', methods=['DELETE'])
|
||||
@require_admin_scope
|
||||
def delete_storage_exclusion(storage_name):
|
||||
"""Remove a storage from the exclusion list."""
|
||||
try:
|
||||
@@ -555,6 +572,7 @@ def delete_storage_exclusion(storage_name):
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@health_bp.route('/api/health/interfaces', methods=['GET'])
|
||||
@require_auth
|
||||
def get_network_interfaces():
|
||||
"""Get all network interfaces with their exclusion status."""
|
||||
try:
|
||||
@@ -615,6 +633,7 @@ def get_network_interfaces():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions', methods=['GET'])
|
||||
@require_auth
|
||||
def get_interface_exclusions():
|
||||
"""Get all interface exclusions."""
|
||||
try:
|
||||
@@ -625,6 +644,7 @@ def get_interface_exclusions():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def save_interface_exclusion():
|
||||
"""
|
||||
Add or update an interface exclusion.
|
||||
@@ -677,6 +697,7 @@ def save_interface_exclusion():
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions/<interface_name>', methods=['DELETE'])
|
||||
@require_admin_scope
|
||||
def delete_interface_exclusion(interface_name):
|
||||
"""Remove an interface from the exclusion list."""
|
||||
try:
|
||||
@@ -692,7 +713,102 @@ def delete_interface_exclusion(interface_name):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/disks', methods=['GET'])
|
||||
@require_auth
|
||||
def get_disks_for_exclusion():
|
||||
"""Physical disks with whether each is excluded from periodic reads.
|
||||
|
||||
Listed from udev and /sys only, so opening the settings page does not
|
||||
touch a disk the user is about to exclude precisely to leave it alone.
|
||||
"""
|
||||
try:
|
||||
from disk_identity import list_physical_disks
|
||||
import disk_temperature_history as _dth
|
||||
excluded = {e['disk_key']: e for e in health_persistence.get_excluded_disks()}
|
||||
present = set()
|
||||
result = []
|
||||
for disk in list_physical_disks():
|
||||
present.add(disk['key'])
|
||||
entry = excluded.get(disk['key'])
|
||||
result.append({
|
||||
**disk,
|
||||
'excluded': entry is not None,
|
||||
'excluded_at': entry.get('excluded_at') if entry else None,
|
||||
'idle': _dth.is_disk_idle(disk['name']),
|
||||
'present': True,
|
||||
})
|
||||
# An excluded disk that is not connected right now — an unplugged USB
|
||||
# drive — stays in the list, so its exclusion can still be seen and
|
||||
# removed rather than silently waiting for it to come back.
|
||||
for key, entry in excluded.items():
|
||||
if key in present:
|
||||
continue
|
||||
result.append({
|
||||
'name': entry.get('disk_name') or '',
|
||||
'key': key,
|
||||
'model': entry.get('model') or '',
|
||||
'serial': entry.get('serial') or '',
|
||||
'size_bytes': 0,
|
||||
'transport': '',
|
||||
'rotational': False,
|
||||
'excluded': True,
|
||||
'excluded_at': entry.get('excluded_at'),
|
||||
'idle': False,
|
||||
'present': False,
|
||||
})
|
||||
result.sort(key=lambda d: (not d['present'], d['name']))
|
||||
return jsonify({'disks': result})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/disk-exclusions', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def save_disk_exclusion():
|
||||
"""Exclude a disk from periodic reads.
|
||||
|
||||
Request body: {"disk_key": "serial:WD-...", "disk_name": "sdb",
|
||||
"model": "...", "serial": "...", "reason": "..."}
|
||||
The key is the one /api/health/disks reports; it follows the disk
|
||||
across kernel renames.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
disk_key = str(data.get('disk_key') or '').strip()
|
||||
if not disk_key or ':' not in disk_key or len(disk_key) > 200:
|
||||
return jsonify({'error': 'a valid disk_key is required'}), 400
|
||||
ok = health_persistence.exclude_disk(
|
||||
disk_key,
|
||||
disk_name=str(data.get('disk_name') or '')[:64] or None,
|
||||
model=str(data.get('model') or '')[:128] or None,
|
||||
serial=str(data.get('serial') or '')[:128] or None,
|
||||
reason=str(data.get('reason') or '')[:500] or None,
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({'error': 'Failed to save exclusion'}), 500
|
||||
import disk_temperature_history as _dth
|
||||
_dth.invalidate_disk_exclusions()
|
||||
return jsonify({'success': True, 'disk_key': disk_key})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/disk-exclusions/<path:disk_key>', methods=['DELETE'])
|
||||
@require_admin_scope
|
||||
def delete_disk_exclusion(disk_key):
|
||||
"""Put a disk back under periodic reads."""
|
||||
try:
|
||||
if not health_persistence.remove_disk_exclusion(disk_key):
|
||||
return jsonify({'error': 'Disk not found in exclusions'}), 404
|
||||
import disk_temperature_history as _dth
|
||||
_dth.invalidate_disk_exclusions()
|
||||
return jsonify({'success': True, 'disk_key': disk_key})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/mounts', methods=['GET'])
|
||||
@require_auth
|
||||
def get_remote_mounts():
|
||||
"""Sprint 13: list NFS/CIFS/SMB mounts on the host AND inside every
|
||||
running LXC, with per-mount health (reachable / stale / read-only).
|
||||
|
||||
@@ -1616,8 +1616,10 @@ _system_info_cache = {
|
||||
'proxmox_version_time': 0,
|
||||
'available_updates': 0,
|
||||
'available_updates_time': 0,
|
||||
'available_updates_stamp': 0.0,
|
||||
}
|
||||
_SYSTEM_INFO_CACHE_TTL = 21600 # 6 hours - update notifications are sent once per 24h
|
||||
_AVAILABLE_UPDATES_MIN_INTERVAL = 30 # seconds between apt recounts when apt state moves
|
||||
|
||||
# Cache for pvesh cluster resources (reduces repeated API calls)
|
||||
_pvesh_cache = {
|
||||
@@ -3316,15 +3318,39 @@ def get_proxmox_version():
|
||||
_system_info_cache['proxmox_version_time'] = now
|
||||
return proxmox_version
|
||||
|
||||
def _apt_state_stamp():
|
||||
"""Newest mtime of the files that decide what `apt list --upgradable`
|
||||
answers: dpkg's status file (what is installed) and apt's package
|
||||
lists (what is on offer). Any upgrade moves it — whichever way the
|
||||
packages were installed."""
|
||||
newest = 0.0
|
||||
for path in ('/var/lib/dpkg/status', '/var/lib/apt/lists', '/var/cache/apt/pkgcache.bin'):
|
||||
try:
|
||||
newest = max(newest, os.path.getmtime(path))
|
||||
except OSError:
|
||||
pass
|
||||
return newest
|
||||
|
||||
|
||||
def get_available_updates():
|
||||
"""Get the number of available package updates. Cached for 6 hours."""
|
||||
"""Get the number of available package updates. Cached for 6 hours,
|
||||
or until apt's own state moves — an upgrade that finishes two minutes
|
||||
after the count was taken must not leave the overview showing what
|
||||
was pending before it ran."""
|
||||
global _system_info_cache
|
||||
|
||||
|
||||
now = time.time()
|
||||
if _system_info_cache['available_updates_time'] > 0 and \
|
||||
now - _system_info_cache['available_updates_time'] < _SYSTEM_INFO_CACHE_TTL:
|
||||
return _system_info_cache['available_updates']
|
||||
|
||||
stamp = _apt_state_stamp()
|
||||
age = now - _system_info_cache['available_updates_time']
|
||||
if _system_info_cache['available_updates_time'] > 0:
|
||||
# dpkg rewrites its status file once per package, so during an
|
||||
# upgrade the stamp moves with every one of them. The floor keeps
|
||||
# that from turning each overview poll into an apt call.
|
||||
if age < _AVAILABLE_UPDATES_MIN_INTERVAL:
|
||||
return _system_info_cache['available_updates']
|
||||
if stamp == _system_info_cache['available_updates_stamp'] and age < _SYSTEM_INFO_CACHE_TTL:
|
||||
return _system_info_cache['available_updates']
|
||||
|
||||
available_updates = 0
|
||||
try:
|
||||
# Use apt list --upgradable to count available updates
|
||||
@@ -3340,6 +3366,7 @@ def get_available_updates():
|
||||
|
||||
_system_info_cache['available_updates'] = available_updates
|
||||
_system_info_cache['available_updates_time'] = now
|
||||
_system_info_cache['available_updates_stamp'] = stamp
|
||||
return available_updates
|
||||
|
||||
# AGREGANDO FUNCIÓN PARA PARSEAR PROCESOS DE INTEL_GPU_TOP (SIN -J)
|
||||
@@ -4123,9 +4150,13 @@ def get_storage_info():
|
||||
# temperature graph isn't a monitor bug — the
|
||||
# disk is parked. See issue #232.
|
||||
in_standby = False
|
||||
in_idle = False
|
||||
in_excluded = False
|
||||
try:
|
||||
import disk_temperature_history as _dth
|
||||
in_standby = _dth.is_disk_in_standby(disk_name)
|
||||
in_idle = _dth.is_disk_idle(disk_name)
|
||||
in_excluded = disk_name in _dth.excluded_disk_names()
|
||||
except Exception:
|
||||
pass
|
||||
physical_disks[disk_name] = {
|
||||
@@ -4135,6 +4166,8 @@ def get_storage_info():
|
||||
'size_bytes': disk_size_bytes,
|
||||
'temperature': smart_data.get('temperature', 0),
|
||||
'standby': in_standby,
|
||||
'idle': in_idle,
|
||||
'excluded': in_excluded,
|
||||
'health': smart_data.get('health', 'unknown'),
|
||||
'power_on_hours': smart_data.get('power_on_hours', 0),
|
||||
'smart_status': smart_data.get('smart_status', 'unknown'),
|
||||
@@ -4860,6 +4893,22 @@ def get_smart_data(disk_name):
|
||||
if cached and now - cached[0] < _SMART_RESULT_TTL:
|
||||
return dict(cached[1])
|
||||
|
||||
# Excluded, or rotational with no I/O since it was last looked at: send
|
||||
# it nothing — not even the power-mode question below, which is still a
|
||||
# command. Serve what is known, without a temperature that would only be
|
||||
# stale. Same rule as the temperature poller, so the two agree.
|
||||
try:
|
||||
import disk_temperature_history as _dth
|
||||
policy = _dth.disk_read_policy(disk_name)
|
||||
except Exception:
|
||||
policy = 'read'
|
||||
if policy != 'read':
|
||||
base = dict(cached[1]) if cached else _smart_default_payload()
|
||||
base['temperature'] = 0
|
||||
base['excluded'] = policy == 'excluded'
|
||||
base['idle'] = policy == 'idle'
|
||||
return base
|
||||
|
||||
if _hdd_in_standby(disk_name):
|
||||
# Keep serving the last known values (temperature blanked, since
|
||||
# we don't have a fresh one) so the card stays populated while
|
||||
@@ -14433,7 +14482,7 @@ def api_health_thresholds_get():
|
||||
|
||||
|
||||
@app.route('/api/health/thresholds', methods=['PUT'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def api_health_thresholds_put():
|
||||
"""Save a partial threshold payload. Body shape mirrors DEFAULTS
|
||||
but the leaves are bare numbers, not metadata dicts. Sections not
|
||||
@@ -14453,7 +14502,7 @@ def api_health_thresholds_put():
|
||||
|
||||
|
||||
@app.route('/api/health/thresholds/reset', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def api_health_thresholds_reset():
|
||||
"""Reset thresholds. ?section=<name> resets one section, no
|
||||
parameter resets everything to recommended."""
|
||||
@@ -14473,7 +14522,7 @@ def api_health_thresholds_reset():
|
||||
|
||||
|
||||
@app.route('/api/health/acknowledge', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def api_health_acknowledge():
|
||||
"""Acknowledge/dismiss a health error by error_key.
|
||||
|
||||
@@ -14502,7 +14551,7 @@ def api_health_acknowledge():
|
||||
|
||||
|
||||
@app.route('/api/health/un-acknowledge', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin_scope
|
||||
def api_health_unacknowledge():
|
||||
"""Reverse a previous dismiss — re-enables the alert so it can fire again.
|
||||
|
||||
@@ -20148,7 +20197,11 @@ def _borg_env_for(target: dict, extra: dict | None = None) -> dict:
|
||||
env['BORG_PASSPHRASE'] = pw
|
||||
ssh_key = target.get('ssh_key') or ''
|
||||
if ssh_key:
|
||||
env['BORG_RSH'] = f'ssh -i {ssh_key} -o StrictHostKeyChecking=accept-new'
|
||||
# IdentitiesOnly keeps ssh from offering root's default keys first:
|
||||
# a server that only accepts the ProxMenux key can hit MaxAuthTries
|
||||
# before it is ever tried. borg adds `-p <port>` from the ssh:// URL.
|
||||
env['BORG_RSH'] = (f'ssh -i {ssh_key} -o IdentitiesOnly=yes '
|
||||
'-o StrictHostKeyChecking=accept-new')
|
||||
# Non-interactive: if borg would prompt about a relocated repo, take
|
||||
# the safe answer instead of hanging the request.
|
||||
env['BORG_RELOCATED_REPO_ACCESS_IS_OK'] = 'yes'
|
||||
|
||||
@@ -421,6 +421,22 @@ class HealthPersistence:
|
||||
)
|
||||
''')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_excluded_interface ON excluded_interfaces(interface_name)')
|
||||
|
||||
# Disks the user wants left alone: no periodic SMART or temperature
|
||||
# reads, so a drive can reach its own spin-down and stop cycling its
|
||||
# heads. Keyed by a stable identity rather than the kernel name, which
|
||||
# a USB drive can change (sda -> sdb) on every reconnection.
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS excluded_disks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
disk_key TEXT UNIQUE NOT NULL,
|
||||
disk_name TEXT,
|
||||
model TEXT,
|
||||
serial TEXT,
|
||||
excluded_at TEXT NOT NULL,
|
||||
reason TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -430,7 +446,7 @@ class HealthPersistence:
|
||||
required_tables = {'errors', 'events', 'system_capabilities', 'user_settings',
|
||||
'notification_history', 'notification_last_sent', 'notification_delivery_claims',
|
||||
'disk_registry', 'disk_observations',
|
||||
'excluded_storages', 'excluded_interfaces'}
|
||||
'excluded_storages', 'excluded_interfaces', 'excluded_disks'}
|
||||
missing = required_tables - tables
|
||||
if missing:
|
||||
print(f"[HealthPersistence] WARNING: Missing tables after init: {missing}")
|
||||
@@ -3257,6 +3273,67 @@ class HealthPersistence:
|
||||
print(f"[HealthPersistence] Error removing interface exclusion: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Disk exclusions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_excluded_disks(self) -> List[Dict[str, Any]]:
|
||||
"""Every disk the user has excluded from periodic reads."""
|
||||
try:
|
||||
with self._db_connection(row_factory=True) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT disk_key, disk_name, model, serial, excluded_at, reason
|
||||
FROM excluded_disks
|
||||
''')
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error getting excluded disks: {e}")
|
||||
return []
|
||||
|
||||
def exclude_disk(self, disk_key: str, disk_name: str = None, model: str = None,
|
||||
serial: str = None, reason: str = None) -> bool:
|
||||
"""Add a disk to the exclusion list, or refresh its display fields."""
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO excluded_disks
|
||||
(disk_key, disk_name, model, serial, excluded_at, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(disk_key) DO UPDATE SET
|
||||
disk_name = excluded.disk_name,
|
||||
model = excluded.model,
|
||||
serial = excluded.serial
|
||||
''', (disk_key, disk_name, model, serial, datetime.now().isoformat(), reason))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error excluding disk: {e}")
|
||||
return False
|
||||
|
||||
def remove_disk_exclusion(self, disk_key: str) -> bool:
|
||||
"""Put a disk back under periodic reads."""
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM excluded_disks WHERE disk_key = ?', (disk_key,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"[HealthPersistence] Error removing disk exclusion: {e}")
|
||||
return False
|
||||
|
||||
def get_excluded_disk_keys(self) -> set:
|
||||
"""Stable keys of the excluded disks (see disk_identity.disk_key)."""
|
||||
try:
|
||||
with self._db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT disk_key FROM excluded_disks')
|
||||
return {row[0] for row in cursor.fetchall()}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def get_excluded_interface_names(self, check_type: str = 'health') -> set:
|
||||
"""
|
||||
Get set of interface names excluded for a specific check type.
|
||||
|
||||
@@ -2622,6 +2622,7 @@ def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
|
||||
# showing the version of an image it no longer runs.
|
||||
app['docker_available_version'] = None
|
||||
app['docker_update_available'] = None
|
||||
app['docker_pinned'] = None
|
||||
link = resolve_docker_image_for_app(app, docker_inventory)
|
||||
app['docker_image_reference'] = link.get('image_reference')
|
||||
app['docker_binding_error'] = link.get('error')
|
||||
@@ -2633,6 +2634,7 @@ def annotate_delegated_apps(apps: list, docker_inventory: dict) -> None:
|
||||
continue
|
||||
app['docker_available_version'] = image.get('available_version')
|
||||
app['docker_update_available'] = image.get('update_available')
|
||||
app['docker_pinned'] = image.get('pinned')
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2903,6 +2905,7 @@ def _docker_inventory_from_ct(vmid) -> dict:
|
||||
"architecture": str(inspected_image.get("Architecture") or ""),
|
||||
"variant": str(inspected_image.get("Variant") or ""),
|
||||
},
|
||||
"pinned": False,
|
||||
"available_version": None,
|
||||
"available_version_source": None,
|
||||
"update_available": None,
|
||||
@@ -2911,13 +2914,78 @@ def _docker_inventory_from_ct(vmid) -> dict:
|
||||
if len(images) >= _DOCKER_MAX_IMAGES:
|
||||
break
|
||||
|
||||
# A container pinned by digest runs exactly the image it names; a newer
|
||||
# tag upstream does not move it, only an edit to its reference does. It is
|
||||
# listed under that reference with its installed version, and is never
|
||||
# compared with the registry nor offered an update.
|
||||
pinned_groups: dict[str, list[dict]] = {}
|
||||
for item in containers:
|
||||
reference = str(item.get("image_reference") or item.get("image") or "").strip()
|
||||
if "@" in reference:
|
||||
pinned_groups.setdefault(reference, []).append(item)
|
||||
for reference in sorted(pinned_groups):
|
||||
if len(images) >= _DOCKER_MAX_IMAGES:
|
||||
break
|
||||
name, _, pinned_digest = reference.partition("@")
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", pinned_digest):
|
||||
continue
|
||||
final_component = name.rsplit("/", 1)[-1]
|
||||
repository, tag = name.rsplit(":", 1) if ":" in final_component else (name, "")
|
||||
parsed = _parse_docker_reference(repository, tag or pinned_digest)
|
||||
if not parsed or reference in seen:
|
||||
continue
|
||||
seen.add(reference)
|
||||
parsed = {**parsed, "tag": tag, "reference": reference}
|
||||
group = pinned_groups[reference]
|
||||
image_id = next((str(item.get("image_id")) for item in group if item.get("image_id")), "")
|
||||
inspected_image = (
|
||||
inspected_images.get(image_id)
|
||||
or inspected_images.get(image_id.removeprefix("sha256:"))
|
||||
or {}
|
||||
)
|
||||
installed_version, installed_version_source = _docker_version_from_image_inspect(
|
||||
parsed, inspected_image,
|
||||
)
|
||||
primary_compose = group[0].get("compose") or {}
|
||||
display_meta = _docker_service_catalog_meta(
|
||||
str(primary_compose.get("service") or ""),
|
||||
str(group[0].get("name") or ""),
|
||||
reference,
|
||||
)
|
||||
images.append({
|
||||
**parsed,
|
||||
"local_digest": pinned_digest,
|
||||
"remote_digest": None,
|
||||
"image_id": image_id,
|
||||
"used_by": sorted({item["name"] for item in group}),
|
||||
"update_targets": [],
|
||||
"standalone_containers": [],
|
||||
"display_name": display_meta.get("name"),
|
||||
"logo_url": display_meta.get("logo_url"),
|
||||
"installed_version": installed_version,
|
||||
"installed_version_source": installed_version_source,
|
||||
"platform": {
|
||||
"os": str(inspected_image.get("Os") or ""),
|
||||
"architecture": str(inspected_image.get("Architecture") or ""),
|
||||
"variant": str(inspected_image.get("Variant") or ""),
|
||||
},
|
||||
"pinned": True,
|
||||
"available_version": None,
|
||||
"available_version_source": None,
|
||||
"update_available": None,
|
||||
"error": None,
|
||||
})
|
||||
|
||||
def _check(item: dict) -> tuple[str, Optional[str], Optional[str]]:
|
||||
remote, error = _fetch_registry_manifest_digest(item)
|
||||
return item["reference"], remote, error
|
||||
|
||||
if images:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(images))) as pool:
|
||||
results = list(pool.map(_check, images))
|
||||
checkable = [item for item in images if not item.get("pinned")]
|
||||
results = []
|
||||
if checkable:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(checkable))) as pool:
|
||||
results = list(pool.map(_check, checkable))
|
||||
by_ref = {ref: (digest, error) for ref, digest, error in results}
|
||||
for item in images:
|
||||
remote, remote_error = by_ref.get(item["reference"], (None, None))
|
||||
|
||||
@@ -40,9 +40,10 @@ CATALOG_FILE = os.path.join(OCI_BASE_DIR, "catalog.json")
|
||||
INSTALLED_FILE = os.path.join(OCI_BASE_DIR, "installed.json")
|
||||
INSTANCES_DIR = os.path.join(OCI_BASE_DIR, "instances")
|
||||
|
||||
# Source catalog from Scripts (bundled with ProxMenux)
|
||||
SCRIPTS_CATALOG = "/usr/local/share/proxmenux/scripts/oci/catalog.json"
|
||||
DEV_SCRIPTS_CATALOG = os.path.join(os.path.dirname(__file__), "..", "..", "Scripts", "oci", "catalog.json")
|
||||
# Source catalog shipped with ProxMenux, inside the OCI engine
|
||||
SCRIPTS_CATALOG = os.path.join(OCI_BASE_DIR, "engine", "addons", "secure-gateway.json")
|
||||
LEGACY_SCRIPTS_CATALOG = "/usr/local/share/proxmenux/scripts/oci/catalog.json"
|
||||
DEV_SCRIPTS_CATALOG = os.path.join(os.path.dirname(__file__), "..", "..", "oci", "addons", "secure-gateway.json")
|
||||
|
||||
# Encryption key file
|
||||
ENCRYPTION_KEY_FILE = os.path.join(OCI_BASE_DIR, ".encryption_key")
|
||||
@@ -143,10 +144,10 @@ def ensure_oci_directories():
|
||||
os.makedirs(INSTANCES_DIR, exist_ok=True)
|
||||
|
||||
if not os.path.exists(CATALOG_FILE):
|
||||
if os.path.exists(SCRIPTS_CATALOG):
|
||||
shutil.copy2(SCRIPTS_CATALOG, CATALOG_FILE)
|
||||
elif os.path.exists(DEV_SCRIPTS_CATALOG):
|
||||
shutil.copy2(DEV_SCRIPTS_CATALOG, CATALOG_FILE)
|
||||
for source in (SCRIPTS_CATALOG, LEGACY_SCRIPTS_CATALOG, DEV_SCRIPTS_CATALOG):
|
||||
if os.path.exists(source):
|
||||
shutil.copy2(source, CATALOG_FILE)
|
||||
break
|
||||
|
||||
if not os.path.exists(INSTALLED_FILE):
|
||||
with open(INSTALLED_FILE, 'w') as f:
|
||||
@@ -689,7 +690,7 @@ def load_catalog() -> Dict[str, Any]:
|
||||
"""Load the OCI app catalog."""
|
||||
ensure_oci_directories()
|
||||
|
||||
for path in [CATALOG_FILE, SCRIPTS_CATALOG, DEV_SCRIPTS_CATALOG]:
|
||||
for path in [CATALOG_FILE, SCRIPTS_CATALOG, LEGACY_SCRIPTS_CATALOG, DEV_SCRIPTS_CATALOG]:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r') as f:
|
||||
|
||||
Reference in New Issue
Block a user