Add live OS service health dashboard
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
111
src/components/status/ServiceHealthGrid.tsx
Normal file
111
src/components/status/ServiceHealthGrid.tsx
Normal 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
31
src/config/services.ts
Normal 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')
|
||||||
|
}
|
||||||
|
];
|
||||||
65
src/lib/fetchServiceHealth.ts
Normal file
65
src/lib/fetchServiceHealth.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user