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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 11:03:52 +00:00
parent c40d7617f4
commit 3cfa092f73
2 changed files with 211 additions and 2 deletions

View file

@ -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<AppStatus[]>([])
const [deploys, setDeploys] = useState<DeployEntry[]>([])
const [health, setHealth] = useState<HealthStatus[]>([])
@ -80,6 +97,10 @@ export function AdminMonitor({ user }: { user: User }) {
const [shellOutput, setShellOutput] = useState('')
const [shellRunning, setShellRunning] = useState(false)
const termRef = useRef<HTMLPreElement>(null)
const [backupRuns, setBackupRuns] = useState<BackupRun[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [backupTriggering, setBackupTriggering] = useState(false)
const [expandedRun, setExpandedRun] = useState<string | null>(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: <ArrowUpCircle size={14} strokeWidth={1.75} /> },
{ key: 'states' as const, label: 'States', icon: <Activity size={14} strokeWidth={1.75} /> },
{ key: 'deploys' as const, label: 'Deploy Log', icon: <ScrollText size={14} strokeWidth={1.75} /> },
{ key: 'backups' as const, label: 'Backups', icon: <HardDrive size={14} strokeWidth={1.75} /> },
{ key: 'shell' as const, label: 'Shell', icon: <TerminalSquare size={14} strokeWidth={1.75} /> },
]
@ -398,6 +443,169 @@ export function AdminMonitor({ user }: { user: User }) {
</div>
)}
{/* Backups tab */}
{tab === 'backups' && (
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}>
<h2 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-dark)' }}>Backup Status</h2>
<div style={{ display: 'flex', gap: '0.6rem' }}>
<button onClick={fetchBackupRuns} disabled={backupLoading} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
color: 'var(--text-mid)', padding: '0.35rem 0.9rem', fontSize: '0.8rem',
display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: 'pointer',
}}>
<RefreshCw size={13} strokeWidth={1.75} />
{backupLoading ? 'Loading…' : 'Refresh'}
</button>
<button onClick={triggerBackup} disabled={backupTriggering} style={{
background: backupTriggering ? 'var(--card-bg)' : 'var(--gold)',
color: backupTriggering ? 'var(--text-mid)' : 'var(--navy)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.35rem 0.9rem', fontSize: '0.8rem', fontWeight: 600,
display: 'flex', alignItems: 'center', gap: '0.4rem', cursor: backupTriggering ? 'default' : 'pointer',
}}>
<Play size={13} strokeWidth={1.75} />
{backupTriggering ? 'Triggered…' : 'Run Now'}
</button>
</div>
</div>
{/* 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 (
<div style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderLeft: `3px solid ${statusColor}`,
borderRadius: 'var(--radius)', padding: '1rem 1.25rem', marginBottom: '1.25rem',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
boxShadow: 'var(--shadow-sm)',
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)', marginBottom: '0.2rem' }}>
Last backup <span style={{ color: statusColor, textTransform: 'capitalize' }}>{last.status}</span>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-mid)' }}>
{new Date(last.finished).toLocaleString()} · {durationStr}
{last.error && <span style={{ color: 'var(--danger)', marginLeft: '0.5rem' }}>{last.error}</span>}
</div>
</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)', textAlign: 'right' }}>
<div>{last.items.filter(i => i.status === 'ok').length}/{last.items.length} items OK</div>
<div style={{ fontSize: '0.7rem', marginTop: '0.1rem' }}>
{last.items.filter(i => i.status === 'error').length > 0 &&
<span style={{ color: 'var(--danger)' }}>
{last.items.filter(i => i.status === 'error').length} error(s)
</span>
}
</div>
</div>
</div>
)
})()}
{backupRuns.length === 0 && !backupLoading && (
<p style={{ color: 'var(--text-mid)', fontSize: '0.88rem' }}>
No backup runs recorded yet. Configure Nextcloud in Settings then click Run Now.
</p>
)}
{/* Run history */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{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 (
<div key={run.id} style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
borderLeft: `3px solid ${statusColor}`,
borderRadius: 'var(--radius)', overflow: 'hidden', boxShadow: 'var(--shadow-sm)',
}}>
<button
onClick={() => setExpandedRun(isExpanded ? null : run.id)}
style={{
width: '100%', background: 'transparent', border: 'none', cursor: 'pointer',
padding: '0.75rem 1rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{isExpanded
? <ChevronDown size={13} strokeWidth={1.75} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />
: <ChevronRight size={13} strokeWidth={1.75} style={{ color: 'var(--text-mid)', flexShrink: 0 }} />
}
<div style={{ textAlign: 'left' }}>
<div style={{ fontSize: '0.82rem', fontWeight: 600, color: 'var(--text-dark)' }}>
{new Date(run.started).toLocaleString()}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', marginTop: '0.1rem' }}>
{durationStr} · {run.items.filter(i => i.status === 'ok').length}/{run.items.length} OK
</div>
</div>
</div>
<span style={{
fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
background: run.status === 'success' ? '#dcfce7' : run.status === 'partial' ? '#fef9c3' : '#fee2e2',
color: statusColor, textTransform: 'capitalize',
}}>{run.status}</span>
</button>
{isExpanded && (
<div style={{ borderTop: '1px solid var(--card-border)', padding: '0.75rem 1rem' }}>
{run.items.map((item, idx) => (
<div key={idx} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.3rem 0', borderBottom: idx < run.items.length - 1 ? '1px solid var(--card-border)' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
fontSize: '0.65rem', fontWeight: 700, padding: '0.1rem 0.35rem', borderRadius: '3px',
background: item.type === 'db' ? '#e0f2fe' : '#f0fdf4',
color: item.type === 'db' ? '#0369a1' : '#15803d',
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>{item.type}</span>
<span style={{ fontSize: '0.82rem', color: 'var(--text-dark)', fontFamily: 'monospace' }}>
{item.name}
</span>
{item.error && (
<span style={{ fontSize: '0.72rem', color: 'var(--danger)' }}> {item.error}</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexShrink: 0 }}>
{item.size > 0 && (
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>
{item.size >= 1_048_576
? `${(item.size / 1_048_576).toFixed(1)} MB`
: `${Math.round(item.size / 1024)} KB`}
</span>
)}
<span style={{
fontSize: '0.72rem', fontWeight: 600,
color: item.status === 'ok' ? '#16a34a' : item.status === 'skipped' ? '#d97706' : 'var(--danger)',
}}>
{item.status === 'ok' ? '✓' : item.status === 'skipped' ? '—' : '✗'}
</span>
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* Shell tab */}
{tab === 'shell' && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}>

View file

@ -34,6 +34,7 @@ const FIELD_LABELS: Record<string, string> = {
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 {