Merge pull request #4 from BlackRoad-OS/codex/implement-health-and-version-api-routes

This commit is contained in:
Alexa Amundson
2025-11-20 12:40:31 -06:00
committed by GitHub
18 changed files with 519 additions and 197 deletions

65
.github/workflows/console-deploy.yaml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Console Deploy
on:
push:
branches: [dev, staging, main]
env:
NODE_VERSION: '20'
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Select Railway environment
id: env
run: |
BRANCH=${GITHUB_REF##*/}
if [ "$BRANCH" = "main" ]; then
echo "railway_env=prod" >> $GITHUB_OUTPUT
echo "health_url=https://console.blackroad.systems/api/health" >> $GITHUB_OUTPUT
elif [ "$BRANCH" = "staging" ]; then
echo "railway_env=staging" >> $GITHUB_OUTPUT
echo "health_url=https://staging.console.blackroad.systems/api/health" >> $GITHUB_OUTPUT
else
echo "railway_env=dev" >> $GITHUB_OUTPUT
if [ -n "${DEV_CONSOLE_URL:-}" ]; then
echo "health_url=${DEV_CONSOLE_URL%/}/api/health" >> $GITHUB_OUTPUT
else
echo "health_url=" >> $GITHUB_OUTPUT
fi
fi
- name: Deploy to Railway
uses: railwayapp/railway-action@v2
with:
railwayToken: ${{ secrets.RAILWAY_TOKEN }}
service: prism-console-web
environment: ${{ steps.env.outputs.railway_env }}
- name: Health check
run: |
if [ -z "${{ steps.env.outputs.health_url }}" ]; then
echo "No health URL configured for this branch; skipping health check."
exit 0
fi
echo "Checking ${{ steps.env.outputs.health_url }}"
curl --fail --location "${{ steps.env.outputs.health_url }}"

View File

@@ -1,60 +0,0 @@
name: Deploy Prism Console
on:
push:
branches: [dev, staging, main]
jobs:
deploy:
runs-on: ubuntu-latest
env:
NODE_VERSION: '20'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Select Railway environment
run: |
BRANCH=${GITHUB_REF##*/}
if [ "$BRANCH" = "main" ]; then
echo "RAILWAY_ENV=prod" >> $GITHUB_ENV
echo "CONSOLE_URL=https://console.blackroad.systems" >> $GITHUB_ENV
elif [ "$BRANCH" = "staging" ]; then
echo "RAILWAY_ENV=staging" >> $GITHUB_ENV
echo "CONSOLE_URL=https://staging.console.blackroad.systems" >> $GITHUB_ENV
else
echo "RAILWAY_ENV=dev" >> $GITHUB_ENV
echo "CONSOLE_URL=${DEV_CONSOLE_URL:-""}" >> $GITHUB_ENV
echo "SKIP_HEALTH_CHECK=$([ -z "${DEV_CONSOLE_URL:-}" ] && echo true || echo false)" >> $GITHUB_ENV
fi
- name: Deploy to Railway
uses: railwayapp/railway-action@v2
with:
railwayToken: ${{ secrets.RAILWAY_TOKEN }}
service: prism-console-web
environment: ${{ env.RAILWAY_ENV }}
- name: Health check
run: |
if [ "${SKIP_HEALTH_CHECK:-false}" = "true" ]; then
echo "Skipping health check because CONSOLE_URL is not configured for dev deployments"
exit 0
fi
if [ -z "$CONSOLE_URL" ]; then
echo "CONSOLE_URL is not set for this environment" >&2
exit 1
fi
curl --fail --location "$CONSOLE_URL/health"

View File

@@ -1,75 +1,47 @@
# BlackRoad OS — Prism Console # BlackRoad OS — Prism Console
## Short Description Operator console UI for BlackRoad OS built with Next.js (App Router + TypeScript).
Admin console for deployments, observability, environments, and system control. ## Running locally
## Long Description 1. Install dependencies: `npm ci`
2. Set environment variables (see below). You can use a `.env.local` file for local development.
3. Start the dev server: `npm run dev`
4. Visit `http://localhost:3000` to load the console.
Prism Console is the command center of BlackRoad OS. It exposes environment controls, deployment dashboards, worker health, agent supervision, configuration management, secrets overview (abstracted), and unified observability tools. It serves founders, engineers, and operators. ## Key routes
## Structured Table - `/` — Overview of configured upstream endpoints.
- `/status` — Live health table that polls each configured `/health` endpoint.
- `/health` — UI that consumes the `/api/health` JSON endpoint and shows service badges.
- `/agents` — Lists agents via `AGENTS_API_URL/agents` and triggers `AGENTS_API_URL/agents/run` through internal API proxies.
- `/api/health` and `/api/version` — Machine-friendly health and version metadata.
| Field | Value | ## Configuration
Environment variables are centralized in `src/lib/config.ts`. Server-side values stay private; `NEXT_PUBLIC_*` values are exposed to the browser.
| Variable | Purpose |
| --- | --- | | --- | --- |
| **Purpose** | Admin, ops, observability, environments | | `NODE_ENV` | Runtime environment selector (`development` \| `staging` \| `production`). |
| **Depends On** | API Gateway, Operator Engine | | `CORE_API_URL` | Server-side Core API base URL used for backend calls. |
| **Used By** | Founders, devs, ops | | `AGENTS_API_URL` | Server-side Agents API base URL for listing/running agents. |
| **Owner** | Alexa + Cece (Prism Operations Group) | | `PUBLIC_CONSOLE_URL` | Public URL for this console; also used for the operator badge. |
| **Status** | Active — mission critical | | `NEXT_PUBLIC_CORE_API_URL` | Browser-safe Core API base URL (if direct browser calls are allowed). |
| `NEXT_PUBLIC_AGENTS_API_URL` | Browser-safe Agents API base URL (if direct browser calls are allowed). |
| `SKIP_ENV_VALIDATION` | Optional; set to `true` in dev to bypass strict env checks. |
## Roadmap Board (Prism) > Keep sensitive URLs in server-side vars (`CORE_API_URL`, `AGENTS_API_URL`). Only expose endpoints in `NEXT_PUBLIC_*` if they are safe for the browser.
Columns: ## Wiring to backend services
- Backlog - Health polling: `/api/health` calls each configured service and appends `/health` to the base URL. `/status` renders the live results.
- UI Wireframing - Version metadata: `/api/version` (and `/version`) returns the build version, environment, and timestamp.
- Integration Layer - Agents: `/api/agents` proxies `GET {AGENTS_API_URL}/agents`; `/api/agents/run` proxies `POST {AGENTS_API_URL}/agents/run`. The `/agents` page consumes these endpoints.
- Telemetry
- Review
- Prod Ready
Sample tasks:
- Agent lifecycle dashboard
- Deployment monitor
- Worker queue timeline
- Error heatmaps
- Rail & Cloudflare integrated view
## Deployment ## Deployment
Prism Console deploys to the Railway project **`blackroad-prism-console`** as the service **`prism-console-web`**. It fronts environments published via Cloudflare: - Railway service: `prism-console-web` defined in `railway.json` (build `npm run build`, start `npm run start`, health check `/api/health`).
- GitHub Actions: `.github/workflows/console-deploy.yaml` installs dependencies, runs lint+build, deploys to the correct Railway environment (`dev`/`staging`/`prod`), and performs a post-deploy health check.
- **Production:** `https://console.blackroad.systems` Production and staging URLs remain `https://console.blackroad.systems` and `https://staging.console.blackroad.systems`; dev uses the Railway-provided URL (or `DEV_CONSOLE_URL` in CI for health checks).
- **Staging:** `https://staging.console.blackroad.systems`
- **Dev:** Railway-provided dev URL (optionally `https://dev.console.blackroad.systems`)
### Runtime commands
- Install: `npm ci`
- Build: `npm run build`
- Start: `npm run start`
### Environment variables
| Variable | Purpose | Prod | Staging | Dev |
| --- | --- | --- | --- | --- |
| `NODE_ENV` | Runtime environment | `production` | `staging` | `development` |
| `CORE_API_URL` | Core backend base URL | `https://core.blackroad.systems` | `https://staging.core.blackroad.systems` | Core dev Railway URL |
| `AGENTS_API_URL` | Agents API base URL | `https://agents.blackroad.systems` | `https://staging.agents.blackroad.systems` | Agents dev Railway URL |
| `PUBLIC_CONSOLE_URL` | Console base URL | `https://console.blackroad.systems` | `https://staging.console.blackroad.systems` | Dev console URL |
| `NEXT_PUBLIC_CORE_API_URL` | Browser-safe core URL | `https://core.blackroad.systems` | `https://staging.core.blackroad.systems` | Core dev Railway URL |
| `NEXT_PUBLIC_AGENTS_API_URL` | Browser-safe agents URL | `https://agents.blackroad.systems` | `https://staging.agents.blackroad.systems` | Agents dev Railway URL |
| `NEXT_PUBLIC_CONSOLE_URL` | Browser-safe console URL | `https://console.blackroad.systems` | `https://staging.console.blackroad.systems` | Dev console URL |
| `DATABASE_URL` | (Optional) Metrics DB connection string | as provisioned | as provisioned | as provisioned |
| `SKIP_ENV_VALIDATION` | (Optional) Set to `true` to bypass env enforcement during local builds | optional | optional | `true` (in build script) |
### Health and status
- `GET /health` — lightweight liveness JSON suitable for Railway checks.
- `GET /status` — static status board that surfaces configured backend URLs; ready to wire to real health endpoints later.
### Railway definition
`railway.json` declares the project + service so `railway up` deploys with the right commands and port. Set `RAILWAY_TOKEN` in CI/CD and provide the above env vars per environment before deploying.

View File

@@ -8,15 +8,14 @@
"port": 3000, "port": 3000,
"buildCommand": "npm run build", "buildCommand": "npm run build",
"startCommand": "npm run start", "startCommand": "npm run start",
"healthcheckPath": "/api/health",
"envVariables": { "envVariables": {
"NODE_ENV": "production", "NODE_ENV": "production",
"CORE_API_URL": "", "CORE_API_URL": "",
"AGENTS_API_URL": "", "AGENTS_API_URL": "",
"PUBLIC_CONSOLE_URL": "", "PUBLIC_CONSOLE_URL": "",
"NEXT_PUBLIC_CORE_API_URL": "", "NEXT_PUBLIC_CORE_API_URL": "",
"NEXT_PUBLIC_AGENTS_API_URL": "", "NEXT_PUBLIC_AGENTS_API_URL": ""
"NEXT_PUBLIC_CONSOLE_URL": "",
"DATABASE_URL": ""
} }
} }
] ]

