Merge pull request #3 from BlackRoad-OS/codex/analyze-and-update-blackroad-os-prism-console

Set up Prism Console app and deployment automation
This commit is contained in:
Alexa Amundson
2025-11-19 14:14:50 -06:00
committed by GitHub
18 changed files with 6649 additions and 27 deletions

60
.github/workflows/deploy-console.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
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

@@ -36,3 +36,40 @@ Sample tasks:
- Worker queue timeline - Worker queue timeline
- Error heatmaps - Error heatmaps
- Rail & Cloudflare integrated view - Rail & Cloudflare integrated view
## Deployment
Prism Console deploys to the Railway project **`blackroad-prism-console`** as the service **`prism-console-web`**. It fronts environments published via Cloudflare:
- **Production:** `https://console.blackroad.systems`
- **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.

24
eslint.config.mjs Normal file
View File

@@ -0,0 +1,24 @@
import nextPlugin from '@next/eslint-plugin-next';
import tsParser from '@typescript-eslint/parser';
export default [
{
ignores: ['.next/**/*', 'node_modules/**/*']
},
{
plugins: {
'@next/next': nextPlugin
},
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module'
}
},
rules: {
...nextPlugin.configs['core-web-vitals'].rules,
'@next/next/no-html-link-for-pages': 'off'
}
}
];

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

8
next.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
typedRoutes: true
};
export default nextConfig;

5945
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "blackroad-os-prism-console",
"version": "1.0.0",
"description": "Admin console for deployments, observability, environments, and system control.",
"private": true,
"scripts": {
"dev": "next dev",
"build": "SKIP_ENV_VALIDATION=true next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx"
},
"keywords": [
"prism",
"console",
"operations"
],
"author": "Prism Operations Group",
"license": "Apache-2.0",
"dependencies": {
"next": "^16.0.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/parser": "^8.47.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.0.3",
"typescript": "^5.9.3"
}
}

23
railway.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://railway.app/railway.schema.json",
"project": "blackroad-prism-console",
"services": [
{
"name": "prism-console-web",
"source": "./",
"port": 3000,
"buildCommand": "npm run build",
"startCommand": "npm run start",
"envVariables": {
"NODE_ENV": "production",
"CORE_API_URL": "",
"AGENTS_API_URL": "",
"PUBLIC_CONSOLE_URL": "",
"NEXT_PUBLIC_CORE_API_URL": "",
"NEXT_PUBLIC_AGENTS_API_URL": "",
"NEXT_PUBLIC_CONSOLE_URL": "",
"DATABASE_URL": ""
}
}
]
}

115
src/app/globals.css Normal file
View File

