portal/src/pages/AdminMonitor.tsx
jtricerolph 64efa435c8 Replace Kuma iframe with live service health status tab
Uptime tab now polls /deploy/health-status (parallel HTTP checks) and
shows a status grid with response times. Removes broken Kuma sub-path
nginx locations. Auto-refreshes every 30s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 17:34:58 +00:00

281 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Sidebar } from '../components/Sidebar'
import type { User } from '../types'
interface AppStatus {
repo: string
host: string
currentCommit: string
latestCommit: string
updateAvailable: boolean
deployed: boolean
checkedAt: string
}
interface HealthStatus {
name: string
up: boolean
ms: number | null
}
interface DeployEntry {
repo: string
host: string
started: string
finished?: string
status: 'running' | 'success' | 'failed'
output?: string
error?: string
}
export function AdminMonitor({ user }: { user: User }) {
const [tab, setTab] = useState<'updates' | 'uptime' | 'deploys'>('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())
async function fetchStatus() {
setLoading(true)
try {
const res = await fetch('/deploy/status', { credentials: 'include' })
if (res.ok) setStatuses(await res.json())
} finally {
setLoading(false)
}
}
async function fetchHealth() {
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 triggerDeploy(repo: string) {
setDeploying(prev => new Set(prev).add(repo))
try {
await fetch(`/deploy/deploy/${repo}`, { method: 'POST', credentials: 'include' })
setTimeout(() => {
fetchStatus()
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 })
}
}
useEffect(() => {
if (tab === 'updates') fetchStatus()
if (tab === 'deploys') fetchDeploys()
if (tab === 'uptime') {
fetchHealth()
const id = setInterval(fetchHealth, 30_000)
return () => clearInterval(id)
}
}, [tab])
const updatesAvailable = statuses.filter(s => s.updateAvailable).length
return (
<div style={{ display: 'flex', height: '100dvh' }}>
<Sidebar user={user} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
display: 'flex', borderBottom: '1px solid var(--surface-2)',
background: 'var(--navy)', flexShrink: 0,
}}>
{(['updates', 'uptime', 'deploys'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
background: tab === t ? 'var(--surface)' : 'transparent',
border: 'none', borderBottom: tab === t ? '2px solid var(--gold)' : '2px solid transparent',
color: tab === t ? 'var(--gold)' : 'var(--text-muted)',
padding: '0.75rem 1.5rem', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer',
position: 'relative',
}}>
{t === 'updates' && '⬆ Updates'}
{t === 'updates' && updatesAvailable > 0 && (
<span style={{
position: 'absolute', top: '0.4rem', right: '0.4rem',
background: 'var(--gold)', color: 'var(--navy-dark)',
borderRadius: '50%', width: '16px', height: '16px',
fontSize: '0.65rem', fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{updatesAvailable}</span>
)}
{t === 'uptime' && '📡 Uptime'}
{t === 'deploys' && '📋 Deploy Log'}
</button>
))}
</div>
{tab === 'updates' && (
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>App Updates</h2>
<button onClick={fetchStatus} disabled={loading} style={{
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
}}>
{loading ? 'Checking…' : 'Refresh'}
</button>
</div>
{statuses.length === 0 && !loading && (
<p style={{ color: 'var(--text-muted)' }}>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(--surface)', border: '1px solid var(--surface-2)',
borderLeft: `3px solid ${s.updateAvailable ? 'var(--gold)' : s.deployed ? '#2d6a4f' : 'var(--surface-2)'}`,
borderRadius: '8px', padding: '0.85rem 1rem',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem',
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem', marginBottom: '0.25rem' }}>
{s.repo}
{!s.deployed && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
not deployed
</span>
)}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontFamily: 'monospace' }}>
{s.deployed ? `deployed: ${s.currentCommit}` : s.currentCommit}
{s.latestCommit !== 'unknown' && (
<span style={{ marginLeft: '0.75rem' }}>
latest: {s.latestCommit}
</span>
)}
</div>
</div>
<div style={{ flexShrink: 0 }}>
{s.updateAvailable ? (
<button
onClick={() => triggerDeploy(s.repo)}
disabled={deploying.has(s.repo)}
style={{
background: 'var(--gold)', color: 'var(--navy-dark)',
border: 'none', borderRadius: '6px',
padding: '0.4rem 1rem', fontSize: '0.8rem', fontWeight: 700,
cursor: deploying.has(s.repo) ? 'not-allowed' : 'pointer',
opacity: deploying.has(s.repo) ? 0.6 : 1,
}}
>
{deploying.has(s.repo) ? 'Updating…' : 'Update'}
</button>
) : s.deployed ? (
<span style={{ fontSize: '0.75rem', color: '#52b788' }}>✓ Up to date</span>
) : null}
</div>
</div>
))}
</div>
</div>
)}
{tab === 'uptime' && (
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>Service Health</h2>
<button onClick={fetchHealth} disabled={loading} style={{
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
}}>
{loading ? 'Checking…' : 'Refresh'}
</button>
</div>
{health.length === 0 && !loading && (
<p style={{ color: 'var(--text-muted)' }}>Checking services…</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{health.map(h => (
<div key={h.name} style={{
background: 'var(--surface)', border: '1px solid var(--surface-2)',
borderLeft: `3px solid ${h.up ? '#2d6a4f' : 'var(--danger, #ff4d4d)'}`,
borderRadius: '8px', padding: '0.75rem 1rem',
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 ? '#52b788' : 'var(--danger, #ff4d4d)',
boxShadow: h.up ? '0 0 6px #52b78866' : undefined,
}} />
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{h.name}</span>
</div>
<span style={{
fontSize: '0.75rem', fontFamily: 'monospace',
color: h.up ? '#52b788' : 'var(--text-muted)',
}}>
{h.up ? `${h.ms}ms` : 'unreachable'}
</span>
</div>
))}
</div>
</div>
)}
{tab === 'deploys' && (
<div style={{ flex: 1, overflowY: 'auto', padding: '1.5rem 2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600 }}>Deploy History</h2>
<button onClick={fetchDeploys} disabled={loading} style={{
background: 'var(--surface-2)', border: 'none', borderRadius: '6px',
color: 'var(--text-muted)', padding: '0.4rem 1rem', fontSize: '0.8rem', cursor: 'pointer',
}}>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
{deploys.length === 0 && !loading && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>No deploys recorded yet.</p>
)}
{deploys.map((d, i) => (
<div key={i} style={{
background: 'var(--surface)', border: '1px solid var(--surface-2)',
borderLeft: `3px solid ${d.status === 'success' ? '#2d6a4f' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)'}`,
borderRadius: '8px', padding: '1rem', marginBottom: '0.75rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.4rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{d.repo}</span>
<span style={{
fontSize: '0.75rem', fontWeight: 600, padding: '0.15rem 0.5rem', borderRadius: '4px',
background: d.status === 'success' ? '#2d6a4f33' : d.status === 'failed' ? '#ff4d4d22' : '#f5a62322',
color: d.status === 'success' ? '#52b788' : d.status === 'failed' ? 'var(--danger)' : 'var(--gold)',
}}>{d.status}</span>
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
{d.host} · {new Date(d.started).toLocaleString()}
{d.finished && ` → ${new Date(d.finished).toLocaleString()}`}
</div>
{(d.output || d.error) && (
<pre style={{
background: 'var(--navy-dark)', borderRadius: '4px', padding: '0.5rem',
fontSize: '0.7rem', color: 'var(--text-muted)', overflowX: 'auto',
margin: '0.5rem 0 0', maxHeight: '8rem', overflowY: 'auto',
}}>{d.output || d.error}</pre>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}