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>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-29 20:32:52 +02:00
1 parent 70ba60c7da
commit df2a5ce42b
24 files changed
+897 -13

No files matched your search

+2
View File
@@ -20,6 +20,7 @@ import Gitea from "./pages/Gitea";
import Proxmox from "./pages/Proxmox";
import Synology from "./pages/Synology";
import UptimeKuma from "./pages/UptimeKuma";
import PbsBackup from "./pages/PbsBackup";
import Generator from "./pages/Generator";
import Maintenance from "./pages/Maintenance";
import Domains from "./pages/Domains";
@@ -105,6 +106,7 @@ export default function App() {
<Route path="/proxmox" element={<Proxmox user={user} />} />
<Route path="/synology" element={<Synology user={user} />} />
<Route path="/uptime-kuma" element={<UptimeKuma user={user} />} />
<Route path="/pbs" element={<PbsBackup user={user} />} />
<Route
path="/users"
element={
+43 -1
View File
@@ -182,6 +182,7 @@ export interface NotificationEvents {
tailscaleKeyCheck: boolean;
dockerUpdateCheck: boolean;
proxmoxBackupCheck: boolean;
pbsVerificationCheck: boolean;
healthAlerts: boolean;
automationAlerts: boolean;
domainExpiryCheck: boolean;
@@ -428,7 +429,7 @@ export interface ManualTaskInput {
enabled?: boolean;
}
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam";
export type IntegrationType = "proxmox" | "synology" | "semaphore" | "tailscale" | "gitea" | "dockhand" | "uptimekuma" | "phpipam" | "pbs";
export interface IntegrationField {
key: string;
@@ -635,6 +636,41 @@ export interface UptimeKumaImportResult {
monitorsInMaintenance: number;
}
export interface PbsFailedSnapshot {
backupType: string;
backupId: string;
backupTime: number;
}
export interface PbsDatastore {
name: string;
comment: string | null;
totalBytes: number | null;
usedBytes: number | null;
availBytes: number | null;
error: string | null;
snapshotCount: number;
failedCount: number;
unverifiedCount: number;
latestSnapshotAt: number | null;
recentFailures: PbsFailedSnapshot[];
}
export interface PbsDatastoresResponse {
datastores: PbsDatastore[];
summary: { datastoreCount: number; failedSnapshotCount: number; unverifiedSnapshotCount: number };
}
export interface PbsNodeStatus {
cpuUsagePercent: number | null;
cpuCores: number | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
rootfsTotalBytes: number | null;
rootfsUsedBytes: number | null;
uptime: number | null;
}
export type SemaphoreTaskStatus =
| "waiting"
| "starting"
@@ -1115,6 +1151,12 @@ export const api = {
monitors: (integrationId: number) =>
request<UptimeKumaMonitorsResponse>(`/api/integrations/${integrationId}/uptimekuma/monitors`),
},
pbs: {
datastores: (integrationId: number) =>
request<PbsDatastoresResponse>(`/api/integrations/${integrationId}/pbs/datastores`),
status: (integrationId: number) =>
request<PbsNodeStatus>(`/api/integrations/${integrationId}/pbs/status`),
},
semaphore: {
templates: (integrationId: number) =>
request<SemaphoreTemplatesResponse>(`/api/integrations/${integrationId}/semaphore/templates`),
+1
View File
@@ -19,6 +19,7 @@ const INTEGRATION_TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
};
const DNS_PROVIDER_LABELS: Record<DnsProviderType, string> = {
@@ -10,6 +10,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
};
export default function IntegrationEditForm({
+1
View File
@@ -10,6 +10,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
};
export default function IntegrationForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
+2
View File
@@ -28,6 +28,7 @@ import {
IconShieldLock,
IconRefresh,
IconActivityHeartbeat,
IconShieldCheck,
} from "@tabler/icons-react";
import { api, type CurrentUser, type MaintenanceWindow } from "../api/client";
import { formatRemaining } from "../utils/duration";
@@ -65,6 +66,7 @@ const NAV: NavEntry[] = [
{ to: "/servers", label: "Servers", icon: <IconServer2 size={20} /> },
{ to: "/proxmox", label: "Proxmox", icon: <IconServerCog size={20} /> },
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
{ to: "/pbs", label: "Proxmox Backup", icon: <IconShieldCheck size={20} /> },
{ to: "/docker", label: "Docker", icon: <IconBrandDocker size={20} /> },
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
],
+57 -2
View File
@@ -6,6 +6,7 @@ import {
type DomainList,
type IntegrationSummary,
type IntegrationType,
type PbsDatastoresResponse,
type SecretRecord,
type ServerSummary,
type TailscaleDevicesResponse,
@@ -202,6 +203,7 @@ const WIDGETS: { type: IntegrationType; label: string }[] = [
{ type: "gitea", label: "Gitea" },
{ type: "dockhand", label: "Dockhand / Docker" },
{ type: "uptimekuma", label: "Uptime Kuma" },
{ type: "pbs", label: "Proxmox Backup Server" },
];
interface UptimeKumaSummary {
@@ -212,6 +214,12 @@ interface UptimeKumaSummary {
statusBreakdown: { label: string; count: number }[];
}
interface PbsSummary {
datastoreCount: number;
failedSnapshotCount: number;
unverifiedSnapshotCount: number;
}
interface GiteaSummary {
repoCount: number;
privateCount: number;
@@ -277,6 +285,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
const [uptimeKumaSummary, setUptimeKumaSummary] = useState<UptimeKumaSummary | null>(null);
const [pbsSummary, setPbsSummary] = useState<PbsSummary | null>(null);
const [dnsStats, setDnsStats] = useState<Awaited<ReturnType<typeof api.dns.stats>> | null>(null);
const [dnsError, setDnsError] = useState<string | null>(null);
@@ -460,6 +469,24 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
.catch(() => setUptimeKumaSummary(null));
}, [integrations]);
useEffect(() => {
const pbs = integrations?.find((i) => i.type === "pbs" && i.enabled);
if (!pbs) {
setPbsSummary(null);
return;
}
api.integrations.pbs
.datastores(pbs.id)
.then((res: PbsDatastoresResponse) =>
setPbsSummary({
datastoreCount: res.summary.datastoreCount,
failedSnapshotCount: res.summary.failedSnapshotCount,
unverifiedSnapshotCount: res.summary.unverifiedSnapshotCount,
}),
)
.catch(() => setPbsSummary(null));
}, [integrations]);
const domains = domainList?.domains ?? [];
const expiredDomains = domains.filter((d) => d.status === "expired");
const expiringDomains = domains.filter((d) => d.status === "expiring");
@@ -764,7 +791,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<SectionHeader
title="Integrations"
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, and Uptime Kuma will appear here as each integration is connected."
hint="Live status widgets for Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand, Uptime Kuma, and Proxmox Backup Server will appear here as each integration is connected."
/>
<div className="row row-cards">
{WIDGETS.map(({ type, label }) => {
@@ -776,8 +803,16 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
const isLiveSynology = type === "synology" && integration && synologySummary;
const isLiveUptimeKuma = type === "uptimekuma" && integration && uptimeKumaSummary;
const isLivePbs = type === "pbs" && integration && pbsSummary;
const isLive =
isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology || isLiveUptimeKuma;
isLiveTailscale ||
isLiveGitea ||
isLiveDockhand ||
isLiveSemaphore ||
isLiveProxmox ||
isLiveSynology ||
isLiveUptimeKuma ||
isLivePbs;
return (
<WidgetCard
@@ -953,6 +988,26 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
<BreakdownBar data={uptimeKumaSummary!.statusBreakdown} colors={MONITOR_STATE_COLORS} compact />
)}
</>
) : isLivePbs ? (
<div className="row g-2 mb-3">
<div className="col-4">
<MiniStat label="Datastores" value={pbsSummary!.datastoreCount} />
</div>
<div className="col-4">
<MiniStat
label="Failed"
value={pbsSummary!.failedSnapshotCount}
accent={pbsSummary!.failedSnapshotCount > 0 ? "#ef4444" : undefined}
/>
</div>
<div className="col-4">
<MiniStat
label="Unverified"
value={pbsSummary!.unverifiedSnapshotCount}
accent={pbsSummary!.unverifiedSnapshotCount > 0 ? "#f59e0b" : undefined}
/>
</div>
</div>
) : (
<div className="text-secondary mt-2">
{integration ? (
+1
View File
@@ -16,6 +16,7 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
};
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
+1
View File
@@ -351,6 +351,7 @@ export default function Maintenance({ user }: { user: CurrentUser }) {
<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
+331
View File
@@ -0,0 +1,331 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
api,
type CurrentUser,
type IntegrationSummary,
type PbsDatastore,
type PbsDatastoresResponse,
type PbsNodeStatus,
} from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { downloadCsv } from "../utils/csv";
function formatBytes(bytes: number | null): string {
if (bytes === null) return "—";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
function formatDate(unixSeconds: number | null): string {
if (unixSeconds === null) return "—";
return new Date(unixSeconds * 1000).toLocaleString();
}
function formatUptime(seconds: number | null): string {
if (seconds === null) return "—";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (days || hours) parts.push(`${hours}h`);
parts.push(`${minutes}m`);
return parts.join(" ");
}
function verificationBadge(d: PbsDatastore) {
if (d.error) return <span className="badge bg-red-lt text-red">Unreadable</span>;
if (d.failedCount > 0) return <span className="badge bg-red-lt text-red">{d.failedCount} failed</span>;
if (d.unverifiedCount > 0) return <span className="badge bg-yellow-lt text-yellow">{d.unverifiedCount} unverified</span>;
return <span className="badge bg-green-lt text-green">All verified</span>;
}
export default function PbsBackup({ user: _user }: { user: CurrentUser }) {
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<PbsDatastoresResponse | null>(null);
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<PbsNodeStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [loadingStatus, setLoadingStatus] = useState(false);
useEffect(() => {
api.integrations
.list()
.then((res) => {
const pbsIntegrations = res.integrations.filter((i) => i.type === "pbs");
setIntegrations(pbsIntegrations);
if (!selectedId && pbsIntegrations.length > 0) setSelectedId(pbsIntegrations[0].id);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
function loadDatastores(id: number) {
setLoading(true);
setError(null);
api.integrations.pbs
.datastores(id)
.then((res) => setData(res))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
}
function loadStatus(id: number) {
setLoadingStatus(true);
setStatusError(null);
api.integrations.pbs
.status(id)
.then((res) => setStatus(res))
.catch((err) => setStatusError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingStatus(false));
}
useEffect(() => {
if (selected?.enabled) {
loadDatastores(selected.id);
loadStatus(selected.id);
} else {
setData(null);
setStatus(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
const { sorted: sortedDatastores, sortKey, sortDir, requestSort } = useSortable(data?.datastores);
function exportCsv() {
if (!sortedDatastores) return;
downloadCsv(
"pbs-datastores.csv",
["Datastore", "Used", "Total", "Snapshots", "Failed", "Unverified", "Latest snapshot"],
sortedDatastores.map((d) => [
d.name,
formatBytes(d.usedBytes),
formatBytes(d.totalBytes),
d.snapshotCount,
d.failedCount,
d.unverifiedCount,
formatDate(d.latestSnapshotAt),
]),
);
}
return (
<>
<h2 className="page-title mb-3">Proxmox Backup Server</h2>
{error && <div className="alert alert-danger">{error}</div>}
{integrations?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No Proxmox Backup Server integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
</div>
</div>
) : (
<>
{integrations && integrations.length > 1 && (
<div className="mb-3" style={{ maxWidth: 320 }}>
<select
className="form-select"
value={selectedId ?? ""}
onChange={(e) => setSelectedId(Number(e.target.value))}
>
{integrations.map((i) => (
<option key={i.id} value={i.id} disabled={!i.enabled}>
{i.name}
{!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
)}
{selected && !selected.enabled ? (
<div className="card">
<div className="card-body text-secondary">
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
datastores.
</div>
</div>
) : (
<div className="row g-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Node</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadStatus(selected.id)}
disabled={loadingStatus}
>
{loadingStatus ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="card-body">
{statusError ? (
<div className="text-danger">{statusError}</div>
) : (
<div className="row g-3">
<div className="col-6 col-md-3">
<div className="text-secondary">Uptime</div>
<div>{formatUptime(status?.uptime ?? null)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">CPU</div>
<div>
{status?.cpuUsagePercent !== null && status?.cpuUsagePercent !== undefined
? `${status.cpuUsagePercent.toFixed(0)}%`
: "—"}
{status?.cpuCores ? <span className="text-secondary"> ({status.cpuCores} cores)</span> : null}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Memory</div>
<div>
{formatBytes(status?.memUsedBytes ?? null)} / {formatBytes(status?.memTotalBytes ?? null)}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Root filesystem</div>
<div>
{formatBytes(status?.rootfsUsedBytes ?? null)} / {formatBytes(status?.rootfsTotalBytes ?? null)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Datastores</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={exportCsv}
disabled={!sortedDatastores || sortedDatastores.length === 0}
>
Export CSV
</button>
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadDatastores(selected.id)}
disabled={loading}
>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<PbsDatastore> label="Datastore" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PbsDatastore> label="Used" sortKeyName="usedBytes" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<PbsDatastore>
label="Snapshots"
sortKeyName="snapshotCount"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<PbsDatastore>
label="Latest snapshot"
sortKeyName="latestSnapshotAt"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<PbsDatastore>
label="Verification"
sortKeyName="failedCount"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
</tr>
</thead>
<tbody>
{sortedDatastores?.map((d) => (
<tr key={d.name}>
<td>
{d.name}
{d.comment && <div className="text-secondary small">{d.comment}</div>}
</td>
<td className="text-secondary">
{formatBytes(d.usedBytes)} / {formatBytes(d.totalBytes)}
</td>
<td className="text-secondary">{d.snapshotCount}</td>
<td className="text-secondary">{formatDate(d.latestSnapshotAt)}</td>
<td>{verificationBadge(d)}</td>
</tr>
))}
{sortedDatastores?.length === 0 && (
<tr>
<td colSpan={5} className="text-secondary text-center">
No datastores found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{sortedDatastores?.some((d) => d.recentFailures.length > 0) && (
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">Recent verification failures</h3>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Datastore</th>
<th>Backup</th>
<th>When</th>
</tr>
</thead>
<tbody>
{sortedDatastores
.flatMap((d) => d.recentFailures.map((f) => ({ datastore: d.name, ...f })))
.map((f, idx) => (
<tr key={`${f.datastore}-${f.backupType}-${f.backupId}-${f.backupTime}-${idx}`}>
<td>{f.datastore}</td>
<td className="text-secondary">
{f.backupType}/{f.backupId}
</td>
<td className="text-secondary">{formatDate(f.backupTime)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
)}
</>
)}
</>
);
}
+1
View File
@@ -19,6 +19,7 @@ const INTEGRATION_NAMES: Record<string, string> = {
dockhand: "Dockhand",
uptimekuma: "Uptime Kuma",
phpipam: "phpIPAM",
pbs: "Proxmox Backup Server",
};
function ColorList({
@@ -46,6 +46,7 @@ const DEFAULT_NOTIFICATIONS: NotificationEvents = {
tailscaleKeyCheck: true,
dockerUpdateCheck: true,
proxmoxBackupCheck: true,
pbsVerificationCheck: true,
healthAlerts: true,
automationAlerts: true,
domainExpiryCheck: true,
@@ -527,6 +528,7 @@ export default function NotificationSettings() {
{ 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 or a guest has no coverage" },
{ key: "pbsVerificationCheck" as const, label: "Proxmox Backup Server snapshot failed verification" },
{ key: "healthAlerts" as const, label: "Server offline, disk nearly full, or Synology volume/disk problem" },
{ key: "domainExpiryCheck" as const, label: "Domain registration expiring or expired (daily reminder)" },
{ key: "automationAlerts" as const, label: "Semaphore template or Gitea workflow run failed" },
@@ -618,6 +620,7 @@ export default function NotificationSettings() {
notifications.tailscaleKeyCheck ||
notifications.dockerUpdateCheck ||
notifications.proxmoxBackupCheck ||
notifications.pbsVerificationCheck ||
notifications.domainExpiryCheck;
return (
<div className="row g-2 mt-2">