Standardize Prism Console service metadata and health endpoints

This commit is contained in:
Alexa Amundson
2025-11-20 20:29:13 -06:00
parent d17575c6b8
commit 9dd7501946
13 changed files with 260 additions and 91 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# BlackRoad OS Prism Console
OS_ROOT=https://blackroad.systems
SERVICE_BASE_URL=https://console.blackroad.systems
NEXT_PUBLIC_OS_ROOT=https://blackroad.systems
NEXT_PUBLIC_SERVICE_ID=console
NEXT_PUBLIC_SERVICE_NAME="BlackRoad OS Prism Console"
# Optional upstream services
CORE_API_URL=
AGENTS_API_URL=
PUBLIC_CONSOLE_URL=
NEXT_PUBLIC_CORE_API_URL=
NEXT_PUBLIC_AGENTS_API_URL=

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]

View File

@@ -1,47 +1,48 @@
# BlackRoad OS Prism Console # BlackRoad OS Prism Console
Operator console UI for BlackRoad OS built with Next.js (App Router + TypeScript). Operator / admin console for BlackRoad OS services. This frontend surfaces system health, environment metadata, and operator workflows for the broader BlackRoad OS platform.
## Running locally ## Tech Stack
- Next.js (App Router)
- React
- TypeScript
1. Install dependencies: `npm ci` ## Getting Started
2. Set environment variables (see below). You can use a `.env.local` file for local development. Install dependencies and run the development server:
3. Start the dev server: `npm run dev`
4. Visit `http://localhost:3000` to load the console.
## Key routes ```bash
npm install
npm run dev
```
- `/` — Overview of configured upstream endpoints. Visit http://localhost:3000 by default.
- `/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.
## Configuration ## Build & Start
Production build and runtime (default port 8080 for deployment targets like Railway):
Environment variables are centralized in `src/lib/config.ts`. Server-side values stay private; `NEXT_PUBLIC_*` values are exposed to the browser. ```bash
npm run build
npm start
```
| Variable | Purpose | ## Environment Variables
| --- | --- | See `.env.example` for available variables. Key values:
| `NODE_ENV` | Runtime environment selector (`development` \| `staging` \| `production`). | - `OS_ROOT` base BlackRoad OS root URL
| `CORE_API_URL` | Server-side Core API base URL used for backend calls. | - `SERVICE_BASE_URL` public URL for this console
| `AGENTS_API_URL` | Server-side Agents API base URL for listing/running agents. | - `CORE_API_URL`, `AGENTS_API_URL` optional upstream APIs
| `PUBLIC_CONSOLE_URL` | Public URL for this console; also used for the operator badge. |
| `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. |
> 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. ## Health & Info
- `/api/health` health payload including service id and readiness
- `/api/info` static metadata about the Prism Console service
- `/api/version` version and environment snapshot
- `/api/debug-env` safe environment surface for troubleshooting
## Wiring to backend services ## Deployment (Railway)
- Build command: `npm install && npm run build`
- Start command: `npm start`
- Port: `8080`
- Healthcheck: `/api/health`
- Health polling: `/api/health` calls each configured service and appends `/health` to the base URL. `/status` renders the live results. ## Additional Notes
- Version metadata: `/api/version` (and `/version`) returns the build version, environment, and timestamp. - Base URL: https://console.blackroad.systems
- 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. - OS Root: https://blackroad.systems
## Deployment
- 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 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).

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "SKIP_ENV_VALIDATION=true next build", "build": "SKIP_ENV_VALIDATION=true next build",
"start": "next start", "start": "next start -p 8080",
"lint": "eslint . --ext .ts,.tsx" "lint": "eslint . --ext .ts,.tsx"
}, },
"keywords": [ "keywords": [

View File

@@ -1,22 +1,10 @@
{ {
"$schema": "https://railway.app/railway.schema.json", "build": "npm install && npm run build",
"project": "blackroad-prism-console", "start": "npm start",
"services": [ "service": {
{ "port": 8080,
"name": "prism-console-web", "healthcheck": {
"source": "./", "path": "/api/health"
"port": 3000,
"buildCommand": "npm run build",
"startCommand": "npm run start",
"healthcheckPath": "/api/health",
"envVariables": {
"NODE_ENV": "production",
"CORE_API_URL": "",
"AGENTS_API_URL": "",
"PUBLIC_CONSOLE_URL": "",
"NEXT_PUBLIC_CORE_API_URL": "",
"NEXT_PUBLIC_AGENTS_API_URL": ""
} }
} }
]
} }

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { publicConfig, serverConfig } from '@/lib/config';
import { serviceConfig } from '@/config/serviceConfig';
export async function GET() {
const safeEnv = {
nodeEnv: process.env.NODE_ENV,
nextRuntime: process.env.NEXT_RUNTIME,
environment: serverConfig.environment,
serviceId: serviceConfig.SERVICE_ID,
serviceName: serviceConfig.SERVICE_NAME,
serviceBaseUrl: serviceConfig.SERVICE_BASE_URL,
osRoot: serviceConfig.OS_ROOT,
publicCoreApiUrl: publicConfig.coreApiUrl,
publicAgentsApiUrl: publicConfig.agentsApiUrl,
publicConsoleUrl: publicConfig.consoleUrl
};
return NextResponse.json(safeEnv);
}

View File

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

14
src/app/api/info/route.ts Normal file
View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
import { serviceConfig } from '@/config/serviceConfig';
export async function GET() {
const payload = {
name: serviceConfig.SERVICE_NAME,
id: serviceConfig.SERVICE_ID,
baseUrl: serviceConfig.SERVICE_BASE_URL,
osRoot: serviceConfig.OS_ROOT,
ts: new Date().toISOString()
};
return NextResponse.json(payload);
}

View File

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

View File

@@ -2,10 +2,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ServiceStatus } from '@/lib/config'; import type { ServiceStatus } from '@/lib/config';
import { serviceConfig } from '@/config/serviceConfig';
type HealthResponse = { type HealthResponse = {
ok: boolean;
status: string; status: string;
timestamp: string; ts: string;
environment: string; environment: string;
version?: string; version?: string;
services: ServiceStatus[]; services: ServiceStatus[];
@@ -53,15 +55,13 @@ export default function HealthPage() {
<div className="grid"> <div className="grid">
<div className="card"> <div className="card">
<h1>Health Check</h1> <h1>Health Check</h1>
<p className="muted">Live status from <code>/api/health</code>.</p> <p className="muted">Live status from <code>/api/health</code> for {serviceConfig.SERVICE_NAME}.</p>
{health && ( {health && (
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span className={health.status === 'ok' ? 'badge' : 'status-bad'}> <span className={health.ok ? 'badge' : 'status-bad'}>{health.ok ? 'OK' : 'Degraded'}</span>
{health.status === 'ok' ? 'OK' : 'Degraded'}
</span>
<span className="muted">Env: {health.environment}</span> <span className="muted">Env: {health.environment}</span>
<span className="muted">Version: {health.version}</span> <span className="muted">Version: {health.version}</span>
<span className="muted">Updated: {new Date(health.timestamp).toLocaleString()}</span> <span className="muted">Updated: {new Date(health.ts).toLocaleString()}</span>
</div> </div>
)} )}
{error && <p className="status-bad">{error}</p>} {error && <p className="status-bad">{error}</p>}

View File

@@ -1,43 +1,74 @@
import { LiveHealthCard } from '@/components/status/LiveHealthCard';
import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config'; import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config';
import { serviceConfig } from '@/config/serviceConfig';
const cards = [ const serviceLinks = [
{ title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' }, { name: 'Core', url: 'https://core.blackroad.systems' },
{ title: 'Agents API', key: 'agentsApiUrl', description: 'Agent runtime surface area.' }, { name: 'API', url: 'https://api.blackroad.systems' },
{ title: 'Console URL', key: 'consoleUrl', description: 'Public entrypoint for this console.' } { name: 'Operator', url: 'https://operator.blackroad.systems' },
] as const; { 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();
const resolvedValues = {
coreApiUrl: publicConfig.coreApiUrl || serverConfig.coreApiUrl,
agentsApiUrl: publicConfig.agentsApiUrl || serverConfig.agentsApiUrl,
consoleUrl: publicConfig.consoleUrl || serverConfig.consoleUrl
} as const;
return ( return (
<div className="grid"> <div className="grid">
<div className="card" style={{ gridColumn: 'span 2' }}>
<h1>{serviceConfig.SERVICE_NAME}</h1>
<p className="muted">Operator-facing control panel for BlackRoad OS</p>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="badge">Service ID: {serviceConfig.SERVICE_ID}</span>
<span className="badge">Environment: {serverConfig.environment}</span>
</div>
<div style={{ marginTop: 12 }}>
<div className="muted">Base URL: {serviceConfig.SERVICE_BASE_URL}</div>
<div className="muted">OS Root: {serviceConfig.OS_ROOT}</div>
</div>
</div>
<LiveHealthCard />
<div className="card"> <div className="card">
<h3>Environment</h3> <h3>Configuration Snapshot</h3>
<p className="muted">Aligns with Railway and Cloudflare mapping.</p> <p className="muted">Resolved URLs from server/public configuration.</p>
<div className="badge">{serverConfig.environment}</div>
<table className="table"> <table className="table">
<tbody> <tbody>
{cards.map((card) => ( <tr>
<tr key={card.key}> <td>Core API</td>
<td>{card.title}</td> <td className="muted">{publicConfig.coreApiUrl || serverConfig.coreApiUrl || 'not set'}</td>
<td className="muted">{resolvedValues[card.key] || 'not set'}</td> </tr>
<tr>
<td>Agents API</td>
<td className="muted">{publicConfig.agentsApiUrl || serverConfig.agentsApiUrl || 'not set'}</td>
</tr>
<tr>
<td>Console URL</td>
<td className="muted">{publicConfig.consoleUrl || serverConfig.consoleUrl || 'not set'}</td>
</tr> </tr>
))}
</tbody> </tbody>
</table> </table>
<p className="muted" style={{ marginTop: 12 }}> </div>
Add authentication here later centralize auth checks before rendering protected pages.
</p> <div className="card" style={{ gridColumn: 'span 2' }}>
<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>
<div className="card"> <div className="card">
<h3>Connectivity</h3> <h3>System Status</h3>
<p className="muted">Configuration-based readiness of upstream services.</p> <p className="muted">Configuration readiness across Prism Console dependencies.</p>
<ul> <ul>
{serviceHealth.map((service) => ( {serviceHealth.map((service) => (
<li key={service.key}> <li key={service.key}>
@@ -52,11 +83,12 @@ export default function Home() {
</div> </div>
<div className="card"> <div className="card">
<h3>Notes</h3> <h3>Operator Queue</h3>
<p className="muted">Placeholder for pending operator tasks, incidents, or approvals.</p>
<ul> <ul>
<li>Use /status for a focused service status board.</li> <li>Integrate authentication for console routes.</li>
<li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li> <li>Connect deployment events stream.</li>
<li>Health endpoint: <code>/api/health</code>.</li> <li>Surface observability snapshots from core services.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import type { ServiceStatus } from '@/lib/config';
import { serviceConfig } from '@/config/serviceConfig';
type HealthResponse = {
ok: boolean;
service: string;
ts: string;
status: string;
environment: string;
version?: string;
services: ServiceStatus[];
};
export function LiveHealthCard() {
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, 10000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
const statusText = health?.ok ? 'ONLINE' : 'OFFLINE';
return (
<div className="card">
<h3>System Status</h3>
<p className="muted">Live ping against <code>/api/health</code> for {serviceConfig.SERVICE_NAME}.</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span className={health?.ok ? 'badge' : 'status-bad'}>Status: {statusText}</span>
<span className="muted">Service: {serviceConfig.SERVICE_ID}</span>
</div>
{health && (
<div style={{ marginTop: 12, display: 'grid', gap: 6 }}>
<div className="muted">Environment: {health.environment}</div>
<div className="muted">Version: {health.version || 'unknown'}</div>
<div className="muted">Last checked: {new Date(health.ts).toLocaleString()}</div>
</div>
)}
{error && <p className="status-bad">{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,11 @@
export const SERVICE_ID = 'console';
export const SERVICE_NAME = 'BlackRoad OS Prism Console';
export const SERVICE_BASE_URL = process.env.SERVICE_BASE_URL || 'https://console.blackroad.systems';
export const OS_ROOT = process.env.OS_ROOT || 'https://blackroad.systems';
export const serviceConfig = {
SERVICE_ID,
SERVICE_NAME,
SERVICE_BASE_URL,
OS_ROOT
};