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)} + /> + ) + )} + + ); +}