Surface Proxmox backup job status, with a daily failure notification

Proxmox already runs vzdump backups, but nothing in the app said
whether they were actually succeeding — a silent backup failure is
one of the more dangerous blind spots a homelab admin can have. Adds
a "Backups" card to the Proxmox page: configured backup job
schedules (storage target, which guests, enabled/disabled) from
GET /cluster/backup, and recent vzdump task history per node from
GET /nodes/{node}/tasks?typefilter=vzdump, with a banner at the top
if the most recent run didn't succeed.

New "Proxmox backup failed" notification toggle under Settings ->
Notifications, on the same daily schedule as the other checks. The
scheduler checks each node's own most-recent vzdump run independently
(not just the single most recent task overall) so one node's healthy
backup can't mask another node's failing one in a multi-node cluster.

Known limitation, documented in the adapter's own header comment:
Proxmox's task list doesn't reliably expose which specific guest
failed within an "all guests" job — only the task's own log text has
that — so this surfaces job- and task-level status rather than
guessing at per-guest outcomes.

Verified against a fake Proxmox server (real self-signed HTTPS, since
the adapter's node:https usage can't be monkey-patched under ESM)
reproducing the documented /cluster/backup and task-list response
shapes: job parsing (all-guests+exclude vs specific-vmids+disabled)
correct, task OK/failure parsing correct, and the critical multi-node
scenario confirmed — one node's failing latest run flagged, the
other's healthy latest run correctly left alone, with exactly one
notification of the right content. This reproduces Proxmox's
documented API shape rather than a live-verified one; flag if the
real cluster's response differs in some way this didn't anticipate.
Confirmed the real dev database's mtime was untouched throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-22 18:42:22 +02:00
1 parent 6d673db9ec
commit 99db7e1cf0
11 files changed
+366 -6

No files matched your search

+28
View File
@@ -153,6 +153,7 @@ export interface NotificationEvents {
secretCheck: boolean;
tailscaleKeyCheck: boolean;
dockerUpdateCheck: boolean;
proxmoxBackupCheck: boolean;
secretCheckTime: string;
timezone: string;
integrationFailureAlerts: boolean;
@@ -509,6 +510,32 @@ export interface ProxmoxNodesResponse {
nodes: ProxmoxNodeStats[];
}
export interface ProxmoxBackupJob {
id: string;
enabled: boolean;
schedule: string;
storage: string;
node: string | null;
allGuests: boolean;
vmids: string | null;
exclude: string | null;
}
export interface ProxmoxBackupTask {
node: string;
upid: string;
guestId: string | null;
status: string;
ok: boolean;
startTime: string;
endTime: string | null;
}
export interface ProxmoxBackupsResponse {
jobs: ProxmoxBackupJob[];
tasks: ProxmoxBackupTask[];
}
export interface SynologyVolume {
id: string;
status: string;
@@ -826,6 +853,7 @@ export const api = {
proxmox: {
guests: (integrationId: number) => request<ProxmoxGuestsResponse>(`/api/integrations/${integrationId}/proxmox/guests`),
nodes: (integrationId: number) => request<ProxmoxNodesResponse>(`/api/integrations/${integrationId}/proxmox/nodes`),
backups: (integrationId: number) => request<ProxmoxBackupsResponse>(`/api/integrations/${integrationId}/proxmox/backups`),
start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }),
stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
+131
View File
@@ -7,6 +7,8 @@ import {
type ProxmoxGuest,
type ProxmoxGuestsResponse,
type ProxmoxNodeStats,
type ProxmoxBackupJob,
type ProxmoxBackupTask,
} from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
@@ -43,6 +45,29 @@ function formatBytes(bytes: number | null): string {
return `${value.toFixed(1)} ${units[unit]}`;
}
function describeGuests(job: ProxmoxBackupJob): string {
if (job.allGuests) return job.exclude ? `All guests (except ${job.exclude})` : "All guests";
return job.vmids ? `Guests ${job.vmids}` : "—";
}
function formatDuration(startIso: string, endIso: string | null): string {
if (!endIso) return "running…";
const seconds = Math.max(0, Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 1000));
const minutes = Math.floor(seconds / 60);
const remSeconds = seconds % 60;
return minutes > 0 ? `${minutes}m ${remSeconds}s` : `${remSeconds}s`;
}
function backupStatusBadge(task: ProxmoxBackupTask) {
if (task.ok) return <span className="badge bg-green-lt text-green">OK</span>;
if (task.status === "running") return <span className="badge bg-blue-lt text-blue">Running</span>;
return (
<span className="badge bg-red-lt text-red" title={task.status}>
Failed
</span>
);
}
function usageBar(used: number | null, total: number | null) {
if (!total) return null;
const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100));
@@ -68,6 +93,11 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
const [nodeStatsError, setNodeStatsError] = useState<string | null>(null);
const [loadingNodeStats, setLoadingNodeStats] = useState(false);
const [backupJobs, setBackupJobs] = useState<ProxmoxBackupJob[] | null>(null);
const [backupTasks, setBackupTasks] = useState<ProxmoxBackupTask[] | null>(null);
const [backupsError, setBackupsError] = useState<string | null>(null);
const [loadingBackups, setLoadingBackups] = useState(false);
useEffect(() => {
api.integrations
.list()
@@ -102,13 +132,29 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
.finally(() => setLoadingNodeStats(false));
}
function loadBackups(id: number) {
setLoadingBackups(true);
setBackupsError(null);
api.integrations.proxmox
.backups(id)
.then((res) => {
setBackupJobs(res.jobs);
setBackupTasks(res.tasks);
})
.catch((err) => setBackupsError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingBackups(false));
}
useEffect(() => {
if (selected?.enabled) {
loadGuests(selected.id);
loadNodeStats(selected.id);
loadBackups(selected.id);
} else {
setData(null);
setNodeStats(null);
setBackupJobs(null);
setBackupTasks(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
@@ -281,6 +327,91 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
)}
</div>
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">Backups</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadBackups(selected.id)}
disabled={loadingBackups}
>
{loadingBackups ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="card-body">
{backupsError && <div className="alert alert-danger mb-3">{backupsError}</div>}
{backupTasks && backupTasks.length > 0 && !backupTasks[0].ok && backupTasks[0].status !== "running" && (
<div className="alert alert-danger">
Most recent backup run on <strong>{backupTasks[0].node}</strong>
{backupTasks[0].guestId ? ` (guest ${backupTasks[0].guestId})` : ""} did not succeed:{" "}
{backupTasks[0].status}
</div>
)}
<div className="text-secondary small mb-1">Scheduled jobs</div>
{backupJobs && backupJobs.length > 0 ? (
<table className="table table-sm table-vcenter mb-4">
<thead>
<tr>
<th>Schedule</th>
<th>Storage</th>
<th>Guests</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{backupJobs.map((j) => (
<tr key={j.id}>
<td>{j.schedule || "—"}</td>
<td className="text-secondary">{j.storage}</td>
<td className="text-secondary">{describeGuests(j)}</td>
<td>
{j.enabled ? (
<span className="badge bg-green-lt text-green">Enabled</span>
) : (
<span className="badge bg-secondary-lt text-secondary">Disabled</span>
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
!backupsError && <div className="text-secondary small mb-4">No backup jobs configured in Proxmox.</div>
)}
<div className="text-secondary small mb-1">Recent runs</div>
{backupTasks && backupTasks.length > 0 ? (
<table className="table table-sm table-vcenter mb-0">
<thead>
<tr>
<th>Node</th>
<th>Guest</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{backupTasks.slice(0, 20).map((t) => (
<tr key={t.upid}>
<td className="text-secondary">{t.node}</td>
<td>{t.guestId ?? "—"}</td>
<td>{backupStatusBadge(t)}</td>
<td className="text-secondary">{new Date(t.startTime).toLocaleString()}</td>
<td className="text-secondary">{formatDuration(t.startTime, t.endTime)}</td>
</tr>
))}
</tbody>
</table>
) : (
!backupsError && <div className="text-secondary small mb-0">No recent backup runs found.</div>
)}
</div>
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">
@@ -43,6 +43,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
secretCheck: true,
tailscaleKeyCheck: true,
dockerUpdateCheck: true,
proxmoxBackupCheck: true,
secretCheckTime: "08:00",
timezone: "UTC",
integrationFailureAlerts: true,
@@ -493,6 +494,7 @@ export default function NotificationSettings() {
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
{ key: "dockerUpdateCheck" as const, label: "Docker image update available" },
{ key: "proxmoxBackupCheck" as const, label: "Proxmox backup failed" },
{ key: "integrationFailureAlerts" as const, label: "Integration/DNS provider failing repeatedly" },
].map(({ key, label }) => (
<label key={key} className="form-check mb-2">
@@ -526,7 +528,8 @@ export default function NotificationSettings() {
</div>
</div>
{(() => {
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck;
const dailyChecksEnabled =
notifications.secretCheck || notifications.tailscaleKeyCheck || notifications.dockerUpdateCheck || notifications.proxmoxBackupCheck;
return (
<div className="row g-2 mt-2">
<div className="col-6">
@@ -538,7 +541,7 @@ export default function NotificationSettings() {
disabled={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
<div className="form-hint">Shared by the secret, Tailscale key, and Docker update reminders above.</div>
<div className="form-hint">Shared by the secret, Tailscale key, Docker update, and Proxmox backup reminders above.</div>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>