diff --git a/frontend/src/components/BackupAlertsTable.tsx b/frontend/src/components/BackupAlertsTable.tsx
new file mode 100644
index 0000000..f0c1e5b
--- /dev/null
+++ b/frontend/src/components/BackupAlertsTable.tsx
@@ -0,0 +1,56 @@
+import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
+import { BackupAlert } from "../types/backups";
+
+interface Props {
+ alerts: BackupAlert[];
+ onAcknowledge: (alertId: string) => void;
+}
+
+function formatTimestamp(ts: number): string {
+ return new Date(ts * 1000).toLocaleString();
+}
+
+export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
+ return (
+
+
+
+
+ Severity
+ Type
+ Message
+ Created
+ Actions
+
+
+
+ {alerts.map((alert) => (
+
+
+
+
+ {alert.alert_type}
+ {alert.message}
+ {formatTimestamp(alert.created_at)}
+
+ {!alert.acknowledged && (
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/BackupDashboardWidget.tsx b/frontend/src/components/BackupDashboardWidget.tsx
new file mode 100644
index 0000000..5364b39
--- /dev/null
+++ b/frontend/src/components/BackupDashboardWidget.tsx
@@ -0,0 +1,52 @@
+import { Card, CardContent, Typography, Box, Chip } from "@mui/material";
+import { useBackupDashboard } from "../hooks/useBackups";
+
+export default function BackupDashboardWidget() {
+ const { data, isLoading } = useBackupDashboard();
+
+ if (isLoading || !data) {
+ return (
+
+
+ Backups
+ Loading...
+
+
+ );
+ }
+
+ return (
+
+
+ Backups
+
+
+ {data.total_jobs}
+ Jobs
+
+
+ {data.success_rate_24h}%
+ 24h Success
+
+
+
+ {data.active_alerts > 0 ? (
+
+ ) : (
+ 0
+ )}
+
+ Alerts
+
+ {data.last_failed_at && (
+
+
+ Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/BackupJobsTable.tsx b/frontend/src/components/BackupJobsTable.tsx
new file mode 100644
index 0000000..a2b00e2
--- /dev/null
+++ b/frontend/src/components/BackupJobsTable.tsx
@@ -0,0 +1,84 @@
+import {
+ Chip,
+ Paper,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from "@mui/material";
+import { BackupJob, BackupRun } from "../types/backups";
+
+interface Props {
+ jobs: BackupJob[];
+ latestRuns: Map;
+}
+
+function formatInterval(seconds: number | null): string {
+ if (!seconds) return "N/A";
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
+ return `${Math.floor(seconds / 86400)}d`;
+}
+
+function formatTimestamp(ts: number | null): string {
+ if (!ts) return "Never";
+ return new Date(ts * 1000).toLocaleString();
+}
+
+export default function BackupJobsTable({ jobs, latestRuns }: Props) {
+ return (
+
+
+
+
+ Name
+ Source
+ Target
+ Schedule
+ Last Status
+ Last Run
+ Next Expected
+
+
+
+ {jobs.map((job) => {
+ const run = latestRuns.get(job.id);
+ const status = run?.status ?? "unknown";
+ const nextExpected = run && job.schedule_interval_seconds
+ ? run.started_at + job.schedule_interval_seconds
+ : null;
+
+ return (
+
+ {job.name}
+ {job.source ?? "—"}
+ {job.target ?? "—"}
+ {formatInterval(job.schedule_interval_seconds)}
+
+
+
+ {formatTimestamp(run?.started_at ?? null)}
+ {formatTimestamp(nextExpected)}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/BackupRunsTable.tsx b/frontend/src/components/BackupRunsTable.tsx
new file mode 100644
index 0000000..3c9624d
--- /dev/null
+++ b/frontend/src/components/BackupRunsTable.tsx
@@ -0,0 +1,103 @@
+import {
+ Chip,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+} from "@mui/material";
+import { useState } from "react";
+import { BackupRun } from "../types/backups";
+
+interface Props {
+ runs: BackupRun[];
+}
+
+function formatBytes(bytes: number | null): string {
+ if (bytes === null || bytes === undefined) return "—";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
+
+function formatDuration(ms: number | null): string {
+ if (ms === null || ms === undefined) return "—";
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
+ return `${(ms / 3600_000).toFixed(1)}h`;
+}
+
+function formatTimestamp(ts: number): string {
+ return new Date(ts * 1000).toLocaleString();
+}
+
+export default function BackupRunsTable({ runs }: Props) {
+ const [statusFilter, setStatusFilter] = useState("all");
+
+ const filteredRuns = statusFilter === "all"
+ ? runs
+ : runs.filter((r) => r.status === statusFilter);
+
+ return (
+ <>
+
+ Status
+
+
+
+
+
+
+
+ Job
+ Status
+ Duration
+ Size
+ Started
+
+
+
+ {filteredRuns.map((run) => (
+
+ {run.job_id}
+
+
+
+ {formatDuration(run.duration_ms)}
+ {formatBytes(run.bytes_transferred)}
+ {formatTimestamp(run.started_at)}
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/BackupsPage.tsx b/frontend/src/components/BackupsPage.tsx
new file mode 100644
index 0000000..5c19abb
--- /dev/null
+++ b/frontend/src/components/BackupsPage.tsx
@@ -0,0 +1,69 @@
+import { Box, Tab, Tabs, Typography } from "@mui/material";
+import { useState } from "react";
+import {
+ useAcknowledgeAlert,
+ useBackupAlerts,
+ useBackupJobs,
+ useBackupRuns,
+} from "../hooks/useBackups";
+import BackupAlertsTable from "./BackupAlertsTable";
+import BackupJobsTable from "./BackupJobsTable";
+import BackupRunsTable from "./BackupRunsTable";
+
+export default function BackupsPage() {
+ const [tab, setTab] = useState(0);
+ const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
+ const { data: runsData, isLoading: runsLoading } = useBackupRuns();
+ const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false);
+ const acknowledgeMutation = useAcknowledgeAlert();
+
+ // Build a map of latest runs per job
+ const latestRuns = new Map();
+ if (runsData) {
+ for (const run of runsData) {
+ const existing = latestRuns.get(run.job_id);
+ if (!existing || run.started_at > existing.started_at) {
+ latestRuns.set(run.job_id, run);
+ }
+ }
+ }
+
+ return (
+
+ Backups
+
+ setTab(v)} sx={{ mb: 2 }}>
+
+
+
+
+
+ {tab === 0 && (
+ jobsLoading ? (
+ Loading jobs...
+ ) : (
+
+ )
+ )}
+
+ {tab === 1 && (
+ runsLoading ? (
+ Loading runs...
+ ) : (
+
+ )
+ )}
+
+ {tab === 2 && (
+ alertsLoading ? (
+ Loading alerts...
+ ) : (
+ acknowledgeMutation.mutate(id)}
+ />
+ )
+ )}
+
+ );
+}