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:
1 parent
70ba60c7da
commit
df2a5ce42b
24 files changed
+897
-13
No files matched your search
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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} /> },
|
||||
],
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user