portal/src/pages/AdminMonitor.tsx
2026-07-21 09:37:56 +00:00

722 lines
34 KiB
TypeScript

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 (
<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 (
<PageShell user={user} padding="2rem">
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: '900px' }}>
{/* 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>
</PageShell>
)
}