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 { 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 { Sidebar } from '../components/Sidebar'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
|
@ -68,8 +68,25 @@ interface DeployEntry {
|
||||||
error?: string
|
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 }) {
|
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 [statuses, setStatuses] = useState<AppStatus[]>([])
|
||||||
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
const [deploys, setDeploys] = useState<DeployEntry[]>([])
|
||||||
const [health, setHealth] = useState<HealthStatus[]>([])
|
const [health, setHealth] = useState<HealthStatus[]>([])
|
||||||
|
|
@ -80,6 +97,10 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
const [shellOutput, setShellOutput] = useState('')
|
const [shellOutput, setShellOutput] = useState('')
|
||||||
const [shellRunning, setShellRunning] = useState(false)
|
const [shellRunning, setShellRunning] = useState(false)
|
||||||
const termRef = useRef<HTMLPreElement>(null)
|
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) {
|
async function fetchStatus(force = false) {
|
||||||
setLoading(true)
|
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) {
|
async function triggerDeploy(repo: string) {
|
||||||
setDeploying(prev => new Set(prev).add(repo))
|
setDeploying(prev => new Set(prev).add(repo))
|
||||||
try {
|
try {
|
||||||
|
|
@ -162,6 +205,7 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}
|
}
|
||||||
if (tab === 'shell' && health.length === 0) fetchStates()
|
if (tab === 'shell' && health.length === 0) fetchStates()
|
||||||
|
if (tab === 'backups') fetchBackupRuns()
|
||||||
}, [tab])
|
}, [tab])
|
||||||
|
|
||||||
useEffect(() => {
|
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: '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: '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: '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} /> },
|
{ key: 'shell' as const, label: 'Shell', icon: <TerminalSquare size={14} strokeWidth={1.75} /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -398,6 +443,169 @@ export function AdminMonitor({ user }: { user: User }) {
|
||||||
</div>
|
</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 */}
|
{/* Shell tab */}
|
||||||
{tab === 'shell' && (
|
{tab === 'shell' && (
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||||
graphql_endpoint: 'GraphQL Endpoint', bearer_token: 'Bearer Token',
|
graphql_endpoint: 'GraphQL Endpoint', bearer_token: 'Bearer Token',
|
||||||
email: 'Email', sync_hours: 'Sync Interval (hours)', user: 'Username', pass: 'Password',
|
email: 'Email', sync_hours: 'Sync Interval (hours)', user: 'Username', pass: 'Password',
|
||||||
location_id: 'Default Location', from: 'From Address', reply_to: 'Reply-To Address',
|
location_id: 'Default Location', from: 'From Address', reply_to: 'Reply-To Address',
|
||||||
|
backup_path: 'Backup Directory',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppRow {
|
interface AppRow {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue