Files
Homelab-manager/web/src/pages/Maintenance.tsx
T
bobbanandClaude Sonnet 5 df2a5ce42b Add a Proxmox Backup Server integration: datastore/snapshot verification status
Proxmox VE already shows whether the last vzdump push to PBS succeeded, but
has no visibility into PBS's own backup verification, GC/prune health, or
host status. This adds PBS as its own integration (own adapter, page, nav
entry, and Dashboard widget) that reads datastore usage and, for every
stored snapshot, its verification state directly from PBS.

A new daily check (mirroring the existing Proxmox backup-failure check)
notifies when a snapshot has failed verification or a datastore couldn't be
read, with its own toggle in Settings -> Notifications and its own
maintenance-window silencing.

Not verified against a live PBS instance — built from PBS's published API
docs and a scratch test against a mocked PBS server exercising the adapter's
parsing and auth-header format (PBSAPIToken uses a colon separator, unlike
PVE's PVEAPIToken which uses =). See INTEGRATIONS.md for details and the
"not verified" caveat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 20:32:52 +02:00

382 lines
15 KiB
TypeScript

import { useEffect, useState } from "react";
import {
api,
type CurrentUser,
type IntegrationSummary,
type MaintenanceTargets,
type MaintenanceTargetType,
type MaintenanceWindow,
type UptimeKumaImportResult,
} from "../api/client";
import { formatDateTime } from "../utils/date";
import { formatRemaining } from "../utils/duration";
import { readableError } from "../utils/errors";
const DURATIONS: { minutes: number; label: string }[] = [
{ minutes: 30, label: "30 minutes" },
{ minutes: 60, label: "1 hour" },
{ minutes: 120, label: "2 hours" },
{ minutes: 240, label: "4 hours" },
{ minutes: 480, label: "8 hours" },
{ minutes: 1440, label: "24 hours" },
{ minutes: 4320, label: "3 days" },
];
// Tells the app shell's banner to refetch right away instead of waiting for its next poll.
function announceChange() {
window.dispatchEvent(new Event("maintenance-changed"));
}
export default function Maintenance({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [windows, setWindows] = useState<MaintenanceWindow[] | null>(null);
const [targets, setTargets] = useState<MaintenanceTargets | null>(null);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const [target, setTarget] = useState("");
const [minutes, setMinutes] = useState(60);
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [kumaIntegrations, setKumaIntegrations] = useState<IntegrationSummary[] | null>(null);
const [kumaIntegrationId, setKumaIntegrationId] = useState<number | "">("");
const [kumaMinutes, setKumaMinutes] = useState(60);
const [kumaBusy, setKumaBusy] = useState(false);
const [kumaError, setKumaError] = useState<string | null>(null);
const [kumaResult, setKumaResult] = useState<UptimeKumaImportResult | null>(null);
useEffect(() => {
api.integrations
.list()
.then((res) => {
const found = res.integrations.filter((i) => i.type === "uptimekuma");
setKumaIntegrations(found);
const firstEnabled = found.find((i) => i.enabled);
if (firstEnabled) setKumaIntegrationId(firstEnabled.id);
})
.catch(() => setKumaIntegrations([]));
}, []);
function load() {
api.maintenance
.list()
.then((res) => {
setWindows(res.windows);
setTargets(res.targets);
})
.catch((err) => setError(readableError(err)));
}
useEffect(() => {
load();
const tick = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(tick);
}, []);
async function start(e: React.FormEvent) {
e.preventDefault();
const [targetType, id] = target.split(":");
if (!targetType || !id) return;
setError(null);
setBusy(true);
try {
await api.maintenance.start({
targetType: targetType as MaintenanceTargetType,
targetId: Number(id),
minutes,
reason: reason.trim() || undefined,
});
setTarget("");
setReason("");
load();
announceChange();
} catch (err) {
setError(readableError(err));
} finally {
setBusy(false);
}
}
async function end(w: MaintenanceWindow) {
setError(null);
try {
await api.maintenance.end(w.id);
load();
announceChange();
} catch (err) {
setError(readableError(err));
}
}
async function importFromUptimeKuma(e: React.FormEvent) {
e.preventDefault();
if (kumaIntegrationId === "") return;
setKumaError(null);
setKumaResult(null);
setKumaBusy(true);
try {
const result = await api.maintenance.importUptimeKuma({ integrationId: kumaIntegrationId, minutes: kumaMinutes });
setKumaResult(result);
if (result.imported.length > 0) {
load();
announceChange();
}
} catch (err) {
setKumaError(readableError(err));
} finally {
setKumaBusy(false);
}
}
return (
<>
<h2 className="page-title mb-3">Maintenance</h2>
<div className="text-secondary mb-3">
Silence alerts about one server or integration while you work on it. Every window ends on its own — anything
still wrong when it ends alerts then, so nothing stays hidden.
</div>
{error && <div className="alert alert-danger">{error}</div>}
{canEdit && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Start maintenance</h3>
</div>
<form onSubmit={start}>
<div className="card-body row g-3">
<div className="col-md-4">
<label className="form-label">What are you working on?</label>
<select className="form-select" required value={target} onChange={(e) => setTarget(e.target.value)}>
<option value="">Choose…</option>
{targets && targets.servers.length > 0 && (
<optgroup label="Servers">
{targets.servers.map((s) => (
<option key={`s${s.id}`} value={`server:${s.id}`}>
{s.name}
</option>
))}
</optgroup>
)}
{targets && targets.integrations.length > 0 && (
<optgroup label="Integrations">
{targets.integrations.map((i) => (
<option key={`i${i.id}`} value={`integration:${i.id}`}>
{i.name} ({i.type})
</option>
))}
</optgroup>
)}
{targets && targets.dnsProviders.length > 0 && (
<optgroup label="DNS providers">
{targets.dnsProviders.map((p) => (
<option key={`d${p.id}`} value={`dns_provider:${p.id}`}>
{p.name} ({p.providerType})
</option>
))}
</optgroup>
)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">For</label>
<select className="form-select" value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}>
{DURATIONS.map((d) => (
<option key={d.minutes} value={d.minutes}>
{d.label}
</option>
))}
</select>
</div>
<div className="col-md-5">
<label className="form-label">Reason (optional)</label>
<input
className="form-control"
maxLength={200}
placeholder="e.g. kernel update and reboot"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</div>
</div>
<div className="card-footer">
<button type="submit" className="btn btn-primary" disabled={busy || !target}>
{busy ? "Starting…" : "Start maintenance"}
</button>
</div>
</form>
</div>
)}
{canEdit && kumaIntegrations && kumaIntegrations.length > 0 && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Import from Uptime Kuma</h3>
</div>
<form onSubmit={importFromUptimeKuma}>
<div className="card-body row g-3">
<div className="text-secondary">
Uptime Kuma only ever tells us what's in maintenance <em>right now</em>, not for how long, so pick a
duration below — it doesn't have to match Uptime Kuma's own schedule exactly, and importing again
later just extends the window instead of stacking a second one.
</div>
{kumaIntegrations.length > 1 && (
<div className="col-md-5">
<label className="form-label">Uptime Kuma integration</label>
<select
className="form-select"
value={kumaIntegrationId}
onChange={(e) => setKumaIntegrationId(e.target.value ? Number(e.target.value) : "")}
>
{kumaIntegrations.map((i) => (
<option key={i.id} value={i.id} disabled={!i.enabled}>
{i.name}
{!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
)}
<div className="col-md-3">
<label className="form-label">For</label>
<select className="form-select" value={kumaMinutes} onChange={(e) => setKumaMinutes(Number(e.target.value))}>
{DURATIONS.map((d) => (
<option key={d.minutes} value={d.minutes}>
{d.label}
</option>
))}
</select>
</div>
</div>
{kumaError && (
<div className="card-body pt-0">
<div className="alert alert-danger mb-0">{kumaError}</div>
</div>
)}
{kumaResult && (
<div className="card-body pt-0">
{kumaResult.monitorsInMaintenance === 0 ? (
<div className="text-secondary">Nothing is currently in maintenance in Uptime Kuma.</div>
) : (
<div className="alert alert-success mb-0">
{kumaResult.imported.length > 0 ? (
<>
{kumaResult.imported.length} window{kumaResult.imported.length === 1 ? "" : "s"}{" "}
{kumaResult.imported.every((i) => i.extended)
? "extended"
: kumaResult.imported.every((i) => !i.extended)
? "started"
: "started or extended"}
: {kumaResult.imported.map((i) => `${i.serverName} (${i.extended ? "extended" : "started"})`).join(", ")}.
</>
) : (
"None of the monitors currently in maintenance matched a server."
)}
{kumaResult.unmatched.length > 0 && (
<div className="mt-1">
{kumaResult.unmatched.length} monitor{kumaResult.unmatched.length === 1 ? "" : "s"} in maintenance
{" "}couldn't be matched to a server: {kumaResult.unmatched.join(", ")}.
</div>
)}
</div>
)}
</div>
)}
<div className="card-footer">
<button type="submit" className="btn btn-primary" disabled={kumaBusy || kumaIntegrationId === ""}>
{kumaBusy ? "Importing…" : "Import"}
</button>
</div>
</form>
</div>
)}
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Active</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Target</th>
<th>Reason</th>
<th>Ends</th>
<th>Started by</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{windows?.map((w) => (
<tr key={w.id}>
<td>
{w.targetName} <span className="text-secondary">· {w.targetKind}</span>
</td>
<td className="text-secondary">{w.reason ?? "—"}</td>
<td>
<span className="badge bg-yellow-lt text-yellow me-2">{formatRemaining(w.endsAt, now)} left</span>
<span className="text-secondary small">{formatDateTime(new Date(w.endsAt))}</span>
</td>
<td className="text-secondary">{w.createdBy ?? "—"}</td>
{canEdit && (
<td>
<button className="btn btn-sm btn-outline-secondary" onClick={() => end(w)}>
End now
</button>
</td>
)}
</tr>
))}
{windows?.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
Nothing is in maintenance — all alerts are active.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">What gets silenced</h3>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-6">
<div className="fw-bold mb-1">Silenced for the target</div>
<ul className="text-secondary">
<li>Server offline and disk-full alerts (for a server).</li>
<li>Storage, volume and disk-health alerts (for a Proxmox or Synology integration).</li>
<li>Backup-failure and uncovered-guest alerts (for a Proxmox integration).</li>
<li>Backup-verification-failure alerts (for a Proxmox Backup Server integration).</li>
<li>Failed-run alerts for the Semaphore or Gitea integration you choose.</li>
<li>
"Integration down" alerts for that <em>type</em> of service — these are tracked per type (e.g. all
Proxmox), not per instance, so with two Proxmox integrations a failure on the other one is silenced
too while a window is open.
</li>
</ul>
</div>
<div className="col-md-6">
<div className="fw-bold mb-1">Not silenced</div>
<ul className="text-secondary">
<li>Secret and certificate expiry, Tailscale key expiry, Docker update reminders.</li>
<li>DNS record change notifications.</li>
</ul>
<div className="fw-bold mb-1">When a window ends</div>
<div className="text-secondary">
A problem that started during it and is still there alerts on the next check (within 15 minutes). One
that was already alerted before it began and has since cleared is reported as cleared; one that
started and cleared entirely inside the window is never reported.
</div>
</div>
</div>
</div>
</div>
</>
);
}