109
src/app/agents/page.tsx Normal file
View File

@@ -0,0 +1,109 @@
'use client';
import { useEffect, useState } from 'react';
type Agent = {
id?: string;
name?: string;
description?: string;
status?: string;
};
type AgentsResponse = {
agents: Agent[];
error?: string;
};
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadAgents = async () => {
setLoading(true);
try {
const response = await fetch('/api/agents', { cache: 'no-store' });
const payload = (await response.json()) as AgentsResponse;
if (!response.ok) {
throw new Error(payload.error || 'Unable to load agents');
}
setAgents(payload.agents || []);
setError(null);
} catch (err) {
setAgents([]);
setError(err instanceof Error ? err.message : 'Unable to load agents');
} finally {
setLoading(false);
}
};
const runAgent = async (agent: Agent) => {
try {
setMessage(null);
const response = await fetch('/api/agents/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ agentId: agent.id || agent.name })
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload?.error || 'Agent run failed');
}
setMessage(`Triggered ${agent.name || agent.id || 'agent'}`);
} catch (err) {
setMessage(null);
setError(err instanceof Error ? err.message : 'Unable to trigger agent');
}
};
useEffect(() => {
loadAgents();
}, []);
return (
<div className="grid">
<div className="card">
<h1>Agents</h1>
<p className="muted">Lists agents from the configured AGENTS_API_URL and triggers /agents/run.</p>
<button onClick={loadAgents} disabled={loading} style={{ marginTop: 8 }}>
{loading ? 'Loading…' : 'Refresh'}
</button>
{message && <p className="status-ok" style={{ marginTop: 8 }}>{message}</p>}
{error && <p className="status-bad" style={{ marginTop: 8 }}>{error}</p>}
</div>
<div className="card">
<h3>Agent List</h3>
{agents.length === 0 && !loading && <p className="muted">No agents found.</p>}
{loading && <p className="muted">Loading</p>}
{agents.length > 0 && (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{agents.map((agent) => (
<tr key={agent.id || agent.name}>
<td>{agent.name || agent.id}</td>
<td className="muted">{agent.status || 'n/a'}</td>
<td className="muted">{agent.description || '—'}</td>
<td>
<button onClick={() => runAgent(agent)}>Run</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { serverConfig } from '@/lib/config';
export async function GET() {
if (!serverConfig.agentsApiUrl) {
return NextResponse.json({ error: 'AGENTS_API_URL is not configured' }, { status: 503 });
}
const url = new URL('/agents', serverConfig.agentsApiUrl);
try {
const response = await fetch(url.toString(), { cache: 'no-store' });
const payload = await response.json();
const agents = Array.isArray(payload) ? payload : payload.agents ?? [];
return NextResponse.json({ agents });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unable to load agents' },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { serverConfig } from '@/lib/config';
export async function POST(request: Request) {
if (!serverConfig.agentsApiUrl) {
return NextResponse.json({ error: 'AGENTS_API_URL is not configured' }, { status: 503 });
}
const url = new URL('/agents/run', serverConfig.agentsApiUrl);
const body = await request.json().catch(() => ({}));
try {
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body ?? {}),
cache: 'no-store'
});
const payload = await response.json().catch(() => ({}));
return NextResponse.json(payload, { status: response.status });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unable to trigger agent' },
{ status: 502 }
);
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { pollServiceHealth, serverConfig } from '@/lib/config';
export async function GET() {
const services = await pollServiceHealth();
const healthy = services.every((service) => service.status === 'healthy' || service.status === 'not_configured');
const payload = {
status: healthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
environment: serverConfig.environment,
version: process.env.npm_package_version ?? 'unknown',
services
};
return NextResponse.json(payload, { status: healthy ? 200 : 503 });
}

View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { serverConfig } from '@/lib/config';
export async function GET() {
const version = process.env.npm_package_version ?? 'unknown';
return NextResponse.json({
version,
environment: serverConfig.environment,
timestamp: new Date().toISOString()
});
}

View File

@@ -1,13 +1,97 @@
import { getStaticServiceHealth } from '@/lib/config'; 'use client';
export const revalidate = 0; import { useEffect, useState } from 'react';
import type { ServiceStatus } from '@/lib/config';
type HealthResponse = {
status: string;
timestamp: string;
environment: string;
version?: string;
services: ServiceStatus[];
};
function badgeClass(status: ServiceStatus['status']) {
if (status === 'healthy') return 'status-ok';
if (status === 'not_configured') return 'status-bad';
return 'status-bad';
}
export default function HealthPage() {
const [health, setHealth] = useState<HealthResponse | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const response = await fetch('/api/health', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Health endpoint returned ${response.status}`);
}
const payload = (await response.json()) as HealthResponse;
if (!cancelled) {
setHealth(payload);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unable to load health');
}
}
};
load();
const interval = setInterval(load, 15000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
export default async function HealthPage() {
const health = await getStaticServiceHealth();
return ( return (
<div> <div className="grid">
<div className="card">
<h1>Health Check</h1> <h1>Health Check</h1>
<pre>{JSON.stringify(health, null, 2)}</pre> <p className="muted">Live status from <code>/api/health</code>.</p>
{health && (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span className={health.status === 'ok' ? 'badge' : 'status-bad'}>
{health.status === 'ok' ? 'OK' : 'Degraded'}
</span>
<span className="muted">Env: {health.environment}</span>
<span className="muted">Version: {health.version}</span>
<span className="muted">Updated: {new Date(health.timestamp).toLocaleString()}</span>
</div>
)}
{error && <p className="status-bad">{error}</p>}
</div>
<div className="card">
<h3>Services</h3>
<p className="muted">Core, API Gateway, Agents, and the operator console.</p>
<div style={{ display: 'grid', gap: 8 }}>
{health?.services.map((service) => (
<div
key={service.key}
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<div>
<div>{service.name}</div>
<div className="muted">{service.url || 'not set'}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className={badgeClass(service.status)}>
{service.status === 'healthy' && 'Healthy'}
{service.status === 'not_configured' && 'Not configured'}
{service.status === 'unreachable' && 'Unreachable'}
</span>
<span className="muted">{service.latencyMs ? `${service.latencyMs} ms` : '—'}</span>
</div>
</div>
)) || <p className="muted">No services configured.</p>}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
import { config } from '@/lib/config';
export async function GET() {
const payload = {
status: 'ok',
timestamp: new Date().toISOString(),
environment: config.environment,
services: {
coreApiConfigured: Boolean(config.coreApiUrl),
agentsApiConfigured: Boolean(config.agentsApiUrl)
}
};
return NextResponse.json(payload);
}

View File

@@ -1,4 +1,4 @@
import { config, getStaticServiceHealth, publicConfig } from '@/lib/config'; import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config';
const cards = [ const cards = [
{ title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' }, { title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' },
@@ -9,9 +9,9 @@ const cards = [
export default function Home() { export default function Home() {
const serviceHealth = getStaticServiceHealth(); const serviceHealth = getStaticServiceHealth();
const resolvedValues = { const resolvedValues = {
coreApiUrl: publicConfig.coreApiUrl || config.coreApiUrl, coreApiUrl: publicConfig.coreApiUrl || serverConfig.coreApiUrl,
agentsApiUrl: publicConfig.agentsApiUrl || config.agentsApiUrl, agentsApiUrl: publicConfig.agentsApiUrl || serverConfig.agentsApiUrl,
consoleUrl: publicConfig.consoleUrl || config.consoleUrl consoleUrl: publicConfig.consoleUrl || serverConfig.consoleUrl
} as const; } as const;
return ( return (
@@ -19,15 +19,13 @@ export default function Home() {
<div className="card"> <div className="card">
<h3>Environment</h3> <h3>Environment</h3>
<p className="muted">Aligns with Railway and Cloudflare mapping.</p> <p className="muted">Aligns with Railway and Cloudflare mapping.</p>
<div className="badge">{config.environment}</div> <div className="badge">{serverConfig.environment}</div>
<table className="table"> <table className="table">
<tbody> <tbody>
{cards.map((card) => ( {cards.map((card) => (
<tr key={card.key}> <tr key={card.key}>
<td>{card.title}</td> <td>{card.title}</td>
<td className="muted"> <td className="muted">{resolvedValues[card.key] || 'not set'}</td>
{resolvedValues[card.key] || 'not set'}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -42,7 +40,7 @@ export default function Home() {
<p className="muted">Configuration-based readiness of upstream services.</p> <p className="muted">Configuration-based readiness of upstream services.</p>
<ul> <ul>
{serviceHealth.map((service) => ( {serviceHealth.map((service) => (
<li key={service.name}> <li key={service.key}>
{service.name}:{' '} {service.name}:{' '}
<span className={service.configured ? 'status-ok' : 'status-bad'}> <span className={service.configured ? 'status-ok' : 'status-bad'}>
{service.configured ? 'Configured' : 'Missing'} {service.configured ? 'Configured' : 'Missing'}
@@ -58,7 +56,7 @@ export default function Home() {
<ul> <ul>
<li>Use /status for a focused service status board.</li> <li>Use /status for a focused service status board.</li>
<li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li> <li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li>
<li>Health endpoint: <code>/health</code>.</li> <li>Health endpoint: <code>/api/health</code>.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,19 +1,22 @@
import { getStaticServiceHealth, publicConfig } from '@/lib/config'; import { pollServiceHealth, publicConfig } from '@/lib/config';
import { StatusCard } from '@/components/status/StatusCard'; import { StatusCard } from '@/components/status/StatusCard';
export const metadata = { export const metadata = {
title: 'System Status | Prism Console' title: 'System Status | Prism Console'
}; };
export default function StatusPage() { export const revalidate = 0;
const services = getStaticServiceHealth(); export const dynamic = 'force-dynamic';
export default async function StatusPage() {
const services = await pollServiceHealth();
const env = publicConfig.environment; const env = publicConfig.environment;
return ( return (
<div className="grid"> <div className="grid">
<StatusCard <StatusCard
title="Prism Console" title="Prism Console"
description="Static overview of configured upstream services. Replace mock health with live pings when endpoints are ready." description="Live health from upstream services. Polls /health for each configured endpoint."
environment={env} environment={env}
services={services} services={services}
/> />

View File

@@ -1,9 +1 @@
import { NextResponse } from 'next/server'; export { GET } from '../api/version/route';
export async function GET() {
const version = process.env.npm_package_version ?? 'unknown';
return NextResponse.json({
version,
timestamp: new Date().toISOString(),
});
}

View File

@@ -1,7 +1,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import type { Route } from 'next'; import type { Route } from 'next';
import { config } from '@/lib/config'; import { serverConfig } from '@/lib/config';
type Props = { type Props = {
children: ReactNode; children: ReactNode;
@@ -10,7 +10,8 @@ type Props = {
const navLinks: { href: Route; label: string }[] = [ const navLinks: { href: Route; label: string }[] = [
{ href: '/', label: 'Overview' }, { href: '/', label: 'Overview' },
{ href: '/status', label: 'Status' }, { href: '/status', label: 'Status' },
{ href: '/health', label: 'Health API' } { href: '/health', label: 'Health' },
{ href: '/agents', label: 'Agents' }
]; ];
export default function AppShell({ children }: Props) { export default function AppShell({ children }: Props) {
@@ -27,7 +28,7 @@ export default function AppShell({ children }: Props) {
</Link> </Link>
))} ))}
<span className="badge" style={{ marginLeft: 'auto' }}> <span className="badge" style={{ marginLeft: 'auto' }}>
Environment: {config.environment} Environment: {serverConfig.environment}
</span> </span>
</div> </div>
</nav> </nav>

View File

@@ -1,12 +1,18 @@
import { ServiceHealth } from '@/lib/config'; import { ServiceStatus } from '@/lib/config';
export type StatusCardProps = { export type StatusCardProps = {
title: string; title: string;
description?: string; description?: string;
environment: string; environment: string;
services: ServiceHealth[]; services: ServiceStatus[];
}; };
function statusLabel(status: ServiceStatus['status']) {
if (status === 'healthy') return 'status-ok';
if (status === 'not_configured') return 'status-bad';
return 'status-bad';
}
export function StatusCard({ title, description, environment, services }: StatusCardProps) { export function StatusCard({ title, description, environment, services }: StatusCardProps) {
return ( return (
<div className="card"> <div className="card">
@@ -21,6 +27,7 @@ export function StatusCard({ title, description, environment, services }: Status
<th>Service</th> <th>Service</th>
<th>URL</th> <th>URL</th>
<th>Status</th> <th>Status</th>
<th>Latency</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -28,9 +35,12 @@ export function StatusCard({ title, description, environment, services }: Status
<tr key={service.name}> <tr key={service.name}>
<td>{service.name}</td> <td>{service.name}</td>
<td className="muted">{service.url || 'not set'}</td> <td className="muted">{service.url || 'not set'}</td>
<td className={service.configured ? 'status-ok' : 'status-bad'}> <td className={statusLabel(service.status)}>
{service.configured ? 'Configured' : 'Missing'} {service.status === 'healthy' && 'Healthy'}
{service.status === 'not_configured' && 'Not configured'}
{service.status === 'unreachable' && 'Unreachable'}
</td> </td>
<td className="muted">{service.latencyMs ? `${service.latencyMs} ms` : '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -1,4 +1,4 @@
import { config } from './config'; import { serverConfig } from './config';
type FetchTarget = 'core' | 'agents'; type FetchTarget = 'core' | 'agents';
@@ -9,8 +9,8 @@ type ApiClientOptions = {
}; };
const baseMap: Record<FetchTarget, string> = { const baseMap: Record<FetchTarget, string> = {
core: config.coreApiUrl, core: serverConfig.coreApiUrl,
agents: config.agentsApiUrl agents: serverConfig.agentsApiUrl
}; };
/** /**

View File

@@ -5,6 +5,8 @@ type ReadEnvOptions = {
defaultValue?: string; defaultValue?: string;
}; };
type ServiceKey = 'console' | 'core' | 'agents';
const nodeEnvRaw = (process.env.NODE_ENV || 'development') as string; const nodeEnvRaw = (process.env.NODE_ENV || 'development') as string;
const environment: RuntimeEnvironment = const environment: RuntimeEnvironment =
nodeEnvRaw === 'production' ? 'production' : nodeEnvRaw === 'staging' ? 'staging' : 'development'; nodeEnvRaw === 'production' ? 'production' : nodeEnvRaw === 'staging' ? 'staging' : 'development';
@@ -26,7 +28,7 @@ function readEnv(variable: string, { optional, defaultValue }: ReadEnvOptions =
return value ?? ''; return value ?? '';
} }
export const config = { export const serverConfig = {
environment, environment,
nodeEnv: environment, nodeEnv: environment,
coreApiUrl: readEnv('CORE_API_URL', { optional: isDev }), coreApiUrl: readEnv('CORE_API_URL', { optional: isDev }),
@@ -45,28 +47,110 @@ export const config = {
export const publicConfig = { export const publicConfig = {
environment, environment,
consoleUrl: readEnv('NEXT_PUBLIC_CONSOLE_URL', { optional: isDev }), consoleUrl: readEnv('PUBLIC_CONSOLE_URL', { optional: true }),
coreApiUrl: readEnv('NEXT_PUBLIC_CORE_API_URL', { optional: isDev }), coreApiUrl: readEnv('NEXT_PUBLIC_CORE_API_URL', { optional: true }),
agentsApiUrl: readEnv('NEXT_PUBLIC_AGENTS_API_URL', { optional: isDev }) agentsApiUrl: readEnv('NEXT_PUBLIC_AGENTS_API_URL', { optional: true })
}; };
export type ServiceHealth = { export type ServiceDescriptor = {
key: ServiceKey;
name: string; name: string;
url: string; url: string;
configured: boolean; configured: boolean;
}; };
export function getStaticServiceHealth(): ServiceHealth[] { export type ServiceStatus = ServiceDescriptor & {
return [ status: 'healthy' | 'unreachable' | 'not_configured';
{ latencyMs?: number;
name: 'Core API', error?: string;
url: publicConfig.coreApiUrl || config.coreApiUrl, };
configured: Boolean(publicConfig.coreApiUrl || config.coreApiUrl)
}, const serviceCatalog: Record<ServiceKey, { name: string }> = {
{ console: { name: 'Operator Console' },
name: 'Agents API', core: { name: 'Core API' },
url: publicConfig.agentsApiUrl || config.agentsApiUrl, agents: { name: 'Agents API' }
configured: Boolean(publicConfig.agentsApiUrl || config.agentsApiUrl) };
function resolveServiceUrl(key: ServiceKey, preferPublic = true): string {
if (key === 'console') {
return preferPublic ? publicConfig.consoleUrl || serverConfig.consoleUrl : serverConfig.consoleUrl || publicConfig.consoleUrl;
} }
];
if (key === 'core') {
return preferPublic ? publicConfig.coreApiUrl || serverConfig.coreApiUrl : serverConfig.coreApiUrl || publicConfig.coreApiUrl;
}
return preferPublic ? publicConfig.agentsApiUrl || serverConfig.agentsApiUrl : serverConfig.agentsApiUrl || publicConfig.agentsApiUrl;
}
export function getStaticServiceHealth(preferPublic = true): ServiceDescriptor[] {
return (Object.keys(serviceCatalog) as ServiceKey[]).map((key) => {
const url = resolveServiceUrl(key, preferPublic);
return {
key,
name: serviceCatalog[key].name,
url,
configured: Boolean(url)
};
});
}
function withHealthPath(url: string): string {
if (!url) return '';
try {
const normalized = url.endsWith('/') ? url.slice(0, -1) : url;
const target = new URL(normalized);
target.pathname = target.pathname.endsWith('/health') ? target.pathname : `${target.pathname || ''}/health`;
return target.toString();
} catch (error) {
return url;
}
}
export async function pollServiceHealth(timeoutMs = 2500): Promise<ServiceStatus[]> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const checks = getStaticServiceHealth(false).map(async (service) => {
if (!service.configured) {
return { ...service, status: 'not_configured' as const } satisfies ServiceStatus;
}
const healthUrl = withHealthPath(service.url);
const started = performance.now();
try {
const response = await fetch(healthUrl, {
method: 'GET',
cache: 'no-store',
signal: controller.signal
});
const latencyMs = Math.round(performance.now() - started);
if (!response.ok) {
return {
...service,
status: 'unreachable',
latencyMs,
error: `HTTP ${response.status}`
} satisfies ServiceStatus;
}
return {
...service,
status: 'healthy',
latencyMs
} satisfies ServiceStatus;
} catch (error) {
return {
...service,
status: 'unreachable',
error: error instanceof Error ? error.message : 'unknown error'
} satisfies ServiceStatus;
}
});
const results = await Promise.all(checks);
clearTimeout(timeout);
return results;
} }