From 3cfa092f73b7925707a7b63100ab4627a300f988 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 14 Jul 2026 11:03:52 +0000 Subject: [PATCH] Add Backups tab to AdminMonitor, backup_path label to Settings Backups tab shows last-run status, expandable run history with per-item breakdown (db/vol, size, status), and a Run Now trigger button. Settings Nextcloud card gains a Backup Directory field. Co-Authored-By: Claude Sonnet 4.6 --- src/pages/AdminMonitor.tsx | 212 +++++++++++++++++++++++++++++++++++- src/pages/AdminSettings.tsx | 1 + 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/src/pages/AdminMonitor.tsx b/src/pages/AdminMonitor.tsx index 9c03619..b463374 100644 --- a/src/pages/AdminMonitor.tsx +++ b/src/pages/AdminMonitor.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare } from 'lucide-react' +import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare, HardDrive, ChevronDown, ChevronRight, Play } from 'lucide-react' import { Sidebar } from '../components/Sidebar' import type { User } from '../types' @@ -68,8 +68,25 @@ interface DeployEntry { error?: string } +interface BackupItem { + type: 'db' | 'vol' + name: string + status: 'ok' | 'error' | 'skipped' + size: number + error: string | null +} + +interface BackupRun { + id: string + started: string + finished: string + status: 'success' | 'partial' | 'error' + items: BackupItem[] + error: string | null +} + export function AdminMonitor({ user }: { user: User }) { - const [tab, setTab] = useState<'updates' | 'states' | 'deploys' | 'shell'>('updates') + const [tab, setTab] = useState<'updates' | 'states' | 'deploys' | 'shell' | 'backups'>('updates') const [statuses, setStatuses] = useState([]) const [deploys, setDeploys] = useState([]) const [health, setHealth] = useState([]) @@ -80,6 +97,10 @@ export function AdminMonitor({ user }: { user: User }) { const [shellOutput, setShellOutput] = useState('') const [shellRunning, setShellRunning] = useState(false) const termRef = useRef(null) + const [backupRuns, setBackupRuns] = useState([]) + const [backupLoading, setBackupLoading] = useState(false) + const [backupTriggering, setBackupTriggering] = useState(false) + const [expandedRun, setExpandedRun] = useState(null) async function fetchStatus(force = false) { setLoading(true) @@ -140,6 +161,28 @@ export function AdminMonitor({ user }: { user: User }) { } } + async function fetchBackupRuns() { + setBackupLoading(true) + try { + const res = await fetch('/deploy/backup/runs', { credentials: 'include' }) + if (res.ok) setBackupRuns(await res.json()) + } finally { + setBackupLoading(false) + } + } + + async function triggerBackup() { + setBackupTriggering(true) + try { + const res = await fetch('/deploy/backup/trigger', { method: 'POST', credentials: 'include' }) + if (res.ok) { + setTimeout(fetchBackupRuns, 3000) + } + } finally { + setTimeout(() => setBackupTriggering(false), 2000) + } + } + async function triggerDeploy(repo: string) { setDeploying(prev => new Set(prev).add(repo)) try { @@ -162,6 +205,7 @@ export function AdminMonitor({ user }: { user: User }) { return () => clearInterval(id) } if (tab === 'shell' && health.length === 0) fetchStates() + if (tab === 'backups') fetchBackupRuns() }, [tab]) useEffect(() => { @@ -174,6 +218,7 @@ export function AdminMonitor({ user }: { user: User }) { { key: 'updates' as const, label: 'Updates', icon: }, { key: 'states' as const, label: 'States', icon: }, { key: 'deploys' as const, label: 'Deploy Log', icon: }, + { key: 'backups' as const, label: 'Backups', icon: }, { key: 'shell' as const, label: 'Shell', icon: }, ] @@ -398,6 +443,169 @@ export function AdminMonitor({ user }: { user: User }) { )} + {/* Backups tab */} + {tab === 'backups' && ( +
+
+

Backup Status

+
+ + +
+
+ + {/* Summary card */} + {backupRuns.length > 0 && (() => { + const last = backupRuns[0] + const statusColor = last.status === 'success' ? '#16a34a' : last.status === 'partial' ? '#d97706' : 'var(--danger)' + const durationMs = new Date(last.finished).getTime() - new Date(last.started).getTime() + const durationStr = durationMs < 60_000 + ? `${Math.round(durationMs / 1000)}s` + : `${Math.floor(durationMs / 60_000)}m ${Math.round((durationMs % 60_000) / 1000)}s` + return ( +
+
+
+ Last backup — {last.status} +
+
+ {new Date(last.finished).toLocaleString()} · {durationStr} + {last.error && {last.error}} +
+
+
+
{last.items.filter(i => i.status === 'ok').length}/{last.items.length} items OK
+
+ {last.items.filter(i => i.status === 'error').length > 0 && + + {last.items.filter(i => i.status === 'error').length} error(s) + + } +
+
+
+ ) + })()} + + {backupRuns.length === 0 && !backupLoading && ( +

+ No backup runs recorded yet. Configure Nextcloud in Settings then click Run Now. +

+ )} + + {/* Run history */} +
+ {backupRuns.map(run => { + const isExpanded = expandedRun === run.id + const statusColor = run.status === 'success' ? '#16a34a' : run.status === 'partial' ? '#d97706' : 'var(--danger)' + const durationMs = new Date(run.finished).getTime() - new Date(run.started).getTime() + const durationStr = durationMs < 60_000 + ? `${Math.round(durationMs / 1000)}s` + : `${Math.floor(durationMs / 60_000)}m ${Math.round((durationMs % 60_000) / 1000)}s` + + return ( +
+ + + {isExpanded && ( +
+ {run.items.map((item, idx) => ( +
+
+ {item.type} + + {item.name} + + {item.error && ( + — {item.error} + )} +
+
+ {item.size > 0 && ( + + {item.size >= 1_048_576 + ? `${(item.size / 1_048_576).toFixed(1)} MB` + : `${Math.round(item.size / 1024)} KB`} + + )} + + {item.status === 'ok' ? '✓' : item.status === 'skipped' ? '—' : '✗'} + +
+
+ ))} +
+ )} +
+ ) + })} +
+
+ )} + {/* Shell tab */} {tab === 'shell' && (
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index b73056f..4cd980f 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -34,6 +34,7 @@ const FIELD_LABELS: Record = { graphql_endpoint: 'GraphQL Endpoint', bearer_token: 'Bearer Token', email: 'Email', sync_hours: 'Sync Interval (hours)', user: 'Username', pass: 'Password', location_id: 'Default Location', from: 'From Address', reply_to: 'Reply-To Address', + backup_path: 'Backup Directory', } interface AppRow {