mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 10:06:41 +00:00
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>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
"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>
|
|
)
|
|
}
|