/** * React UI for rendering devops info. * * Usage: * ```tsx * import { DevopsPanel } from '@bytelyst/devops/ui'; * * fetch('/api/devops/info').then(r => r.json())} /> * ``` * * Styling: uses plain HTML + inline styles (theme-aware via CSS variables). * No hard dependency on any UI framework — works in any React app. */ import * as React from 'react'; import type { DevopsInfo } from './types.js'; export type { DevopsInfo }; export interface DevopsPanelProps { /** Async loader for devops info — typically calls `/api/devops/info`. */ fetchInfo: () => Promise; /** Refresh interval in milliseconds. Default: 10_000 (10s). Set to 0 to disable. */ refreshIntervalMs?: number; /** Optional second source (e.g. web bundle's own info). */ fetchWebInfo?: () => Promise; } type Tab = 'build' | 'runtime' | 'config' | 'dependencies' | 'raw'; export function DevopsPanel(props: DevopsPanelProps): React.ReactElement { const { fetchInfo, fetchWebInfo, refreshIntervalMs = 10_000 } = props; const [info, setInfo] = React.useState(null); const [webInfo, setWebInfo] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); const [tab, setTab] = React.useState('build'); const [source, setSource] = React.useState<'backend' | 'web'>('backend'); const [lastRefreshed, setLastRefreshed] = React.useState(null); const load = React.useCallback(async () => { try { const [b, w] = await Promise.all([ fetchInfo().catch(e => { throw e; }), fetchWebInfo ? fetchWebInfo().catch(() => null) : Promise.resolve(null), ]); setInfo(b); setWebInfo(w); setError(null); setLastRefreshed(new Date()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } }, [fetchInfo, fetchWebInfo]); React.useEffect(() => { void load(); if (refreshIntervalMs > 0) { const h = setInterval(() => void load(), refreshIntervalMs); return () => clearInterval(h); } return undefined; }, [load, refreshIntervalMs]); const active = source === 'web' && webInfo ? webInfo : info; if (loading && !active) { return
Loading devops info…
; } if (error && !active) { return (
Error: {error}
); } if (!active) { return
No devops info available.
; } const tabs: Array<{ id: Tab; label: string; hidden?: boolean }> = [ { id: 'build', label: 'Build' }, { id: 'runtime', label: 'Runtime' }, { id: 'config', label: 'Config' }, { id: 'dependencies', label: 'Dependencies', hidden: !active.dependencies?.length }, { id: 'raw', label: 'Raw JSON' }, ]; return (
DevOps · {active.config.serviceName}
{active.config.productId} · v{active.config.serviceVersion} ·{' '} {active.build.commitSha ? ( {active.build.commitSha} ) : ( no commit info )}{' '} · uptime {active.runtime.uptimeHuman}
{webInfo ? (
{(['backend', 'web'] as const).map(s => ( ))}
) : null}
{lastRefreshed ? (
Last refreshed: {lastRefreshed.toLocaleTimeString()}
) : null} {error ? (
Stale data (refresh failed): {error}
) : null}
{tabs .filter(t => !t.hidden) .map(t => ( ))}
{tab === 'build' && } {tab === 'runtime' && } {tab === 'config' && } {tab === 'dependencies' && active.dependencies?.length ? ( ) : null} {tab === 'raw' &&
{JSON.stringify(active, null, 2)}
}
); } function buildRows(info: DevopsInfo): KvRow[] { const b = info.build; return [ { key: 'Commit', value: b.commitSha ? {b.commitShaFull || b.commitSha} : '—', }, { key: 'Branch', value: b.branch ?? '—' }, { key: 'Built at', value: b.builtAt ? formatTimestamp(b.builtAt) : '—' }, { key: 'Author', value: b.commitAuthor ?? '—' }, { key: 'Message', value: b.commitMessage ?? '—' }, { key: 'Docker image', value: b.dockerImage ? {b.dockerImage} : '—', }, ]; } function runtimeRows(info: DevopsInfo): KvRow[] { const r = info.runtime; return [ { key: 'Uptime', value: r.uptimeHuman }, { key: 'Started at', value: formatTimestamp(r.startedAt) }, { key: 'Node version', value: r.nodeVersion }, { key: 'Platform', value: `${r.platform} / ${r.arch}` }, { key: 'Hostname', value: {r.hostname} }, { key: 'PID', value: String(r.pid) }, { key: 'Memory (RSS)', value: `${r.memoryMb} MB` }, { key: 'Heap used', value: `${r.heapMb} MB` }, ]; } function configRows(info: DevopsInfo): KvRow[] { const c = info.config; const rows: KvRow[] = [ { key: 'Product', value: c.productId }, { key: 'Service', value: c.serviceName }, { key: 'Version', value: c.serviceVersion }, { key: 'NODE_ENV', value: c.nodeEnv }, { key: 'Env keys', value: c.envKeys.length ? {c.envKeys.join(', ')} : '—', }, ]; if (info.extra) { for (const [k, v] of Object.entries(info.extra)) { rows.push({ key: k, value: typeof v === 'object' ? {JSON.stringify(v)} : String(v), }); } } return rows; } interface KvRow { key: string; value: React.ReactNode; } function KvTable({ rows }: { rows: KvRow[] }): React.ReactElement { return (
{rows.map((r, i) => ( ))}
{r.key} {r.value}
); } function DependenciesTable({ deps, }: { deps: NonNullable; }): React.ReactElement { return (
{deps.map((d, i) => ( ))}
Name Status Latency Detail
{d.name} {d.ok ? 'OK' : 'FAIL'} {d.latencyMs != null ? `${d.latencyMs} ms` : '—'} {d.detail ?? '—'}
); } function formatTimestamp(iso: string): string { try { const d = new Date(iso); return `${d.toLocaleString()} (${iso})`; } catch { return iso; } } const styles: Record = { root: { display: 'flex', flexDirection: 'column', gap: 12, maxWidth: '100%', minWidth: 0 }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap', }, title: { fontSize: 16, fontWeight: 600, color: 'var(--foreground, inherit)' }, subtitle: { fontSize: 13, color: 'var(--muted-foreground, #6b7280)', marginTop: 4 }, muted: { fontSize: 12, color: 'var(--muted-foreground, #6b7280)' }, headerActions: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }, sourceToggle: { display: 'inline-flex', border: '1px solid var(--border, #e5e7eb)', borderRadius: 6, overflow: 'hidden', }, sourceBtn: { padding: '4px 10px', fontSize: 12, background: 'transparent', color: 'var(--muted-foreground, #6b7280)', border: 'none', cursor: 'pointer', }, sourceBtnActive: { background: 'var(--accent, #2563eb)', color: 'var(--accent-foreground, #fff)', }, refreshBtn: { padding: '4px 10px', fontSize: 12, background: 'transparent', border: '1px solid var(--border, #e5e7eb)', borderRadius: 6, cursor: 'pointer', color: 'var(--foreground, inherit)', }, tabs: { display: 'flex', gap: 2, borderBottom: '1px solid var(--border, #e5e7eb)', overflowX: 'auto', whiteSpace: 'nowrap', }, tab: { padding: '8px 14px', fontSize: 13, background: 'transparent', border: 'none', borderBottom: '2px solid transparent', cursor: 'pointer', color: 'var(--muted-foreground, #6b7280)', flexShrink: 0, }, tabActive: { color: 'var(--foreground, inherit)', borderBottomColor: 'var(--accent, #2563eb)', fontWeight: 500, }, panel: { paddingTop: 8, minWidth: 0 }, tableWrap: { width: '100%', overflowX: 'auto', overflowY: 'visible', WebkitOverflowScrolling: 'touch', }, table: { width: '100%', borderCollapse: 'collapse', fontSize: 13, tableLayout: 'fixed' }, tr: { borderBottom: '1px solid var(--border, #f3f4f6)' }, th: { textAlign: 'left', fontWeight: 500, color: 'var(--muted-foreground, #6b7280)', padding: '8px 12px 8px 0', verticalAlign: 'top', width: '30%', minWidth: 120, }, td: { padding: '8px 0', verticalAlign: 'top', color: 'var(--foreground, inherit)', overflowWrap: 'anywhere', wordBreak: 'break-word', }, code: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontSize: 12, background: 'color-mix(in oklab, var(--foreground, #000) 5%, transparent)', padding: '2px 6px', borderRadius: 4, wordBreak: 'break-all', }, pre: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontSize: 12, background: 'color-mix(in oklab, var(--foreground, #000) 5%, transparent)', padding: 12, borderRadius: 6, overflow: 'auto', maxHeight: 480, whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, badge: { display: 'inline-block', padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, }, badgeOk: { background: 'color-mix(in oklab, #10b981 20%, transparent)', color: '#10b981', }, badgeErr: { background: 'color-mix(in oklab, #ef4444 20%, transparent)', color: '#ef4444', }, msg: { padding: 16, fontSize: 13, color: 'var(--muted-foreground, #6b7280)', }, };