Add live OS service health dashboard

This commit is contained in:
Alexa Amundson
2025-11-21 14:15:59 -06:00
parent b0d5c85de7
commit bda0fb7bd6
4 changed files with 209 additions and 22 deletions

View File

@@ -1,15 +1,8 @@
import { LiveHealthCard } from '@/components/status/LiveHealthCard'; import { LiveHealthCard } from '@/components/status/LiveHealthCard';
import { ServiceHealthGrid } from '@/components/status/ServiceHealthGrid';
import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config'; import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config';
import { serviceConfig } from '@/config/serviceConfig'; import { serviceConfig } from '@/config/serviceConfig';
const serviceLinks = [
{ name: 'Core', url: 'https://core.blackroad.systems' },
{ name: 'API', url: 'https://api.blackroad.systems' },
{ name: 'Operator', url: 'https://operator.blackroad.systems' },
{ name: 'Web', url: 'https://blackroad.systems' },
{ name: 'Docs', url: 'https://docs.blackroad.systems' }
];
export default function Home() { export default function Home() {
const serviceHealth = getStaticServiceHealth(); const serviceHealth = getStaticServiceHealth();
@@ -71,20 +64,7 @@ export default function Home() {
</div> </div>
</div> </div>
<div className="card" style={{ gridColumn: 'span 2' }}> <ServiceHealthGrid />
<h3>Services</h3>
<p className="muted">Static references for connected BlackRoad OS surfaces.</p>
<ul>
{serviceLinks.map((svc) => (
<li key={svc.name}>
<a href={svc.url} target="_blank" rel="noreferrer">
{svc.name}
</a>{' '}
<span className="muted">{svc.url}</span>
</li>
))}
</ul>
</div>
<div className="card"> <div className="card">
<h3>Operator Queue</h3> <h3>Operator Queue</h3>

View File

@@ -0,0 +1,111 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { osServices } from '@/config/services';
import { fetchServiceHealth, ServiceHealthResult } from '@/lib/fetchServiceHealth';
function formatTimestamp(value?: string) {
if (!value) return '—';
const ts = new Date(value);
if (Number.isNaN(ts.getTime())) return value;
return ts.toLocaleString();
}
function resolveHealthUrl(id: string, healthUrl: string) {
if (id === 'prism-console') {
return '/api/health';
}
return healthUrl;
}
export function ServiceHealthGrid() {
const [results, setResults] = useState<Record<string, ServiceHealthResult>>({});
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const services = useMemo(() => osServices, []);
const refresh = useCallback(async () => {
setLoading(true);
const updates = await Promise.all(
services.map(async (service) => {
const healthUrl = resolveHealthUrl(service.id, service.healthUrl);
return fetchServiceHealth(healthUrl, service.id);
})
);
const merged: Record<string, ServiceHealthResult> = {};
updates.forEach((update) => {
merged[update.id] = update;
});
setResults(merged);
setLastUpdated(new Date().toISOString());
setLoading(false);
}, [services]);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 15000);
return () => clearInterval(interval);
}, [refresh]);
return (
<div className="card" style={{ gridColumn: 'span 2' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2>OS Services Health</h2>
<p className="muted">Live checks against each service health endpoint.</p>
</div>
<button className="button" onClick={refresh} disabled={loading}>
{loading ? 'Checking...' : 'Refresh'}
</button>
</div>
<p className="muted" style={{ marginTop: 8 }}>
Last updated: {lastUpdated ? formatTimestamp(lastUpdated) : '—'}
</p>
<div
className="grid"
style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 12
}}
>
{services.map((service) => {
const result = results[service.id];
const statusClass = result ? (result.status === 'up' ? 'status-ok' : 'status-bad') : 'badge';
return (
<div key={service.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3>{service.name}</h3>
<p className="muted">{resolveHealthUrl(service.id, service.healthUrl)}</p>
</div>
<span className={statusClass}>{result ? (result.status === 'up' ? 'UP' : 'DOWN') : '...'}</span>
</div>
<dl style={{ display: 'grid', gap: 4, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<dt className="muted">Last checked</dt>
<dd>{formatTimestamp(result?.lastCheckedAt)}</dd>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<dt className="muted">HTTP</dt>
<dd>{result?.rawStatusCode ?? '—'}</dd>
</div>
</dl>
{result?.errorMessage && <p className="status-bad">{result.errorMessage}</p>}
{result?.payload && (
<details style={{ marginTop: 8 }}>
<summary>Health payload</summary>
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(result.payload, null, 2)}</pre>
</details>
)}
{!result && loading && <p className="muted">Checking...</p>}
</div>
);
})}
</div>
</div>
);
}

31
src/config/services.ts Normal file
View File

@@ -0,0 +1,31 @@
const resolveHealthUrl = (envKey: string, fallback: string) => {
return process.env[`NEXT_PUBLIC_${envKey}`] || process.env[envKey] || fallback;
};
export const osServices = [
{
id: 'core',
name: 'Core Service',
healthUrl: resolveHealthUrl('CORE_HEALTH_URL', 'https://core.blackroad.systems/health')
},
{
id: 'operator',
name: 'Operator Service',
healthUrl: resolveHealthUrl('OPERATOR_HEALTH_URL', 'https://operator.blackroad.systems/health')
},
{
id: 'web',
name: 'Web Frontend',
healthUrl: resolveHealthUrl('WEB_HEALTH_URL', 'https://blackroad.systems/api/health')
},
{
id: 'prism-console',
name: 'Prism Console',
healthUrl: resolveHealthUrl('PRISM_CONSOLE_HEALTH_URL', 'https://console.blackroad.systems/api/health')
},
{
id: 'docs',
name: 'Docs',
healthUrl: resolveHealthUrl('DOCS_HEALTH_URL', 'https://docs.blackroad.systems/api/health')
}
];

View File

@@ -0,0 +1,65 @@
export type ServiceHealthResult = {
id: string;
ok: boolean;
status: 'up' | 'down';
rawStatusCode?: number;
lastCheckedAt: string;
errorMessage?: string;
payload?: unknown;
};
async function readPayload(response: Response) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
try {
return await response.json();
} catch (error) {
return { parseError: error instanceof Error ? error.message : 'Unable to parse JSON' };
}
}
try {
return await response.text();
} catch (error) {
return { parseError: error instanceof Error ? error.message : 'Unable to read response body' };
}
}
export async function fetchServiceHealth(
healthUrl: string,
id: string,
timeoutMs = 4000
): Promise<ServiceHealthResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(healthUrl, {
method: 'GET',
cache: 'no-store',
signal: controller.signal
});
const payload = await readPayload(response);
const ok = response.ok;
return {
id,
ok,
status: ok ? 'up' : 'down',
rawStatusCode: response.status,
lastCheckedAt: new Date().toISOString(),
payload,
errorMessage: ok ? undefined : `HTTP ${response.status}`
} satisfies ServiceHealthResult;
} catch (error) {
return {
id,
ok: false,
status: 'down',
lastCheckedAt: new Date().toISOString(),
errorMessage: error instanceof Error ? error.message : 'Unknown error'
} satisfies ServiceHealthResult;
} finally {
clearTimeout(timeout);
}
}