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:
parent
c40d7617f4
commit
3cfa092f73
2 changed files with 211 additions and 2 deletions
|
|
@ -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' }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue