import { useEffect, useRef, useState } from 'react' import { ArrowUpCircle, Activity, ScrollText, RefreshCw, TerminalSquare, HardDrive, ChevronDown, ChevronRight, Play } from 'lucide-react' import { PageShell } from '../components/PageShell' 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 (
{label} {pct}% · {formatBytes(stat.used)} / {formatBytes(stat.total)}
) } 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([]) const [deploys, setDeploys] = useState([]) const [health, setHealth] = useState([]) const [loading, setLoading] = useState(false) const [deploying, setDeploying] = useState>(new Set()) const [rebuilding, setRebuilding] = useState>(new Set()) const [shellContainer, setShellContainer] = useState('') const [shellCmd, setShellCmd] = useState('') 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) 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: }, { 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: }, ] return (
{/* Tab bar */}
{tabs.map(({ key, label, icon }) => ( ))}
{/* Updates tab */} {tab === 'updates' && (

App Updates

{statuses.length === 0 && !loading && (

No apps in deploy map yet.

)}
{statuses.map(s => (
{s.repo} {!s.deployed && ( not deployed )}
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit} {s.deployed && relativeTime(s.currentCommitAt) && ( · {relativeTime(s.currentCommitAt)} )} {s.latestCommit !== 'unknown' && ( latest: {s.latestCommit} {relativeTime(s.latestCommitAt) && ( · {relativeTime(s.latestCommitAt)} )} {s.commitsBehind != null && s.commitsBehind > 0 && ( {s.commitsBehind} commit{s.commitsBehind !== 1 ? 's' : ''} behind )} )}
{s.updateAvailable ? ( ) : s.deployed ? ( ✓ Up to date ) : null} {s.deployed && ( )}
))}
)} {/* States tab */} {tab === 'states' && (

Service States

{health.length === 0 && !loading && (

Checking services…

)}
{health.map(h => (
{h.name}
{h.up ? `${h.ms}ms` : 'unreachable'}
{(h.ram || h.disk) && (
{h.ram && } {h.disk && }
)} {h.up && !h.ram && !h.disk && (
resource stats unavailable
)}
))}
)} {/* Deploy log tab */} {tab === 'deploys' && (

Deploy History

{deploys.length === 0 && !loading && (

No deploys recorded yet.

)} {deploys.map((d, i) => { const isSuccess = d.status === 'success' const isFailed = d.status === 'failed' return (
{d.repo} {d.status}
{d.host} · {new Date(d.started).toLocaleString()} {d.finished && ` → ${new Date(d.finished).toLocaleString()}`}
{(d.output || d.error) && (
{d.output || d.error}
)}
) })}
)} {/* 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' && (
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', }} /> {shellOutput && ( )}
              {shellOutput || Select a container and run a command…}
            
)}
) }