@@ -0,0 +1,115 @@
:root {
color-scheme: light;
--bg: #0b1021;
--panel: #11162d;
--card: #151b34;
--text: #e8ecf7;
--muted: #9aa5c0;
--accent: #7dd3fc;
--accent-strong: #38bdf8;
--border: #1f2946;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(56, 189, 248, 0.08), transparent 25%),
radial-gradient(circle at 80% 10%, rgba(99, 102, 241, 0.08), transparent 20%),
var(--bg);
color: var(--text);
min-height: 100vh;
}
a {
color: var(--accent-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
main {
padding: 32px;
}
nav {
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.nav-links {
display: flex;
gap: 16px;
padding: 16px 32px;
align-items: center;
}
.logo {
font-weight: 700;
letter-spacing: 0.04em;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
}
.card h3 {
margin-top: 0;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
background: rgba(125, 211, 252, 0.1);
color: var(--accent-strong);
border: 1px solid var(--border);
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
color: var(--text);
}
.table th,
.table td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.muted {
color: var(--muted);
}
.status-ok {
color: #22c55e;
}
.status-warn {
color: #facc15;
}
.status-bad {
color: #f87171;
}

16
src/app/health/route.ts Normal file
View File

@@ -0,0 +1,16 @@
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);
}

22
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import './globals.css';
import { ReactNode } from 'react';
import AppShell from '@/components/layout/AppShell';
export const metadata = {
title: 'Prism Console',
description: 'Operational console for BlackRoad OS'
};
type Props = {
children: ReactNode;
};
export default function RootLayout({ children }: Props) {
return (
<html lang="en">
<body>
<AppShell>{children}</AppShell>
</body>
</html>
);
}

66
src/app/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { config, getStaticServiceHealth, publicConfig } from '@/lib/config';
const cards = [
{ title: 'Core API', key: 'coreApiUrl', description: 'Primary backend for Prism data.' },
{ title: 'Agents API', key: 'agentsApiUrl', description: 'Agent runtime surface area.' },
{ title: 'Console URL', key: 'consoleUrl', description: 'Public entrypoint for this console.' }
] as const;
export default function Home() {
const serviceHealth = getStaticServiceHealth();
const resolvedValues = {
coreApiUrl: publicConfig.coreApiUrl || config.coreApiUrl,
agentsApiUrl: publicConfig.agentsApiUrl || config.agentsApiUrl,
consoleUrl: publicConfig.consoleUrl || config.consoleUrl
} as const;
return (
<div className="grid">
<div className="card">
<h3>Environment</h3>
<p className="muted">Aligns with Railway and Cloudflare mapping.</p>
<div className="badge">{config.environment}</div>
<table className="table">
<tbody>
{cards.map((card) => (
<tr key={card.key}>
<td>{card.title}</td>
<td className="muted">
{resolvedValues[card.key] || 'not set'}
</td>
</tr>
))}
</tbody>
</table>
<p className="muted" style={{ marginTop: 12 }}>
Add authentication here later centralize auth checks before rendering protected pages.
</p>
</div>
<div className="card">
<h3>Connectivity</h3>
<p className="muted">Configuration-based readiness of upstream services.</p>
<ul>
{serviceHealth.map((service) => (
<li key={service.name}>
{service.name}:{' '}
<span className={service.configured ? 'status-ok' : 'status-bad'}>
{service.configured ? 'Configured' : 'Missing'}
</span>{' '}
<span className="muted">{service.url || 'not set'}</span>
</li>
))}
</ul>
</div>
<div className="card">
<h3>Notes</h3>
<ul>
<li>Use /status for a focused service status board.</li>
<li>Centralized fetch helper lives in <code>src/lib/api.ts</code>.</li>
<li>Health endpoint: <code>/health</code>.</li>
</ul>
</div>
</div>
);
}

30
src/app/status/page.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { getStaticServiceHealth, publicConfig } from '@/lib/config';
import { StatusCard } from '@/components/status/StatusCard';
export const metadata = {
title: 'System Status | Prism Console'
};
export default function StatusPage() {
const services = getStaticServiceHealth();
const env = publicConfig.environment;
return (
<div className="grid">
<StatusCard
title="Prism Console"
description="Static overview of configured upstream services. Replace mock health with live pings when endpoints are ready."
environment={env}
services={services}
/>
<div className="card">
<h3>Next Steps</h3>
<ol>
<li>Wire status to lightweight read-only endpoints from core and agents.</li>
<li>Add authentication guard before exposing sensitive data.</li>
<li>Expand to deployment + observability widgets under /status.</li>
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import Link from 'next/link';
import { ReactNode } from 'react';
import type { Route } from 'next';
import { config } from '@/lib/config';
type Props = {
children: ReactNode;
};
const navLinks: { href: Route; label: string }[] = [
{ href: '/', label: 'Overview' },
{ href: '/status', label: 'Status' },
{ href: '/health', label: 'Health API' }
];
export default function AppShell({ children }: Props) {
return (
<div>
<nav>
<div className="nav-links">
<Link href="/" className="logo">
Prism Console
</Link>
{navLinks.map((link) => (
<Link key={link.href} href={link.href}>
{link.label}
</Link>
))}
<span className="badge" style={{ marginLeft: 'auto' }}>
Environment: {config.environment}
</span>
</div>
</nav>
<main>{children}</main>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { ServiceHealth } from '@/lib/config';
export type StatusCardProps = {
title: string;
description?: string;
environment: string;
services: ServiceHealth[];
};
export function StatusCard({ title, description, environment, services }: StatusCardProps) {
return (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>{title}</h3>
<span className="badge">{environment}</span>
</div>
{description && <p className="muted">{description}</p>}
<table className="table">
<thead>
<tr>
<th>Service</th>
<th>URL</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{services.map((service) => (
<tr key={service.name}>
<td>{service.name}</td>
<td className="muted">{service.url || 'not set'}</td>
<td className={service.configured ? 'status-ok' : 'status-bad'}>
{service.configured ? 'Configured' : 'Missing'}
</td>
</tr>
))}
</tbody>
</table>
{!services.every((svc) => svc.configured) && (
<p className="muted">Backend URLs are required in staging/production.</p>
)}
</div>
);
}

41
src/lib/api.ts Normal file
View File

@@ -0,0 +1,41 @@
import { config } from './config';
type FetchTarget = 'core' | 'agents';
type ApiClientOptions = {
target: FetchTarget;
path: string;
init?: RequestInit;
};
const baseMap: Record<FetchTarget, string> = {
core: config.coreApiUrl,
agents: config.agentsApiUrl
};
/**
* Centralized fetch helper for backend calls.
* TODO: add authentication headers or cookies once auth is available.
*/
export async function apiClient<T>({ target, path, init }: ApiClientOptions): Promise<T> {
const baseUrl = baseMap[target];
if (!baseUrl) {
throw new Error(`${target} API URL is not configured`);
}
const url = new URL(path, baseUrl);
const response = await fetch(url.toString(), {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {})
}
});
if (!response.ok) {
throw new Error(`${target} API responded with ${response.status}`);
}
return response.json() as Promise<T>;
}

72
src/lib/config.ts Normal file
View File

@@ -0,0 +1,72 @@
export type RuntimeEnvironment = 'development' | 'staging' | 'production';
type ReadEnvOptions = {
optional?: boolean;
defaultValue?: string;
};
const nodeEnvRaw = (process.env.NODE_ENV || 'development') as string;
const environment: RuntimeEnvironment =
nodeEnvRaw === 'production' ? 'production' : nodeEnvRaw === 'staging' ? 'staging' : 'development';
const isDev = environment === 'development';
const requireEnv = !isDev && process.env.SKIP_ENV_VALIDATION !== 'true';
function readEnv(variable: string, { optional, defaultValue }: ReadEnvOptions = {}): string {
const value = process.env[variable];
if (!value && !optional && requireEnv) {
throw new Error(`Missing required environment variable ${variable} for ${environment}`);
}
if (!value && defaultValue) {
return defaultValue;
}
return value ?? '';
}
export const config = {
environment,
nodeEnv: environment,
coreApiUrl: readEnv('CORE_API_URL', { optional: isDev }),
agentsApiUrl: readEnv('AGENTS_API_URL', { optional: isDev }),
consoleUrl: readEnv('PUBLIC_CONSOLE_URL', { optional: isDev }),
get isProduction() {
return environment === 'production';
},
get isStaging() {
return environment === 'staging';
},
get isDevelopment() {
return environment === 'development';
}
};
export const publicConfig = {
environment,
consoleUrl: readEnv('NEXT_PUBLIC_CONSOLE_URL', { optional: isDev }),
coreApiUrl: readEnv('NEXT_PUBLIC_CORE_API_URL', { optional: isDev }),
agentsApiUrl: readEnv('NEXT_PUBLIC_AGENTS_API_URL', { optional: isDev })
};
export type ServiceHealth = {
name: string;
url: string;
configured: boolean;
};
export function getStaticServiceHealth(): ServiceHealth[] {
return [
{
name: 'Core API',
url: publicConfig.coreApiUrl || config.coreApiUrl,
configured: Boolean(publicConfig.coreApiUrl || config.coreApiUrl)
},
{
name: 'Agents API',
url: publicConfig.agentsApiUrl || config.agentsApiUrl,
configured: Boolean(publicConfig.agentsApiUrl || config.agentsApiUrl)
}
];
}

44
tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}