Displays a gold badge next to the latest commit hash when the backend returns a commitsBehind count greater than zero. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
723 lines
34 KiB
TypeScript
723 lines
34 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare, HardDrive, ChevronDown, ChevronRight, Play } from 'lucide-react'
|
|
import { Sidebar } from '../components/Sidebar'
|
|
import type { User } from '../types'
|
|
|
|
interface AppStatus {
|
|
repo: string
|
|
host: string
|
|
currentCommit: string
|
|
currentCommitAt: string | null
|
|
latestCommit: string
|
|
latestCommitAt: string | null
|
|
updateAvailable: boolean
|
|
commitsBehind: number | null
|
|
deployed: boolean
|
|
checkedAt: string
|
|
}
|
|
|
|
function relativeTime(iso: string | null | undefined): string | null {
|
|
if (!iso) return null
|
|
const diff = Date.now() - new Date(iso).getTime()
|
|
if (diff < 60_000) return 'just now'
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
|
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}d ago`
|
|
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' })
|
|
}
|
|
|
|
interface StatRange { total: number; used: number }
|
|
|
|
interface HealthStatus {
|
|
name: string
|
|
host: string
|
|
up: boolean
|
|
ms: number | null
|
|
ram: StatRange | null
|
|
disk: StatRange | null
|
|
}
|
|
|
|
function formatBytes(b: number): string {
|
|
if (b >= 1_073_741_824) return `${(b / 1_073_741_824).toFixed(1)} GB`
|
|
if (b >= 1_048_576) return `${(b / 1_048_576).toFixed(0)} MB`
|
|
return `${(b / 1024).toFixed(0)} KB`
|
|
}
|
|
|
|
function StatBar({ label, stat }: { label: string; stat: StatRange }) {
|
|
const pct = Math.min(100, Math.round((stat.used / stat.total) * 100))
|
|
const color = pct > 85 ? 'var(--danger)' : pct > 70 ? '#d97706' : '#16a34a'
|
|
return (
|
|
<div style={{ marginTop: '0.45rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.68rem', color: 'var(--text-mid)', marginBottom: '0.2rem' }}>
|
|
<span>{label}</span>
|
|
<span>{pct}% · {formatBytes(stat.used)} / {formatBytes(stat.total)}</span>
|
|
</div>
|
|
<div style={{ height: '3px', background: 'var(--card-border)', borderRadius: '2px' }}>
|
|
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: '2px', transition: 'width 0.3s' }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface DeployEntry {
|
|
repo: string
|
|
host: string
|
|
started: string
|
|
finished?: string
|
|
status: 'running' | 'success' | 'failed'
|
|
output?: 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 }) {
|
|
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[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [deploying, setDeploying] = useState<Set<string>>(new Set())
|
|
const [rebuilding, setRebuilding] = useState<Set<string>>(new Set())
|
|
const [shellContainer, setShellContainer] = useState('')
|
|
const [shellCmd, setShellCmd] = useState('')
|
|
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)
|
|
try {
|
|
const url = force ? '/deploy/status?force=true' : '/deploy/status'
|
|
const res = await fetch(url, { credentials: 'include' })
|
|
if (res.ok) setStatuses(await res.json())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function fetchStates() {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/deploy/health-status', { credentials: 'include' })
|
|
if (res.ok) setHealth(await res.json())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function fetchDeploys() {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/deploy/deploys', { credentials: 'include' })
|
|
if (res.ok) setDeploys(await res.json())
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function runShellCmd() {
|
|
const cmd = shellCmd.trim()
|
|
if (!shellContainer || !cmd || shellRunning) return
|
|
const [name, host] = shellContainer.split('|')
|
|
setShellOutput(prev => prev + `[${name} @ ${host}] $ ${cmd}\n`)
|
|
setShellRunning(true)
|
|
setShellCmd('')
|
|
try {
|
|
const res = await fetch('/deploy/exec', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ host, command: cmd }),
|
|
})
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
setShellOutput(prev => prev + `Error: ${data.error}\n\n`)
|
|
} else {
|
|
const out = [data.stdout, data.stderr].filter(Boolean).join('')
|
|
setShellOutput(prev => prev + (out.trim() || '(no output)') + '\n\n')
|
|
}
|
|
} catch (e: any) {
|
|
setShellOutput(prev => prev + `Request failed: ${e.message}\n\n`)
|
|
} finally {
|
|
setShellRunning(false)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
await fetch(`/deploy/deploy/${repo}`, { method: 'POST', credentials: 'include' })
|
|
setTimeout(() => {
|
|
fetchStatus(true)
|
|
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
|
}, 3000)
|
|
} catch {
|
|
setDeploying(prev => { const s = new Set(prev); s.delete(repo); return s })
|
|
}
|
|
}
|
|
|
|
async function triggerRebuild(repo: string) {
|
|
setRebuilding(prev => new Set(prev).add(repo))
|
|
try {
|
|
await fetch(`/deploy/rebuild/${repo}`, { method: 'POST', credentials: 'include' })
|
|
setTimeout(() => {
|
|
fetchStatus(true)
|
|
setRebuilding(prev => { const s = new Set(prev); s.delete(repo); return s })
|
|
}, 3000)
|
|
} catch {
|
|
setRebuilding(prev => { const s = new Set(prev); s.delete(repo); return s })
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (tab === 'updates') fetchStatus()
|
|
if (tab === 'deploys') fetchDeploys()
|
|
if (tab === 'states') {
|
|
fetchStates()
|
|
const id = setInterval(fetchStates, 30_000)
|
|
return () => clearInterval(id)
|
|
}
|
|
if (tab === 'shell' && health.length === 0) fetchStates()
|
|
if (tab === 'backups') fetchBackupRuns()
|
|
}, [tab])
|
|
|
|
useEffect(() => {
|
|
if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight
|
|
}, [shellOutput])
|
|
|
|
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
|
|
|
|
const tabs = [
|
|
{ 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} /> },
|
|
]
|
|
|
|
return (
|
|
<div style={{ display: 'flex', height: '100dvh' }}>
|
|
<Sidebar user={user} />
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
|
|
{/* Tab bar */}
|
|
<div style={{
|
|
display: 'flex', borderBottom: '1px solid var(--card-border)',
|
|
background: 'var(--card-bg)', flexShrink: 0, padding: '0 0.5rem',
|
|
}}>
|
|
{tabs.map(({ key, label, icon }) => (
|
|
<button key={key} onClick={() => setTab(key)} style={{
|
|
background: 'transparent', border: 'none',
|
|
borderBottom: tab === key ? '2px solid var(--gold)' : '2px solid transparent',
|
|
color: tab === key ? 'var(--text-dark)' : 'var(--text-mid)',
|
|
padding: '0.75rem 1.25rem', fontSize: '0.82rem', fontWeight: tab === key ? 600 : 400,
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem',
|
|
position: 'relative',
|
|
}}>
|
|
{icon}{label}
|
|
{key === 'updates' && updatesAvailable > 0 && (
|
|
<span style={{
|
|
background: 'var(--gold)', color: 'var(--navy)',
|
|
borderRadius: '50%', width: '16px', height: '16px',
|
|
fontSize: '0.6rem', fontWeight: 700,
|
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
marginLeft: '0.15rem',
|
|
}}>{updatesAvailable}</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Updates tab */}
|
|
{tab === 'updates' && (
|
|
<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)' }}>App Updates</h2>
|
|
<button onClick={() => fetchStatus(true)} disabled={loading} 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',
|
|
}}>
|
|
<RefreshCw size={13} strokeWidth={1.75} />
|
|
{loading ? 'Checking…' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
|
|
{statuses.length === 0 && !loading && (
|
|
<p style={{ color: 'var(--text-mid)' }}>No apps in deploy map yet.</p>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
|
{statuses.map(s => (
|
|
<div key={s.repo} style={{
|
|
background: 'var(--card-bg)',
|
|
border: '1px solid var(--card-border)',
|
|
borderLeft: `3px solid ${s.updateAvailable ? 'var(--gold)' : s.deployed ? '#16a34a' : 'var(--card-border)'}`,
|
|
borderRadius: 'var(--radius)', padding: '0.85rem 1rem',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
|
|
boxShadow: 'var(--shadow-sm)',
|
|
}}>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)', marginBottom: '0.2rem' }}>
|
|
{s.repo}
|
|
{!s.deployed && (
|
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: 'var(--text-mid)', fontWeight: 400 }}>
|
|
not deployed
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', fontFamily: 'monospace' }}>
|
|
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit}
|
|
{s.deployed && relativeTime(s.currentCommitAt) && (
|
|
<span style={{ marginLeft: '0.35rem', fontFamily: 'sans-serif', opacity: 0.7 }}>
|
|
· {relativeTime(s.currentCommitAt)}
|
|
</span>
|
|
)}
|
|
{s.latestCommit !== 'unknown' && (
|
|
<span style={{ marginLeft: '0.75rem' }}>
|
|
latest: {s.latestCommit}
|
|
{relativeTime(s.latestCommitAt) && (
|
|
<span style={{ marginLeft: '0.35rem', fontFamily: 'sans-serif', opacity: 0.7 }}>
|
|
· {relativeTime(s.latestCommitAt)}
|
|
</span>
|
|
)}
|
|
{s.commitsBehind != null && s.commitsBehind > 0 && (
|
|
<span style={{
|
|
marginLeft: '0.6rem', fontFamily: 'sans-serif',
|
|
background: 'var(--gold)', color: 'var(--navy)',
|
|
borderRadius: '3px', padding: '0.05rem 0.35rem',
|
|
fontSize: '0.65rem', fontWeight: 700,
|
|
}}>
|
|
{s.commitsBehind} commit{s.commitsBehind !== 1 ? 's' : ''} behind
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div style={{ flexShrink: 0, display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
{s.updateAvailable ? (
|
|
<button
|
|
onClick={() => triggerDeploy(s.repo)}
|
|
disabled={deploying.has(s.repo)}
|
|
style={{
|
|
background: 'var(--gold)', color: 'var(--navy)',
|
|
border: 'none', borderRadius: '6px',
|
|
padding: '0.4rem 1rem', fontSize: '0.8rem', fontWeight: 700,
|
|
opacity: deploying.has(s.repo) ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{deploying.has(s.repo) ? 'Updating…' : 'Update'}
|
|
</button>
|
|
) : s.deployed ? (
|
|
<span style={{ fontSize: '0.75rem', color: '#16a34a', fontWeight: 500 }}>✓ Up to date</span>
|
|
) : null}
|
|
{s.deployed && (
|
|
<button
|
|
onClick={() => triggerRebuild(s.repo)}
|
|
disabled={rebuilding.has(s.repo) || deploying.has(s.repo)}
|
|
style={{
|
|
background: 'transparent', color: 'var(--text-mid)',
|
|
border: '1px solid var(--card-border)', borderRadius: '6px',
|
|
padding: '0.4rem 0.75rem', fontSize: '0.8rem', fontWeight: 500,
|
|
opacity: (rebuilding.has(s.repo) || deploying.has(s.repo)) ? 0.5 : 1,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{rebuilding.has(s.repo) ? 'Rebuilding…' : 'Rebuild'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* States tab */}
|
|
{tab === 'states' && (
|
|
<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)' }}>Service States</h2>
|
|
<button onClick={fetchStates} disabled={loading} 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',
|
|
}}>
|
|
<RefreshCw size={13} strokeWidth={1.75} />
|
|
{loading ? 'Checking…' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
{health.length === 0 && !loading && (
|
|
<p style={{ color: 'var(--text-mid)' }}>Checking services…</p>
|
|
)}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
|
{health.map(h => (
|
|
<div key={h.name} style={{
|
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
|
borderLeft: `3px solid ${h.up ? '#16a34a' : 'var(--danger)'}`,
|
|
borderRadius: 'var(--radius)', padding: '0.85rem 1rem',
|
|
boxShadow: 'var(--shadow-sm)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
|
<span style={{
|
|
width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0,
|
|
background: h.up ? '#16a34a' : 'var(--danger)',
|
|
boxShadow: h.up ? '0 0 5px #16a34a66' : undefined,
|
|
}} />
|
|
<span style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)' }}>{h.name}</span>
|
|
</div>
|
|
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: h.up ? '#16a34a' : 'var(--text-mid)' }}>
|
|
{h.up ? `${h.ms}ms` : 'unreachable'}
|
|
</span>
|
|
</div>
|
|
{(h.ram || h.disk) && (
|
|
<div style={{ marginTop: '0.5rem' }}>
|
|
{h.ram && <StatBar label="RAM" stat={h.ram} />}
|
|
{h.disk && <StatBar label="Disk" stat={h.disk} />}
|
|
</div>
|
|
)}
|
|
{h.up && !h.ram && !h.disk && (
|
|
<div style={{ marginTop: '0.4rem', fontSize: '0.68rem', color: 'var(--text-mid)' }}>
|
|
resource stats unavailable
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deploy log tab */}
|
|
{tab === 'deploys' && (
|
|
<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)' }}>Deploy History</h2>
|
|
<button onClick={fetchDeploys} disabled={loading} 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',
|
|
}}>
|
|
<RefreshCw size={13} strokeWidth={1.75} />
|
|
{loading ? 'Loading…' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
{deploys.length === 0 && !loading && (
|
|
<p style={{ color: 'var(--text-mid)', fontSize: '0.9rem' }}>No deploys recorded yet.</p>
|
|
)}
|
|
{deploys.map((d, i) => {
|
|
const isSuccess = d.status === 'success'
|
|
const isFailed = d.status === 'failed'
|
|
return (
|
|
<div key={i} style={{
|
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
|
borderLeft: `3px solid ${isSuccess ? '#16a34a' : isFailed ? 'var(--danger)' : '#d97706'}`,
|
|
borderRadius: 'var(--radius)', padding: '1rem', marginBottom: '0.6rem',
|
|
boxShadow: 'var(--shadow-sm)',
|
|
}}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
|
|
<span style={{ fontWeight: 600, fontSize: '0.88rem', color: 'var(--text-dark)' }}>{d.repo}</span>
|
|
<span style={{
|
|
fontSize: '0.72rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
|
|
background: isSuccess ? '#dcfce7' : isFailed ? '#fee2e2' : '#fef9c3',
|
|
color: isSuccess ? '#16a34a' : isFailed ? 'var(--danger)' : '#ca8a04',
|
|
}}>{d.status}</span>
|
|
</div>
|
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.35rem' }}>
|
|
{d.host} · {new Date(d.started).toLocaleString()}
|
|
{d.finished && ` → ${new Date(d.finished).toLocaleString()}`}
|
|
</div>
|
|
{(d.output || d.error) && (
|
|
<pre style={{
|
|
background: '#1e293b', borderRadius: '6px', padding: '0.6rem 0.75rem',
|
|
fontSize: '0.7rem', color: '#94a3b8', overflowX: 'auto',
|
|
margin: '0.5rem 0 0', maxHeight: '8rem', overflowY: 'auto',
|
|
}}>{d.output || d.error}</pre>
|
|
)}
|
|
</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 */}
|
|
{tab === 'shell' && (
|
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', padding: '1.5rem 2rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem', flexShrink: 0 }}>
|
|
<select
|
|
value={shellContainer}
|
|
onChange={e => setShellContainer(e.target.value)}
|
|
style={{
|
|
background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
|
borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.82rem',
|
|
color: 'var(--text-dark)', minWidth: '160px',
|
|
}}
|
|
>
|
|
<option value="">Select container</option>
|
|
{health.map(h => (
|
|
<option key={h.name} value={`${h.name}|${h.host}`}>{h.name}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
value={shellCmd}
|
|
onChange={e => setShellCmd(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && runShellCmd()}
|
|
placeholder="e.g. docker compose logs --tail 50"
|
|
disabled={shellRunning}
|
|
style={{
|
|
flex: 1, background: 'var(--card-bg)', border: '1px solid var(--card-border)',
|
|
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.82rem',
|
|
color: 'var(--text-dark)', fontFamily: 'monospace',
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={runShellCmd}
|
|
disabled={!shellContainer || !shellCmd.trim() || shellRunning}
|
|
style={{
|
|
background: 'var(--gold)', color: 'var(--navy)', border: 'none',
|
|
borderRadius: '6px', padding: '0.4rem 1rem', fontSize: '0.82rem', fontWeight: 700,
|
|
opacity: (!shellContainer || !shellCmd.trim() || shellRunning) ? 0.5 : 1,
|
|
cursor: (!shellContainer || !shellCmd.trim() || shellRunning) ? 'default' : 'pointer',
|
|
}}
|
|
>
|
|
{shellRunning ? 'Running…' : 'Run'}
|
|
</button>
|
|
{shellOutput && (
|
|
<button
|
|
onClick={() => setShellOutput('')}
|
|
style={{
|
|
background: 'transparent', border: '1px solid var(--card-border)',
|
|
borderRadius: '6px', padding: '0.4rem 0.75rem', fontSize: '0.8rem',
|
|
color: 'var(--text-mid)', cursor: 'pointer',
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre
|
|
ref={termRef}
|
|
style={{
|
|
flex: 1, background: '#0f172a', borderRadius: '8px',
|
|
padding: '1rem', fontSize: '0.75rem', color: '#94a3b8',
|
|
overflowY: 'auto', margin: 0, fontFamily: 'monospace',
|
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
|
|
border: '1px solid #1e293b',
|
|
}}
|
|
>
|
|
{shellOutput || <span style={{ color: '#475569' }}>Select a container and run a command…</span>}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|