Add Prism console v2 layout and data scaffolding

This commit is contained in:
Alexa Amundson
2025-11-23 16:24:54 -06:00
parent 9c4c17b4c4
commit 5c82a49a06
20 changed files with 1259 additions and 597 deletions

View File

@@ -1,14 +1,17 @@
# BlackRoad OS Prism Console # BlackRoad OS Prism Console
Operator / admin console for BlackRoad OS services. The app is a Next.js (App Router) frontend that surfaces environment metadata, service health, agent observability, finance telemetry, and the event stream. Prism Console is the operator-facing cockpit for the BlackRoad OS universe. It runs on Next.js (App Router) with TypeScript and
presents environment health, agents, finance, and RoadChain history. Prism speaks to **blackroad-os-api** using
`NEXT_PUBLIC_API_BASE_URL` and participates in the shared GitHub Project **"BlackRoad OS - Master Orchestration"** alongside
core, operator, web, docs, and infra repos.
## Features ## Pages
- Overview dashboard with finance summary, agent counts, and latest events. - **Dashboard**: System overview with service health, throughput, and a quick glance at agents + events.
- Agents list and detail drill-down with recent tasks. - **Agents**: Filterable agent registry with status pills and a detail viewer for raw metadata.
- Finance view with cash balance, runway, forecast buckets, and example statements. - **Finance**: Wallet-style snapshot of treasury, infra costs, savings, and trend hints.
- Events stream with basic filters. - **Events / RoadChain**: Event stream filters plus a lightweight RoadChain block explorer (mocked until the API endpoint ships).
## Local Development ## Getting started
Install dependencies and run the dev server: Install dependencies and run the dev server:
```bash ```bash
@@ -18,40 +21,29 @@ npm run dev
Visit http://localhost:3000 during development. Visit http://localhost:3000 during development.
### Environment ### Environment variables
Set up `.env.local` (see `.env.example`) with: Create `.env.local` with at least:
``` ```
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
NEXT_PUBLIC_ENV=dev NEXT_PUBLIC_ENV=dev
``` ```
Additional optional URLs remain supported: `NEXT_PUBLIC_API_BASE_URL` should point to the `blackroad-os-api` instance that exposes health, agents, finance, events, and
- `NEXT_PUBLIC_OPERATOR_API_URL` / `OPERATOR_API_URL` roadchain endpoints. The UI falls back to typed mock data if the API is unreachable, so the console remains navigable.
- `NEXT_PUBLIC_CORE_API_URL` / `CORE_API_URL`
- `NEXT_PUBLIC_AGENTS_API_URL` / `AGENTS_API_URL`
- `PUBLIC_CONSOLE_URL`
## Deployment Quick Reference (Railway) ## Deployment quick reference
- **Build**: `npm run build` - **Build**: `npm run build`
- **Start**: `npm start` (runs the standalone Next.js server with `HOST=0.0.0.0` and `PORT=${PORT:-8080}`) - **Start**: `npm start` (standalone Next.js server)
- **Health**: `GET /health` - **Health**: `GET /health` returns `{ "status": "ok", "service": "prism-console" }`
```json
{
"status": "ok",
"service": "prism-console"
}
```
Production tips: Environment tips for Railway or container runtimes:
- `PORT`: provided by Railway; the app listens on this port. - `PORT`: provided by the platform; set `HOST=0.0.0.0` when starting.
- `HOST`: set to `0.0.0.0` to bind to all interfaces (defaulted in `npm start`). - `NODE_ENV`: `production` for deployment.
- `NODE_ENV`: set to `production` in Railway. - `NEXT_PUBLIC_API_BASE_URL`: target the correct `blackroad-os-api` environment.
- `NEXT_PUBLIC_API_BASE_URL`: target `blackroad-os-api` environment. - `NEXT_PUBLIC_ENV`: label shown in the top status bar.
- `NEXT_PUBLIC_ENV`: label displayed in the console top bar.
## Project Notes ## Tech notes
- Next.js 16 with the App Router and TypeScript. - Next.js 16 (App Router) + React + TypeScript.
- Production build outputs a standalone server (`.next/standalone`) suitable for Railway or container runtimes. - Styling via `globals.css` tokens (dark-mode first, no hard-coded brand colors in components).
- Additional informational endpoints live under `/api` (e.g., `/api/health`, `/api/info`, `/api/version`). - Mock data lives in `src/lib/apiClient.ts` to keep the UI usable until the backend endpoints are live.
- See `docs/overview.md` for a product-focused overview of Prism Console.

View File

@@ -0,0 +1,16 @@
# Prism Console Roadmap
Prism Console is the operator cockpit for the BlackRoad OS stack. It surfaces environment health, agent orchestration, finance
signals, and RoadChain history while depending on `blackroad-os-api` for live data.
## Current pillars
- **Dashboard** Overview of BlackRoad OS status, service health, throughput, and recent events.
- **Agents** Registry of operator agents with status filters, tags, and metadata drill-downs.
- **Finance** Wallet-style snapshot of treasury, infra costs, and automation savings with a lightweight trend view.
- **Events / RoadChain** Event stream filters plus a RoadChain explorer showing blocks and their events.
## Future enhancements
- Hardware / Nodes view (edge devices like Raspberry Pi, Orin, and field nodes).
- Operator Chat docked to the console for triage and command.
- Deeper RoadChain analytics and cross-repo tracing.
- Integration with demo worlds (Road City / RoadCraft / Road Life) for showpiece observability views.

View File

@@ -8,7 +8,7 @@
"build": "next build", "build": "next build",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"start:dev": "next start -p ${PORT:-3000}", "start:dev": "next start -p ${PORT:-3000}",
"lint": "next lint", "lint": "next lint .",
"test": "vitest" "test": "vitest"
}, },
"keywords": [ "keywords": [

View File

@@ -8,7 +8,7 @@ export default function AgentDetailPage() {
const { agent, tasks, isLoading, error } = useAgentDetail(params?.id); const { agent, tasks, isLoading, error } = useAgentDetail(params?.id);
return ( return (
<div className="grid"> <div className="page-grid">
<div className="card"> <div className="card">
<h1>Agent Detail</h1> <h1>Agent Detail</h1>
{isLoading && <p className="muted">Loading agent</p>} {isLoading && <p className="muted">Loading agent</p>}
@@ -18,21 +18,13 @@ export default function AgentDetailPage() {
<div className="agent-header"> <div className="agent-header">
<div> <div>
<div className="agent-name">{agent.name}</div> <div className="agent-name">{agent.name}</div>
<div className="muted">{agent.domain || 'no domain set'}</div> <div className="muted">{agent.role}</div>
</div> </div>
<div className={`status-pill ${agent.status}`}>{agent.status}</div> <div className={`status-pill ${agent.status}`}>{agent.status}</div>
</div> </div>
<div className="muted">Last heartbeat: {agent.lastHeartbeat || 'n/a'}</div> <div className="muted">Last heartbeat: {agent.lastHeartbeat || 'n/a'}</div>
<div style={{ marginTop: 12 }}> <div className="muted">Tags: {agent.tags?.join(', ') || '—'}</div>
<strong>Capabilities:</strong> <div className="muted">Version: {agent.version ?? '—'}</div>
<ul>
{agent.capabilities.map((capability) => (
<li key={capability.id}>
{capability.name} {capability.description && <span className="muted"> {capability.description}</span>}
</li>
))}
</ul>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -1,42 +1,100 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react';
import { GenericTable } from '@/components/tables/GenericTable'; import { GenericTable } from '@/components/tables/GenericTable';
import { useAgents } from '@/hooks/useAgents'; import { useAgents } from '@/hooks/useAgents';
import type { AgentSummary } from '@/types/agents'; import { Agent } from '@/types';
const statusFilters: (Agent['status'] | 'all')[] = ['all', 'running', 'idle', 'error', 'offline'];
export default function AgentsPage() { export default function AgentsPage() {
const router = useRouter();
const { data: agents, isLoading, error } = useAgents(); const { data: agents, isLoading, error } = useAgents();
const [status, setStatus] = useState<Agent['status'] | 'all'>('all');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Agent | null>(null);
const filtered = useMemo(() => {
return agents.filter((agent) => {
const matchesStatus = status === 'all' ? true : agent.status === status;
const text = search.toLowerCase();
const matchesText = text
? agent.name.toLowerCase().includes(text) || agent.tags?.some((tag) => tag.toLowerCase().includes(text))
: true;
return matchesStatus && matchesText;
});
}, [agents, status, search]);
const columns = [ const columns = [
{ header: 'Name', accessor: (agent: AgentSummary) => agent.name }, { header: 'Name', accessor: (agent: Agent) => agent.name },
{ header: 'Domain', accessor: (agent: AgentSummary) => agent.domain || '—' }, { header: 'Role', accessor: (agent: Agent) => agent.role },
{ {
header: 'Status', header: 'Status',
accessor: (agent: AgentSummary) => <span className={`status-pill ${agent.status}`}>{agent.status}</span> accessor: (agent: Agent) => <span className={`status-pill ${agent.status}`}>{agent.status}</span>
}, },
{ header: 'Last heartbeat', accessor: (agent: AgentSummary) => agent.lastHeartbeat || 'n/a' }, { header: 'Last heartbeat', accessor: (agent: Agent) => new Date(agent.lastHeartbeat).toLocaleString() },
{ header: 'Capabilities', accessor: (agent: AgentSummary) => agent.capabilities?.length ?? 0 } { header: 'Tags', accessor: (agent: Agent) => agent.tags?.join(', ') || '—' },
{ header: 'Version', accessor: (agent: Agent) => agent.version ?? '—' }
]; ];
return ( return (
<div className="card"> <div className="page-grid">
<div className="card" style={{ gridColumn: 'span 2' }}>
<h1>Agents</h1> <h1>Agents</h1>
<p className="muted">Live state of BlackRoad OS agents with their capabilities and health.</p> <p className="muted">
Fleet of orchestrators and domain specialists powered by blackroad-os-operator. Filter by status or tags and open
a row for metadata.
</p>
</div>
<div className="card">
<div className="filter-grid">
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value as Agent['status'] | 'all')}>
{statusFilters.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label>
Search
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Name or tag" />
</label>
<div className="filter-helper">
<p className="muted small">Click any row to inspect raw metadata.</p>
</div>
</div>
{error && <div className="error-banner">{error.message}</div>} {error && <div className="error-banner">{error.message}</div>}
{isLoading && <p className="muted">Loading agents</p>} {isLoading && <p className="muted">Loading agents</p>}
<div className="table-wrapper"> <div className="table-wrapper">
<GenericTable <GenericTable
columns={columns} columns={columns}
data={agents} data={filtered}
emptyText={isLoading ? 'Loading…' : 'No agents found'} emptyText={isLoading ? 'Loading…' : 'No agents found'}
onRowClick={(agent) => router.push(`/agents/${agent.id}`)} onRowClick={(agent) => setSelected(agent)}
/> />
</div> </div>
<p className="muted" style={{ marginTop: 12 }}> </div>
Click a row to view details and recent tasks.
</p> <div className="card">
<h3>Agent detail</h3>
{!selected && <p className="muted">Select an agent to view detail.</p>}
{selected && (
<div className="agent-detail">
<div className="pill-row">
<span className="muted small">ID</span>
<span className="pill">{selected.id}</span>
</div>
<p className="muted small">Role: {selected.role}</p>
<p className="muted small">Heartbeat: {new Date(selected.lastHeartbeat).toLocaleString()}</p>
<p className="muted small">Tags: {selected.tags?.join(', ') || '—'}</p>
<p className="muted small">Version: {selected.version ?? '—'}</p>
<pre className="code-block">{JSON.stringify(selected.metadata || selected, null, 2)}</pre>
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -4,14 +4,30 @@ import { DashboardView } from './page';
timeZoneMock(); timeZoneMock();
vi.mock('@/hooks/useFinanceSummary', () => ({ vi.mock('@/hooks/useSystemOverview', () => ({
useFinanceSummary: () => ({ useSystemOverview: () => ({
data: { data: {
currency: 'USD', overallStatus: 'healthy',
cashBalance: 1500000, services: [
monthlyBurnRate: 250000, { id: 'api', name: 'api', status: 'healthy', lastChecked: '2025-01-01T00:00:00Z' },
runwayMonths: 6, { id: 'operator', name: 'operator', status: 'degraded', lastChecked: '2025-01-01T00:00:00Z' }
generatedAt: '2025-01-01T00:00:00Z' ],
jobsProcessedLast24h: 10,
errorsLast24h: 1
},
isLoading: false,
error: null
})
}));
vi.mock('@/hooks/useFinanceSnapshot', () => ({
useFinanceSnapshot: () => ({
data: {
walletBalanceUsd: 150000,
monthlyInfraCostUsd: 12000,
estimatedSavingsUsd: 24000,
monthlyRevenueUsd: 32000,
timestamp: '2025-01-01T00:00:00Z'
}, },
isLoading: false, isLoading: false,
error: null error: null
@@ -21,8 +37,8 @@ vi.mock('@/hooks/useFinanceSummary', () => ({
vi.mock('@/hooks/useAgents', () => ({ vi.mock('@/hooks/useAgents', () => ({
useAgents: () => ({ useAgents: () => ({
data: [ data: [
{ id: 'a1', name: 'Agent One', status: 'online', capabilities: [], domain: 'finance' }, { id: 'a1', name: 'Agent One', role: 'ops', status: 'running', lastHeartbeat: '2025-01-01T00:00:00Z' },
{ id: 'a2', name: 'Agent Two', status: 'degraded', capabilities: [], domain: 'research' } { id: 'a2', name: 'Agent Two', role: 'finance', status: 'idle', lastHeartbeat: '2025-01-01T00:00:00Z' }
], ],
isLoading: false, isLoading: false,
error: null error: null
@@ -32,7 +48,14 @@ vi.mock('@/hooks/useAgents', () => ({
vi.mock('@/hooks/useEvents', () => ({ vi.mock('@/hooks/useEvents', () => ({
useEvents: () => ({ useEvents: () => ({
events: [ events: [
{ id: 'e1', type: 'task.completed', source: 'agent:finance', timestamp: '2025-01-01T00:00:00Z' } {
id: 'e1',
type: 'task.completed',
source: 'agent:finance',
timestamp: '2025-01-01T00:00:00Z',
summary: 'done',
severity: 'info'
}
], ],
isLoading: false, isLoading: false,
error: null error: null
@@ -45,14 +68,14 @@ function timeZoneMock() {
} }
describe('DashboardView', () => { describe('DashboardView', () => {
it('shows finance and agent stats', () => { it('renders core stats', () => {
render(<DashboardView />); render(<DashboardView />);
expect(screen.getByText('Cash Balance')).toBeInTheDocument(); expect(screen.getByText('Prism Console Overview')).toBeInTheDocument();
expect(screen.getByText('$1,500,000')).toBeInTheDocument(); expect(screen.getByText('BlackRoad OS')).toBeInTheDocument();
expect(screen.getByText('Runway (months)')).toBeInTheDocument(); expect(screen.getByText('OS Wallet Balance')).toBeInTheDocument();
expect(screen.getByText('6.0')).toBeInTheDocument(); expect(screen.getByText('Estimated Savings')).toBeInTheDocument();
expect(screen.getByText('Total agents')).toBeInTheDocument(); expect(screen.getByText('Active Agents')).toBeInTheDocument();
expect(screen.getAllByText('2').length).toBeGreaterThan(0); expect(screen.getByText('Recent Events')).toBeInTheDocument();
}); });
}); });

View File

@@ -5,124 +5,184 @@ import { useMemo } from 'react';
import { StatCard } from '@/components/cards/StatCard'; import { StatCard } from '@/components/cards/StatCard';
import { useAgents } from '@/hooks/useAgents'; import { useAgents } from '@/hooks/useAgents';
import { useEvents } from '@/hooks/useEvents'; import { useEvents } from '@/hooks/useEvents';
import { useFinanceSummary } from '@/hooks/useFinanceSummary'; import { useFinanceSnapshot } from '@/hooks/useFinanceSnapshot';
import type { AgentSummary } from '@/types/agents'; import { useSystemOverview } from '@/hooks/useSystemOverview';
import { Agent, ServiceHealth } from '@/types';
function formatCurrency(value: number | undefined, currency = 'USD') { function formatCurrency(value: number | undefined, currency = 'USD') {
if (value === undefined || Number.isNaN(value)) return '—'; if (value === undefined || Number.isNaN(value)) return '—';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(value); return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
} }
function formatNumber(value: number | undefined, digits = 1) { function StatBlock({ title, children }: { title: string; children: React.ReactNode }) {
if (value === undefined || Number.isNaN(value)) return '—'; return (
return value.toFixed(digits); <div className="card">
<div className="card-header-row">
<h3>{title}</h3>
</div>
{children}
</div>
);
}
function renderServiceSummary(services: ServiceHealth[]) {
const healthy = services.filter((svc) => svc.status === 'healthy').length;
const degraded = services.filter((svc) => svc.status === 'degraded').length;
const down = services.filter((svc) => svc.status === 'down').length;
return `${healthy} healthy · ${degraded} degraded · ${down} down`;
} }
export function DashboardView() { export function DashboardView() {
const { data: finance, isLoading: financeLoading, error: financeError } = useFinanceSummary(); const { data: system, isLoading: systemLoading, error: systemError } = useSystemOverview();
const { data: agents, isLoading: agentsLoading, error: agentsError } = useAgents(); const { data: agents, isLoading: agentsLoading } = useAgents();
const { events, isLoading: eventsLoading, error: eventsError } = useEvents({ limit: 5 }); const { data: finance, isLoading: financeLoading } = useFinanceSnapshot();
const { events, isLoading: eventsLoading, error: eventsError } = useEvents({ limit: 6 });
const topAgents = useMemo(() => agents.slice(0, 5), [agents]); const topAgents = useMemo(() => agents.slice(0, 4), [agents]);
const serviceSummary = system?.services ? renderServiceSummary(system.services) : 'Awaiting health ping…';
return ( return (
<div className="grid dashboard-grid"> <div className="page-grid">
<div className="card" style={{ gridColumn: 'span 2' }}> <div className="card hero">
<div>
<p className="muted">BlackRoad OS Master Orchestration</p>
<h1>Prism Console Overview</h1> <h1>Prism Console Overview</h1>
<p className="muted">Operational snapshot across agents, finance, and recent events.</p> <p className="muted">
Operator cockpit for health, agents, treasury, and RoadChain history. Connects to blackroad-os-api for live
telemetry.
</p>
</div>
<div className="hero-meta">
<div className="muted">System status</div>
<div className={`status-pill ${system?.overallStatus ?? 'unknown'}`}>{system?.overallStatus ?? 'checking'}</div>
<div className="muted small">{serviceSummary}</div>
</div>
</div> </div>
<div className="card stat-grid"> <div className="card stat-grid" style={{ gridColumn: 'span 2' }}>
<h3>Finance</h3> <div className="card-header-row">
<h3>Health snapshot</h3>
{systemError && <div className="error-banner">{systemError.message}</div>}
</div>
<div className="stat-grid-inner"> <div className="stat-grid-inner">
<StatCard <StatCard
label="Cash Balance" label="BlackRoad OS"
value={financeLoading ? 'Loading…' : formatCurrency(finance?.cashBalance, finance?.currency)} value={systemLoading ? 'Loading…' : system?.overallStatus ?? 'unknown'}
helpText={financeError ? financeError.message : 'Live from finance summary'} helpText={serviceSummary}
/> />
<StatCard <StatCard
label="Runway (months)" label="Services monitored"
value={financeLoading ? 'Loading…' : formatNumber(finance?.runwayMonths)} value={systemLoading ? '…' : system?.services.length ?? 0}
helpText="How long we can operate on current burn" helpText="API, operator, docs, and sibling surfaces"
/> />
<StatCard <StatCard
label="Monthly Burn" label="Jobs processed (24h)"
value={financeLoading ? 'Loading…' : formatCurrency(finance?.monthlyBurnRate, finance?.currency)} value={systemLoading ? '…' : system?.jobsProcessedLast24h ?? '—'}
delta="miner-inspired throughput"
/> />
<StatCard <StatCard
label="Active Revenue" label="Errors (24h)"
value={financeLoading ? 'Loading…' : finance?.mrr ? formatCurrency(finance.mrr, finance.currency) : '—'} value={systemLoading ? '…' : system?.errorsLast24h ?? 0}
helpText={finance?.arr ? `ARR: ${formatCurrency(finance.arr, finance.currency)}` : 'MRR/ARR optional'} helpText={system?.notes}
/> />
</div> </div>
</div> </div>
<div className="card stat-grid"> <div className="card stat-grid">
<h3>Agents</h3> <div className="card-header-row">
<h3>Finance pulse</h3>
</div>
<div className="stat-grid-inner"> <div className="stat-grid-inner">
<StatCard <StatCard
label="Total agents" label="OS Wallet Balance"
value={agentsLoading ? 'Loading…' : agents.length} value={financeLoading ? 'Loading…' : formatCurrency(finance?.walletBalanceUsd)}
helpText={agentsError ? agentsError.message : 'Online + offline agents from operator'} helpText="Treasury held by Prism"
/> />
<StatCard <StatCard
label="Online" label="Monthly Infra Cost"
value={agentsLoading ? '…' : agents.filter((a) => a.status === 'online').length} value={financeLoading ? '…' : formatCurrency(finance?.monthlyInfraCostUsd)}
helpText="Cloud + hardware"
/> />
<StatCard <StatCard
label="Degraded" label="Estimated Savings"
value={agentsLoading ? '…' : agents.filter((a) => a.status === 'degraded').length} value={financeLoading ? '…' : formatCurrency(finance?.estimatedSavingsUsd)}
helpText="Automation offset of SaaS/tooling"
/> />
<StatCard <StatCard
label="Domains" label="Monthly Revenue"
value={agentsLoading ? '…' : new Set(agents.map((a) => a.domain || 'unknown')).size} value={financeLoading ? '…' : formatCurrency(finance?.monthlyRevenueUsd)}
helpText="Distinct domains covered" helpText="For commercial runs"
/> />
</div> </div>
</div> </div>
<div className="card" style={{ gridColumn: 'span 2' }}> <StatBlock title="Service detail">
<h3>Recent Events</h3> {systemLoading && <p className="muted">Loading service health</p>}
{!systemLoading && system && (
<ul className="event-list">
{system.services.map((svc) => (
<li key={svc.id} className="event-item">
<div>
<div className="muted">{svc.name}</div>
<div className="muted small">Last checked: {new Date(svc.lastChecked).toLocaleString()}</div>
</div>
<div className="pill-row">
<span className={`status-pill ${svc.status}`}>{svc.status}</span>
{svc.latencyMs && <span className="muted small">{svc.latencyMs} ms</span>}
</div>
</li>
))}
</ul>
)}
</StatBlock>
<StatBlock title="Recent Events">
{eventsError && <div className="error-banner">{eventsError.message}</div>} {eventsError && <div className="error-banner">{eventsError.message}</div>}
{eventsLoading && <p className="muted">Loading events</p>} {eventsLoading && <p className="muted">Loading events</p>}
{!eventsLoading && events.length === 0 && <p className="muted">No events found.</p>} {!eventsLoading && events.length === 0 && <p className="muted">No events yet.</p>}
{events.length > 0 && ( {events.length > 0 && (
<ul className="event-list"> <ul className="event-list">
{events.map((event) => ( {events.map((event) => (
<li key={event.id} className="event-item"> <li key={event.id} className="event-item">
<div> <div>
<div className="muted">{new Date(event.timestamp).toLocaleString()}</div> <div className="muted small">{new Date(event.timestamp).toLocaleString()}</div>
<div className="event-type">{event.type}</div> <div className="event-type">{event.type}</div>
<div className="muted">{event.source}</div> <div className="muted small">{event.source}</div>
</div>
<div className="pill-row">
<span className={`status-pill ${event.severity || 'info'}`}>{event.severity || 'info'}</span>
<span>{event.summary}</span>
</div> </div>
{event.summary && <div>{event.summary}</div>}
</li> </li>
))} ))}
</ul> </ul>
)} )}
<Link href="/events" className="muted"> <Link href="/events" className="muted">
View full stream Explore full RoadChain
</Link> </Link>
</div> </StatBlock>
<div className="card" style={{ gridColumn: 'span 2' }}> <StatBlock title="Active Agents">
<h3>Active Agents</h3>
{topAgents.length === 0 && !agentsLoading && <p className="muted">No agents loaded.</p>}
{agentsLoading && <p className="muted">Loading agents</p>} {agentsLoading && <p className="muted">Loading agents</p>}
{!agentsLoading && topAgents.length === 0 && <p className="muted">No agents registered.</p>}
{topAgents.length > 0 && ( {topAgents.length > 0 && (
<ul className="agent-list"> <ul className="agent-list">
{topAgents.map((agent: AgentSummary) => ( {topAgents.map((agent: Agent) => (
<li key={agent.id} className="agent-item"> <li key={agent.id} className="agent-item">
<div> <div>
<div className="agent-name">{agent.name}</div> <div className="agent-name">{agent.name}</div>
<div className="muted">{agent.domain || 'unspecified domain'}</div> <div className="muted small">{agent.role}</div>
<div className="muted small">Tags: {agent.tags?.join(', ') || '—'}</div>
</div>
<div className="pill-row">
<span className={`status-pill ${agent.status}`}>{agent.status}</span>
<span className="muted small">Heartbeat {new Date(agent.lastHeartbeat).toLocaleTimeString()}</span>
</div> </div>
<div className={`status-pill ${agent.status}`}>{agent.status}</div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
</div> </StatBlock>
</div> </div>
); );
} }

View File

@@ -1,60 +1,163 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { GenericTable } from '@/components/tables/GenericTable'; import { GenericTable } from '@/components/tables/GenericTable';
import { useEvents } from '@/hooks/useEvents'; import { useEvents } from '@/hooks/useEvents';
import type { EventRecord } from '@/types/events'; import { EventRecord, RoadChainBlock } from '@/types';
import { getRoadChainBlocks } from '@/lib/apiClient';
const severities: (EventRecord['severity'] | 'all')[] = ['all', 'info', 'warning', 'error'];
export default function EventsPage() { export default function EventsPage() {
const [limit, setLimit] = useState<number | undefined>(25); const [tab, setTab] = useState<'events' | 'roadchain'>('events');
const [type, setType] = useState(''); const [severity, setSeverity] = useState<EventRecord['severity'] | 'all'>('all');
const [source, setSource] = useState(''); const [search, setSearch] = useState('');
const { events, isLoading, error, setParams, refetch } = useEvents({ limit }); const { events, isLoading, error, setParams } = useEvents({ limit: 50 });
const [blocks, setBlocks] = useState<RoadChainBlock[]>([]);
const [blocksLoading, setBlocksLoading] = useState(true);
const [blocksError, setBlocksError] = useState<Error | null>(null);
const [expanded, setExpanded] = useState<number | null>(null);
const applyFilters = () => { useEffect(() => {
setParams({ limit, type: type || undefined, source: source || undefined }); setBlocksLoading(true);
refetch(); getRoadChainBlocks()
}; .then((res) => {
setBlocks(res);
setBlocksError(null);
})
.catch((err: Error) => setBlocksError(err))
.finally(() => setBlocksLoading(false));
}, []);
useEffect(() => {
const params: { severity?: EventRecord['severity']; search?: string } = {};
if (severity !== 'all') params.severity = severity;
if (search) params.search = search;
setParams(params);
}, [severity, search, setParams]);
const filtered = useMemo(() => {
return events.filter((event) => {
const matchesSeverity = severity === 'all' ? true : event.severity === severity;
const text = search.toLowerCase();
const matchesSearch = text
? event.summary.toLowerCase().includes(text) || event.source.toLowerCase().includes(text)
: true;
return matchesSeverity && matchesSearch;
});
}, [events, severity, search]);
const columns = [ const columns = [
{ header: 'Timestamp', accessor: (event: EventRecord) => new Date(event.timestamp).toLocaleString() }, { header: 'Timestamp', accessor: (event: EventRecord) => new Date(event.timestamp).toLocaleString() },
{ header: 'Type', accessor: (event: EventRecord) => event.type }, { header: 'Type', accessor: (event: EventRecord) => event.type },
{ header: 'Source', accessor: (event: EventRecord) => event.source }, { header: 'Source', accessor: (event: EventRecord) => event.source },
{
header: 'Severity',
accessor: (event: EventRecord) => <span className={`status-pill ${event.severity || 'info'}`}>{event.severity || 'info'}</span>
},
{ header: 'Summary', accessor: (event: EventRecord) => event.summary || '—' } { header: 'Summary', accessor: (event: EventRecord) => event.summary || '—' }
]; ];
return ( const activeBlockEvents = useMemo(() => {
<div className="card"> if (!expanded) return [] as EventRecord[];
<h1>Events</h1> const block = blocks.find((b) => b.height === expanded);
<p className="muted">Recent events across agents and finance systems.</p> if (!block) return [] as EventRecord[];
return block.eventIds
.map((id) => events.find((evt) => evt.id === id))
.filter((evt): evt is EventRecord => Boolean(evt));
}, [blocks, expanded, events]);
return (
<div className="page-grid">
<div className="card" style={{ gridColumn: 'span 2' }}>
<h1>Events / RoadChain</h1>
<p className="muted">
Stream of operator, agent, and finance events plus RoadChain block explorer. Filters are client-side until the API
provides query parameters.
</p>
<div className="tab-row">
<button className={tab === 'events' ? 'pill active' : 'pill'} onClick={() => setTab('events')}>
Events
</button>
<button className={tab === 'roadchain' ? 'pill active' : 'pill'} onClick={() => setTab('roadchain')}>
RoadChain
</button>
</div>
</div>
{tab === 'events' && (
<div className="card" style={{ gridColumn: 'span 2' }}>
<div className="filter-grid"> <div className="filter-grid">
<label> <label>
Type Severity
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="task.completed" /> <select value={severity} onChange={(e) => setSeverity(e.target.value as EventRecord['severity'] | 'all')}>
{severities.map((level) => (
<option key={level}>{level}</option>
))}
</select>
</label> </label>
<label> <label>
Source Search
<input value={source} onChange={(e) => setSource(e.target.value)} placeholder="agent:finance" /> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search summary or source" />
</label> </label>
<label>
Limit
<input
type="number"
min={1}
max={200}
value={limit ?? 0}
onChange={(e) => setLimit(Number(e.target.value))}
/>
</label>
<button className="button" onClick={applyFilters} disabled={isLoading}>
Apply
</button>
</div> </div>
{error && <div className="error-banner">{error.message}</div>} {error && <div className="error-banner">{error.message}</div>}
{isLoading && <p className="muted">Loading events</p>} {isLoading && <p className="muted">Loading events</p>}
<GenericTable columns={columns} data={events} emptyText={isLoading ? 'Loading…' : 'No events found'} /> <GenericTable columns={columns} data={filtered} emptyText={isLoading ? 'Loading…' : 'No events found'} />
</div>
)}
{tab === 'roadchain' && (
<div className="card" style={{ gridColumn: 'span 2' }}>
<h3>RoadChain blocks</h3>
<p className="muted small">TODO: replace with real /roadchain endpoint output.</p>
{blocksError && <div className="error-banner">{blocksError.message}</div>}
{blocksLoading && <p className="muted">Loading blocks</p>}
{!blocksLoading && blocks.length === 0 && <p className="muted">No RoadChain data yet.</p>}
{!blocksLoading && blocks.length > 0 && (
<ul className="event-list">
{blocks.map((block) => (
<li key={block.hash} className="event-item" onClick={() => setExpanded(block.height)}>
<div>
<div className="muted small">Height {block.height}</div>
<div className="muted small">Hash {block.hash.slice(0, 10)}</div>
<div className="muted small">Timestamp {new Date(block.timestamp).toLocaleString()}</div>
</div>
<div className="pill-row">
<span className="pill">{block.eventIds.length} events</span>
<span className="pill subtle">prev {block.prevHash.slice(0, 10)}</span>
</div>
</li>
))}
</ul>
)}
{expanded && (
<div className="card nested-card">
<h4>Block {expanded} events</h4>
{activeBlockEvents.length === 0 && <p className="muted">Events not loaded for this block.</p>}
{activeBlockEvents.length > 0 && (
<ul className="event-list">
{activeBlockEvents.map((event) => (
<li key={event.id} className="event-item">
<div>
<div className="muted small">{new Date(event.timestamp).toLocaleString()}</div>
<div className="event-type">{event.type}</div>
<div className="muted small">{event.source}</div>
</div>
<div className="pill-row">
<span className={`status-pill ${event.severity || 'info'}`}>{event.severity || 'info'}</span>
<span>{event.summary}</span>
</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,206 +1,95 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { StatCard } from '@/components/cards/StatCard'; import { useFinanceSnapshot } from '@/hooks/useFinanceSnapshot';
import { GenericTable } from '@/components/tables/GenericTable'; import { FinanceSnapshot } from '@/types';
import { fetchCashForecast, fetchStatements } from '@/services/financeService';
import { useFinanceSummary } from '@/hooks/useFinanceSummary';
import type { CashForecast, FinancialStatements, StatementLineItem } from '@/types/finance';
const demoPeriods = ['2025-01', '2025-Q1', '2024-12']; function formatCurrency(value: number | undefined) {
if (value === undefined) return '—';
function formatMoney(amount: number | undefined, currency = 'USD') { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value);
if (amount === undefined) return '—';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(amount);
} }
function renderLineItems(items: StatementLineItem[]) { function TrendList({ history }: { history: NonNullable<FinanceSnapshot['history']> }) {
return items.map((item) => ( return (
<tr key={item.account}> <ul className="trend-list">
<td>{item.label}</td> {history.map((item) => (
<td>{formatMoney(item.amount)}</td> <li key={item.label} className="trend-row">
</tr> <div>
)); <div className="muted small">{item.label}</div>
<div className="muted small">Infra: {formatCurrency(item.costs)}</div>
</div>
<div className="trend-bars">
<div className="trend-bar balance" style={{ width: `${Math.min(item.balance / 2000, 100)}%` }} />
<div className="trend-bar savings" style={{ width: `${Math.min(item.savings / 120, 100)}%` }} />
</div>
<div className="muted small">Wallet: {formatCurrency(item.balance)}</div>
</li>
))}
</ul>
);
} }
export default function FinancePage() { export default function FinancePage() {
const { data: summary, isLoading: summaryLoading, error: summaryError } = useFinanceSummary(); const { data, isLoading, error } = useFinanceSnapshot();
const [forecast, setForecast] = useState<CashForecast | null>(null);
const [statements, setStatements] = useState<FinancialStatements | null>(null);
const [period, setPeriod] = useState(demoPeriods[0]);
const [loadingForecast, setLoadingForecast] = useState(true);
const [loadingStatements, setLoadingStatements] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => { const financeNotes = useMemo(() => {
setLoadingForecast(true); if (!data?.notes) return 'Automation offsetting SaaS/tooling costs and operator time saved.';
fetchCashForecast() return data.notes;
.then(setForecast) }, [data]);
.catch((err) => setError(err.message))
.finally(() => setLoadingForecast(false));
}, []);
useEffect(() => {
setLoadingStatements(true);
fetchStatements(period)
.then((result) => {
setStatements(result);
setError(null);
})
.catch((err) => setError(err.message))
.finally(() => setLoadingStatements(false));
}, [period]);
return ( return (
<div className="grid"> <div className="page-grid">
<div className="card" style={{ gridColumn: 'span 2' }}> <div className="card" style={{ gridColumn: 'span 2' }}>
<h1>Finance</h1> <h1>Finance</h1>
<p className="muted">Runway, burn, and statements pulled from blackroad-os-api.</p> <p className="muted">
Treasury and wallet-inspired summary for BlackRoad OS. Data sourced from blackroad-os-api with fallbacks until the
live endpoint is ready.
</p>
</div> </div>
<div className="card stat-grid" style={{ gridColumn: 'span 2' }}> <div className="card stat-grid" style={{ gridColumn: 'span 2' }}>
<h3>Summary</h3> <div className="card-header-row">
{summaryError && <div className="error-banner">{summaryError.message}</div>} <h3>Snapshot</h3>
{error && <div className="error-banner">{error.message}</div>}
</div>
<div className="stat-grid-inner"> <div className="stat-grid-inner">
<StatCard <div className="stat-card">
label="Cash balance" <div className="stat-label">OS Wallet Balance</div>
value={summaryLoading ? 'Loading…' : formatMoney(summary?.cashBalance, summary?.currency)} <div className="stat-value">{isLoading ? 'Loading…' : formatCurrency(data?.walletBalanceUsd)}</div>
helpText="Available cash" <div className="stat-help">Funds available for automation + infra</div>
/> </div>
<StatCard <div className="stat-card">
label="Runway" <div className="stat-label">Monthly Infra Cost</div>
value={summaryLoading ? 'Loading…' : `${summary?.runwayMonths ?? '—'} months`} <div className="stat-value">{isLoading ? '…' : formatCurrency(data?.monthlyInfraCostUsd)}</div>
helpText="Based on monthly burn" <div className="stat-help">Cloud, edge devices, network</div>
/> </div>
<StatCard <div className="stat-card">
label="Monthly burn" <div className="stat-label">Estimated Monthly Savings</div>
value={summaryLoading ? 'Loading…' : formatMoney(summary?.monthlyBurnRate, summary?.currency)} <div className="stat-value">{isLoading ? '…' : formatCurrency(data?.estimatedSavingsUsd)}</div>
/> <div className="stat-help">Automation offset of SaaS and manual ops</div>
<StatCard </div>
label="MRR / ARR" <div className="stat-card">
value={summaryLoading ? 'Loading…' : `${formatMoney(summary?.mrr, summary?.currency)} / ${formatMoney(summary?.arr, summary?.currency)}`} <div className="stat-label">Monthly Revenue</div>
/> <div className="stat-value">{isLoading ? '…' : formatCurrency(data?.monthlyRevenueUsd)}</div>
<div className="stat-help">Optional commercial runs</div>
</div> </div>
</div> </div>
<div className="card" style={{ gridColumn: 'span 2' }}>
<h3>Cash Forecast</h3>
{loadingForecast && <p className="muted">Loading forecast</p>}
{forecast && (
<table className="table">
<thead>
<tr>
<th>Start</th>
<th>End</th>
<th>Net change</th>
<th>Ending balance</th>
</tr>
</thead>
<tbody>
{forecast.buckets.map((bucket) => (
<tr key={`${bucket.startDate}-${bucket.endDate}`}>
<td>{bucket.startDate}</td>
<td>{bucket.endDate}</td>
<td>{formatMoney(bucket.netChange, forecast.currency)}</td>
<td>{formatMoney(bucket.endingBalance, forecast.currency)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card" style={{ gridColumn: 'span 2' }}>
<h3>Statements</h3>
<label className="muted" htmlFor="period-select">
Period
</label>
<select id="period-select" value={period} onChange={(e) => setPeriod(e.target.value)}>
{demoPeriods.map((p) => (
<option key={p}>{p}</option>
))}
</select>
{loadingStatements && <p className="muted">Loading statements</p>}
{error && <div className="error-banner">{error}</div>}
{statements && (
<div className="grid" style={{ marginTop: 12 }}>
<div className="card">
<h4>Income Statement</h4>
<table className="table">
<tbody>
{renderLineItems(statements.incomeStatement.revenue)}
{renderLineItems(statements.incomeStatement.cogs)}
{renderLineItems(statements.incomeStatement.operatingExpenses)}
{renderLineItems(statements.incomeStatement.otherIncomeExpenses)}
<tr>
<td>
<strong>Net Income</strong>
</td>
<td>{formatMoney(statements.incomeStatement.netIncome, statements.incomeStatement.currency)}</td>
</tr>
</tbody>
</table>
</div> </div>
<div className="card"> <div className="card">
<h4>Balance Sheet</h4> <h3>Wallet trend</h3>
<table className="table"> {isLoading && <p className="muted">Loading history</p>}
<tbody> {data?.history ? <TrendList history={data.history} /> : <p className="muted">No trend data yet.</p>}
<tr>
<td colSpan={2}>
<strong>Assets</strong>
</td>
</tr>
{renderLineItems(statements.balanceSheet.assets)}
<tr>
<td colSpan={2}>
<strong>Liabilities</strong>
</td>
</tr>
{renderLineItems(statements.balanceSheet.liabilities)}
<tr>
<td colSpan={2}>
<strong>Equity</strong>
</td>
</tr>
{renderLineItems(statements.balanceSheet.equity)}
</tbody>
</table>
</div> </div>
<div className="card"> <div className="card">
<h4>Cash Flow</h4> <h3>Notes</h3>
<table className="table"> <p className="muted">{financeNotes}</p>
<tbody> <ul className="muted small">
<tr> <li>Automation offsetting SaaS/tooling costs</li>
<td colSpan={2}> <li>Operator time saved for incidents and provisioning</li>
<strong>Operating</strong> <li>Wallet framing for treasury visibility</li>
</td> </ul>
</tr>
{renderLineItems(statements.cashFlowStatement.operatingActivities)}
<tr>
<td colSpan={2}>
<strong>Investing</strong>
</td>
</tr>
{renderLineItems(statements.cashFlowStatement.investingActivities)}
<tr>
<td colSpan={2}>
<strong>Financing</strong>
</td>
</tr>
{renderLineItems(statements.cashFlowStatement.financingActivities)}
<tr>
<td>
<strong>Net change</strong>
</td>
<td>{formatMoney(statements.cashFlowStatement.netChangeInCash, statements.cashFlowStatement.currency)}</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,14 +1,18 @@
:root { :root {
color-scheme: light; color-scheme: dark;
--bg: #0b1021; --br-bg: #05070f;
--panel: #11162d; --br-surface: #0c1020;
--card: #151b34; --br-panel: #0f1428;
--text: #e8ecf7; --br-card: #121830;
--muted: #9aa5c0; --br-border-subtle: #1f2a46;
--accent: #7dd3fc; --br-accent: #6dd3ff;
--accent-strong: #38bdf8; --br-accent-soft: rgba(109, 211, 255, 0.15);
--border: #1f2946; --br-text-primary: #e8edf7;
--danger: #f87171; --br-text-muted: #9aa5c0;
--br-success: #3ee28f;
--br-warning: #f2c94c;
--br-danger: #f88484;
--br-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
} }
* { * {
@@ -18,15 +22,15 @@
body { body {
margin: 0; margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 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%), background: radial-gradient(circle at 20% 20%, rgba(109, 211, 255, 0.08), transparent 25%),
radial-gradient(circle at 80% 10%, rgba(99, 102, 241, 0.08), transparent 20%), radial-gradient(circle at 80% 10%, rgba(109, 211, 255, 0.06), transparent 20%),
var(--bg); var(--br-bg);
color: var(--text); color: var(--br-text-primary);
min-height: 100vh; min-height: 100vh;
} }
a { a {
color: var(--accent-strong); color: var(--br-accent);
text-decoration: none; text-decoration: none;
} }
@@ -38,100 +42,9 @@ main {
padding: 24px 32px 48px; padding: 24px 32px 48px;
} }
.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,
.card h4,
.card h1 {
margin-top: 0;
}
.button {
background: var(--accent-strong);
color: #0b1021;
border: none;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
box-shadow: 0 8px 20px rgba(56, 189, 248, 0.25);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.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;
}
.table-row-clickable {
cursor: pointer;
}
.muted {
color: var(--muted);
}
.status-ok {
color: #22c55e;
}
.status-warn {
color: #facc15;
}
.status-bad {
color: #f87171;
}
.error-banner {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
padding: 8px 12px;
border-radius: 8px;
color: var(--danger);
margin-bottom: 12px;
}
.app-shell { .app-shell {
display: grid; display: grid;
grid-template-columns: 260px 1fr; grid-template-columns: 240px 1fr;
min-height: 100vh; min-height: 100vh;
} }
@@ -139,16 +52,18 @@ main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, rgba(13, 19, 36, 0.9), rgba(12, 16, 32, 0.95));
} }
.app-shell-content { .app-shell-content {
flex: 1; flex: 1;
overflow-y: auto;
} }
.sidebar { .sidebar {
background: var(--panel); background: var(--br-panel);
border-right: 1px solid var(--border); border-right: 1px solid var(--br-border-subtle);
padding: 24px 16px; padding: 24px 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
@@ -166,7 +81,7 @@ main {
} }
.sidebar-subtitle { .sidebar-subtitle {
color: var(--muted); color: var(--br-text-muted);
font-size: 13px; font-size: 13px;
} }
@@ -176,83 +91,145 @@ main {
gap: 8px; gap: 8px;
} }
.sidebar-divider {
height: 1px;
background: var(--br-border-subtle);
margin: 8px 0;
}
.nav-link { .nav-link {
color: var(--text); color: var(--br-text-primary);
padding: 10px 12px; padding: 10px 12px;
border-radius: 8px; border-radius: 10px;
display: grid;
grid-template-columns: 24px 1fr;
align-items: center;
gap: 8px;
border: 1px solid transparent;
} }
.nav-link.active, .nav-link.active,
.nav-link:hover { .nav-link:hover {
background: rgba(125, 211, 252, 0.12); background: var(--br-accent-soft);
color: var(--accent-strong); border-color: var(--br-border-subtle);
color: var(--br-accent);
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
} }
.sidebar-footer { .sidebar-footer {
font-size: 12px; font-size: 12px;
margin-top: auto; margin-top: auto;
color: var(--br-text-muted);
} }
.topbar { .topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 12px;
padding: 16px 32px;
border-bottom: 1px solid var(--br-border-subtle);
background: rgba(15, 20, 40, 0.85);
backdrop-filter: blur(12px);
}
.topbar-group {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 10px;
padding: 16px 32px;
border-bottom: 1px solid var(--border);
background: rgba(17, 22, 45, 0.6);
backdrop-filter: blur(12px);
} }
.topbar-env { .topbar-env {
font-weight: 700; font-weight: 700;
} }
.topbar-status { .topbar-user {
display: flex; display: flex;
gap: 10px;
align-items: center; align-items: center;
gap: 8px; justify-content: flex-end;
} }
.topbar-user { .topbar-operator {
font-weight: 600; font-weight: 600;
} }
.status-pill { .avatar {
padding: 6px 10px; width: 36px;
border-radius: 999px; height: 36px;
text-transform: capitalize; border-radius: 10px;
border: 1px solid var(--border); background: var(--br-accent-soft);
display: grid;
place-items: center;
border: 1px solid var(--br-border-subtle);
} }
.status-pill.online { .page-grid {
background: rgba(34, 197, 94, 0.15); display: grid;
color: #22c55e; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px;
} }
.status-pill.offline { .card {
background: rgba(248, 113, 113, 0.15); background: var(--br-card);
color: #f87171; border: 1px solid var(--br-border-subtle);
border-radius: 14px;
padding: 16px;
box-shadow: var(--br-shadow);
} }
.status-pill.degraded { .hero {
background: rgba(250, 204, 21, 0.15); display: grid;
color: #facc15; grid-template-columns: 2fr 1fr;
gap: 12px;
align-items: center;
} }
.status-pill.unknown { .hero-meta {
background: rgba(148, 163, 184, 0.2); display: flex;
color: var(--muted); flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px dashed var(--br-border-subtle);
border-radius: 10px;
}
.card-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.stat-grid .stat-grid-inner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
} }
.stat-card { .stat-card {
border: 1px solid var(--border); border: 1px solid var(--br-border-subtle);
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 12px;
background: linear-gradient(135deg, rgba(56, 189, 248, 0.08), rgba(17, 24, 39, 0.8)); background: linear-gradient(135deg, var(--br-accent-soft), rgba(17, 24, 39, 0.8));
} }
.stat-label { .stat-label {
color: var(--muted); color: var(--br-text-muted);
font-size: 13px; font-size: 13px;
} }
@@ -262,27 +239,18 @@ main {
} }
.stat-delta { .stat-delta {
color: #22c55e; color: var(--br-success);
font-size: 12px; font-size: 12px;
} }
.stat-help { .stat-help {
color: var(--muted); color: var(--br-text-muted);
font-size: 12px; font-size: 12px;
} }
.dashboard-grid {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.stat-grid .stat-grid-inner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.event-list, .event-list,
.agent-list { .agent-list,
.trend-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0 0 12px; margin: 0 0 12px;
@@ -292,12 +260,13 @@ main {
} }
.event-item, .event-item,
.agent-item { .agent-item,
.trend-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px; padding: 12px;
border: 1px solid var(--border); border: 1px solid var(--br-border-subtle);
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
} }
@@ -319,18 +288,193 @@ main {
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--br-text-muted);
}
.filter-helper {
display: flex;
align-items: center;
} }
input, input,
select { select,
background: var(--panel); button.pill {
border: 1px solid var(--border); background: var(--br-panel);
border: 1px solid var(--br-border-subtle);
border-radius: 8px; border-radius: 8px;
padding: 8px; padding: 8px;
color: var(--text); color: var(--br-text-primary);
}
button.pill {
cursor: pointer;
border-radius: 999px;
}
button.pill.active {
background: var(--br-accent-soft);
color: var(--br-accent);
}
.button {
background: var(--br-accent);
color: var(--br-bg);
border: none;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
box-shadow: 0 8px 20px rgba(109, 211, 255, 0.25);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.badge,
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
background: var(--br-panel);
color: var(--br-text-primary);
border: 1px solid var(--br-border-subtle);
}
.pill.subtle {
background: transparent;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
color: var(--br-text-primary);
}
.table th,
.table td {
border: 1px solid var(--br-border-subtle);
padding: 8px 12px;
text-align: left;
}
.table-row-clickable {
cursor: pointer;
}
.table-row-clickable:hover {
background: rgba(255, 255, 255, 0.02);
}
.muted {
color: var(--br-text-muted);
}
.muted.small {
font-size: 12px;
}
.status-pill {
padding: 6px 10px;
border-radius: 999px;
text-transform: capitalize;
border: 1px solid var(--br-border-subtle);
background: var(--br-panel);
}
.status-pill.healthy,
.status-pill.ok {
background: rgba(62, 226, 143, 0.12);
color: var(--br-success);
}
.status-pill.running {
background: rgba(62, 226, 143, 0.12);
color: var(--br-success);
}
.status-pill.degraded,
.status-pill.warning {
background: rgba(242, 201, 76, 0.12);
color: var(--br-warning);
}
.status-pill.error,
.status-pill.down,
.status-pill.offline {
background: rgba(248, 132, 132, 0.12);
color: var(--br-danger);
}
.status-pill.idle,
.status-pill.info,
.status-pill.unknown {
background: rgba(154, 165, 192, 0.2);
color: var(--br-text-muted);
}
.error-banner {
background: rgba(248, 132, 132, 0.1);
border: 1px solid rgba(248, 132, 132, 0.3);
padding: 8px 12px;
border-radius: 8px;
color: var(--br-danger);
margin-bottom: 12px;
} }
.table-wrapper { .table-wrapper {
position: relative; position: relative;
} }
.pill-row {
display: flex;
gap: 8px;
align-items: center;
}
.tab-row {
display: flex;
gap: 8px;
margin-top: 8px;
}
.agent-detail .code-block {
background: var(--br-panel);
border: 1px solid var(--br-border-subtle);
padding: 12px;
border-radius: 12px;
color: var(--br-text-primary);
overflow-x: auto;
}
.trend-bars {
display: flex;
gap: 6px;
align-items: center;
flex: 1;
margin: 0 10px;
}
.trend-bar {
height: 8px;
border-radius: 999px;
background: var(--br-border-subtle);
flex: 1;
}
.trend-bar.balance {
background: var(--br-accent-soft);
}
.trend-bar.savings {
background: rgba(62, 226, 143, 0.2);
}
.nested-card {
margin-top: 12px;
}

View File

@@ -4,12 +4,17 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import type { Route } from 'next'; import type { Route } from 'next';
const links: { href: Route; label: string }[] = [ const navLinks: { href: Route; label: string; icon: string }[] = [
{ href: '/dashboard', label: 'Dashboard' }, { href: '/dashboard', label: 'Dashboard', icon: '🏠' },
{ href: '/agents', label: 'Agents' }, { href: '/agents', label: 'Agents', icon: '🛰️' },
{ href: '/finance', label: 'Finance' }, { href: '/finance', label: 'Finance', icon: '💠' },
{ href: '/events', label: 'Events' }, { href: '/events', label: 'Events', icon: '🧭' }
{ href: '/status', label: 'Status' } ];
const externalLinks = [
{ href: 'https://blackroad-os-docs.example.com', label: 'Docs', icon: '📘' },
{ href: 'https://blackroad-os-web.example.com', label: 'Web', icon: '🌐' },
{ href: 'https://github.com/BlackRoad-OS', label: 'GitHub', icon: '🐙' }
]; ];
export function Sidebar() { export function Sidebar() {
@@ -22,17 +27,31 @@ export function Sidebar() {
<div className="sidebar-subtitle">BlackRoad OS</div> <div className="sidebar-subtitle">BlackRoad OS</div>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{links.map((link) => { {navLinks.map((link) => {
const active = pathname?.startsWith(link.href); const active = pathname?.startsWith(link.href);
return ( return (
<Link key={link.href} href={link.href} className={active ? 'nav-link active' : 'nav-link'}> <Link key={link.href} href={link.href} className={active ? 'nav-link active' : 'nav-link'}>
{link.label} <span className="nav-icon" aria-hidden>
{link.icon}
</span>
<span>{link.label}</span>
</Link> </Link>
); );
})} })}
</nav> </nav>
<div className="sidebar-divider" aria-hidden />
<nav className="sidebar-nav">
{externalLinks.map((link) => (
<a key={link.href} href={link.href} className="nav-link" target="_blank" rel="noreferrer">
<span className="nav-icon" aria-hidden>
{link.icon}
</span>
<span>{link.label}</span>
</a>
))}
</nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<p className="muted">Read-only dashboards for agents, finance, and events.</p> <p className="muted">Operator cockpit for the BlackRoad OS orchestration universe.</p>
</div> </div>
</aside> </aside>
); );

View File

@@ -1,47 +1,47 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { deriveApiHealth } from '@/lib/apiClient';
import { useSystemOverview } from '@/hooks/useSystemOverview';
function getEnvironmentLabel() { function getEnvironmentLabel() {
return process.env.NEXT_PUBLIC_ENV || process.env.NODE_ENV || 'development'; return process.env.NEXT_PUBLIC_ENV || process.env.NODE_ENV || 'development';
} }
type ApiStatus = 'unknown' | 'online' | 'degraded';
export function TopBar() { export function TopBar() {
const [apiStatus, setApiStatus] = useState<ApiStatus>('unknown'); const { data, isLoading } = useSystemOverview();
const [latency, setLatency] = useState<number | null>(null); const apiHealth = deriveApiHealth(data ?? undefined);
useEffect(() => { const serviceSummary = useMemo(() => {
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; if (!data) return 'Checking services…';
if (!baseUrl) return; const up = data.services.filter((svc) => svc.status === 'healthy').length;
const degraded = data.services.filter((svc) => svc.status === 'degraded').length;
const controller = new AbortController(); const down = data.services.filter((svc) => svc.status === 'down').length;
const start = performance.now(); return `${up} healthy • ${degraded} degraded • ${down} down`;
fetch(`${baseUrl}/health`, { signal: controller.signal }) }, [data]);
.then((res) => {
const elapsed = Math.round(performance.now() - start);
setLatency(elapsed);
setApiStatus(res.ok ? 'online' : 'degraded');
})
.catch(() => setApiStatus('degraded'));
return () => controller.abort();
}, []);
return ( return (
<header className="topbar"> <header className="topbar">
<div> <div className="topbar-group">
<div className="muted" style={{ fontSize: 12 }}> <div className="muted" style={{ fontSize: 12 }}>
Environment Environment
</div> </div>
<div className="topbar-env">{getEnvironmentLabel()}</div> <div className="topbar-env">{getEnvironmentLabel()}</div>
</div> </div>
<div className="topbar-status"> <div className="topbar-group">
<span className={`status-pill ${apiStatus}`}>{apiStatus}</span> <span className={`status-pill ${apiHealth}`}>{apiHealth}</span>
{latency !== null && <span className="muted">{latency} ms</span>} <span className="muted small">{serviceSummary}</span>
{isLoading && <span className="muted small">Refreshing</span>}
</div>
<div className="topbar-user">
<div className="avatar"></div>
<div>
<div className="muted" style={{ fontSize: 12 }}>
Operator
</div>
<div className="topbar-operator">Prism Steward</div>
</div>
</div> </div>
<div className="topbar-user">Cecilia Orchestrator</div>
</header> </header>
); );
} }

View File

@@ -1,11 +1,36 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { AgentSummary, AgentTaskSummary } from '../types/agents'; import { Agent } from '@/types';
import { fetchAgentDetail, fetchAgentTasks } from '../services/agentsService'; import { getAgents } from '@/lib/apiClient';
type AgentTaskSummary = {
id: string;
type: string;
status: string;
createdAt: string;
updatedAt: string;
};
const mockTasks: AgentTaskSummary[] = [
{
id: 'task-001',
type: 'health.check',
status: 'completed',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: 'task-002',
type: 'finance.reconcile',
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
];
export function useAgentDetail(id: string | undefined) { export function useAgentDetail(id: string | undefined) {
const [agent, setAgent] = useState<AgentSummary | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
const [tasks, setTasks] = useState<AgentTaskSummary[]>([]); const [tasks, setTasks] = useState<AgentTaskSummary[]>([]);
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@@ -15,13 +40,13 @@ export function useAgentDetail(id: string | undefined) {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
Promise.all([fetchAgentDetail(id), fetchAgentTasks(id)]) getAgents()
.then(([agentDetail, agentTasks]) => { .then((agents) => {
if (!cancelled) { if (cancelled) return;
setAgent(agentDetail); const found = agents.find((a) => a.id === id) || null;
setTasks(agentTasks); setAgent(found);
setTasks(mockTasks);
setError(null); setError(null);
}
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
@@ -31,9 +56,7 @@ export function useAgentDetail(id: string | undefined) {
} }
}) })
.finally(() => { .finally(() => {
if (!cancelled) { if (!cancelled) setLoading(false);
setLoading(false);
}
}); });
return () => { return () => {

View File

@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { AgentSummary } from '../types/agents'; import { Agent } from '@/types';
import { fetchAgents } from '../services/agentsService'; import { getAgents } from '@/lib/apiClient';
export function useAgents() { export function useAgents() {
const [data, setData] = useState<AgentSummary[]>([]); const [data, setData] = useState<Agent[]>([]);
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@@ -13,23 +13,21 @@ export function useAgents() {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
fetchAgents() getAgents()
.then((res) => { .then((res) => {
if (!cancelled) { if (!cancelled) {
setData(res); setData(res);
setError(null); setError(null);
} }
}) })
.catch((err) => { .catch((err: Error) => {
if (!cancelled) { if (!cancelled) {
setError(err); setError(err);
setData([]); setData([]);
} }
}) })
.finally(() => { .finally(() => {
if (!cancelled) { if (!cancelled) setLoading(false);
setLoading(false);
}
}); });
return () => { return () => {

View File

@@ -1,8 +1,14 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { EventRecord } from '../types/events'; import { EventRecord } from '@/types';
import { fetchEvents, type EventQueryParams } from '../services/eventsService'; import { getEvents } from '@/lib/apiClient';
export type EventQueryParams = {
limit?: number;
severity?: string;
search?: string;
};
export function useEvents(initialParams: EventQueryParams = {}, pollIntervalMs?: number) { export function useEvents(initialParams: EventQueryParams = {}, pollIntervalMs?: number) {
const [params, setParams] = useState<EventQueryParams>(initialParams); const [params, setParams] = useState<EventQueryParams>(initialParams);
@@ -12,12 +18,12 @@ export function useEvents(initialParams: EventQueryParams = {}, pollIntervalMs?:
const load = (nextParams: EventQueryParams = params) => { const load = (nextParams: EventQueryParams = params) => {
setLoading(true); setLoading(true);
fetchEvents(nextParams) getEvents(nextParams)
.then((records) => { .then((records) => {
setEvents(records); setEvents(records);
setError(null); setError(null);
}) })
.catch((err) => { .catch((err: Error) => {
setError(err); setError(err);
setEvents([]); setEvents([]);
}) })
@@ -29,12 +35,23 @@ export function useEvents(initialParams: EventQueryParams = {}, pollIntervalMs?:
if (!pollIntervalMs) return; if (!pollIntervalMs) return;
const id = setInterval(() => load(params), pollIntervalMs); const id = setInterval(() => load(params), pollIntervalMs);
return () => clearInterval(id); return () => clearInterval(id);
}, [pollIntervalMs, params]); }, [pollIntervalMs]);
const filtered = useMemo(() => {
return events.filter((event) => {
const matchesSeverity = params.severity ? event.severity === params.severity : true;
const searchText = params.search?.toLowerCase().trim();
const matchesSearch = searchText
? event.summary.toLowerCase().includes(searchText) || event.source.toLowerCase().includes(searchText)
: true;
return matchesSeverity && matchesSearch;
});
}, [events, params.severity, params.search]);
const updateParams = (next: EventQueryParams) => { const updateParams = (next: EventQueryParams) => {
setParams(next); setParams(next);
load(next); load(next);
}; };
return { events, isLoading, error, params, setParams: updateParams, refetch: () => load(params) }; return { events: filtered, isLoading, error, params, setParams: updateParams, refetch: () => load(params) };
} }

View File

@@ -0,0 +1,36 @@
'use client';
import { useEffect, useState } from 'react';
import { FinanceSnapshot } from '@/types';
import { getFinanceSnapshot } from '@/lib/apiClient';
export function useFinanceSnapshot() {
const [data, setData] = useState<FinanceSnapshot | null>(null);
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
getFinanceSnapshot()
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { data, isLoading, error };
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useEffect, useState } from 'react';
import { SystemOverview } from '@/types';
import { getSystemOverview } from '@/lib/apiClient';
export function useSystemOverview() {
const [data, setData] = useState<SystemOverview | null>(null);
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
getSystemOverview()
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) {
setError(err);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { data, isLoading, error };
}

View File

@@ -1,16 +1,205 @@
export async function apiGet<T>(path: string): Promise<T> { import { ApiHealth, Agent, EventRecord, FinanceSnapshot, RoadChainBlock, ServiceHealth, SystemOverview } from '@/types';
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const res = await fetch(`${baseUrl}${path}`, { const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const DEFAULT_TIMEOUT_MS = 8000;
type HttpGetOptions = {
timeoutMs?: number;
};
export async function httpGet<T>(path: string, options: HttpGetOptions = {}): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'content-type': 'application/json' 'content-type': 'application/json'
} },
signal: controller.signal
}); });
if (!res.ok) { if (!response.ok) {
const text = await res.text().catch(() => ''); const text = await response.text().catch(() => '');
throw new Error(`API error ${res.status}: ${text}`); throw new Error(`API error ${response.status}: ${text}`);
} }
return res.json() as Promise<T>; return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
} }
export async function getSystemOverview(): Promise<SystemOverview> {
try {
return await httpGet<SystemOverview>('/system/overview');
} catch (error) {
console.warn('[api] falling back to mock system overview', error);
return mockSystemOverview;
}
}
export async function getAgents(): Promise<Agent[]> {
try {
return await httpGet<Agent[]>('/agents');
} catch (error) {
console.warn('[api] falling back to mock agents', error);
return mockAgents;
}
}
export async function getFinanceSnapshot(): Promise<FinanceSnapshot> {
try {
return await httpGet<FinanceSnapshot>('/finance/snapshot');
} catch (error) {
console.warn('[api] falling back to mock finance snapshot', error);
return mockFinanceSnapshot;
}
}
export async function getEvents(params: { limit?: number; severity?: string; search?: string } = {}): Promise<EventRecord[]> {
const query = new URLSearchParams();
if (params.limit) query.set('limit', String(params.limit));
if (params.severity) query.set('severity', params.severity);
if (params.search) query.set('q', params.search);
const qs = query.toString();
try {
return await httpGet<EventRecord[]>(qs ? `/events?${qs}` : '/events');
} catch (error) {
console.warn('[api] falling back to mock events', error);
return mockEvents;
}
}
export async function getRoadChainBlocks(): Promise<RoadChainBlock[]> {
try {
return await httpGet<RoadChainBlock[]>('/roadchain');
} catch (error) {
console.warn('[api] falling back to mock roadchain blocks', error);
return mockRoadChainBlocks;
}
}
export function deriveApiHealth(overall: SystemOverview | undefined): ApiHealth {
if (!overall) return 'unknown';
if (overall.overallStatus === 'healthy') return 'ok';
if (overall.overallStatus === 'degraded') return 'degraded';
return 'down';
}
const nowIso = new Date().toISOString();
const mockServices: ServiceHealth[] = [
{ id: 'api', name: 'blackroad-os-api', status: 'healthy', latencyMs: 48, lastChecked: nowIso },
{ id: 'operator', name: 'blackroad-os-operator', status: 'healthy', latencyMs: 72, lastChecked: nowIso },
{ id: 'core', name: 'blackroad-os-core', status: 'healthy', latencyMs: 35, lastChecked: nowIso },
{ id: 'doc', name: 'blackroad-os-docs', status: 'degraded', latencyMs: 140, lastChecked: nowIso }
];
const mockSystemOverview: SystemOverview = {
overallStatus: 'healthy',
services: mockServices,
jobsProcessedLast24h: 3480,
errorsLast24h: 7,
notes: 'Mock data fallback; connect blackroad-os-api to view live health.'
};
const mockAgents: Agent[] = [
{
id: 'br-agent-ops',
name: 'Ops Steward',
role: 'Orchestration + systems',
status: 'running',
lastHeartbeat: nowIso,
version: '1.8.2',
tags: ['ops', 'infra']
},
{
id: 'br-agent-finance',
name: 'Treasury Scout',
role: 'Finance + vendor intelligence',
status: 'idle',
lastHeartbeat: nowIso,
version: '1.3.0',
tags: ['finance', 'intel']
},
{
id: 'br-agent-triage',
name: 'Triage First Responder',
role: 'Incident response',
status: 'error',
lastHeartbeat: nowIso,
version: '1.1.4',
tags: ['incidents']
},
{
id: 'br-agent-field',
name: 'Field Node Watcher',
role: 'Edge hardware telemetry',
status: 'offline',
lastHeartbeat: nowIso,
version: '0.9.8',
tags: ['edge', 'hardware']
}
];
const mockFinanceSnapshot: FinanceSnapshot = {
timestamp: nowIso,
monthlyInfraCostUsd: 4280,
monthlyRevenueUsd: 9600,
estimatedSavingsUsd: 7200,
walletBalanceUsd: 182400,
notes: 'Mocked finance snapshot. Replace once /finance/snapshot is live.',
history: [
{ label: 'Jan', balance: 168000, costs: 4100, savings: 6500 },
{ label: 'Feb', balance: 172400, costs: 4200, savings: 6700 },
{ label: 'Mar', balance: 178800, costs: 4300, savings: 7000 },
{ label: 'Apr', balance: 182400, costs: 4280, savings: 7200 }
]
};
const mockEvents: EventRecord[] = [
{
id: 'evt-001',
timestamp: nowIso,
source: 'operator',
type: 'task.completed',
summary: 'Agent Ops Steward reconciled service definitions',
severity: 'info'
},
{
id: 'evt-002',
timestamp: nowIso,
source: 'finance',
type: 'report.generated',
summary: 'Treasury Scout produced updated cost offset model',
severity: 'warning'
},
{
id: 'evt-003',
timestamp: nowIso,
source: 'roadchain',
type: 'block.committed',
summary: 'RoadChain block 1182 sealed',
severity: 'info'
}
];
const mockRoadChainBlocks: RoadChainBlock[] = [
{
height: 1182,
hash: '0xabc123def456',
prevHash: '0xabc123def455',
timestamp: nowIso,
eventIds: ['evt-003', 'evt-001']
},
{
height: 1181,
hash: '0xabc123def455',
prevHash: '0xabc123def454',
timestamp: nowIso,
eventIds: ['evt-002']
}
];

1
src/types/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './prism';

64
src/types/prism.ts Normal file
View File

@@ -0,0 +1,64 @@
export type HealthStatus = 'healthy' | 'degraded' | 'down';
export interface ServiceHealth {
id: string;
name: string;
status: HealthStatus;
latencyMs?: number;
lastChecked: string;
}
export interface SystemOverview {
overallStatus: HealthStatus;
services: ServiceHealth[];
jobsProcessedLast24h?: number;
errorsLast24h?: number;
notes?: string;
}
export type AgentStatus = 'idle' | 'running' | 'error' | 'offline';
export interface Agent {
id: string;
name: string;
role: string;
status: AgentStatus;
lastHeartbeat: string;
version?: string;
tags?: string[];
metadata?: Record<string, unknown>;
}
export interface FinanceSnapshot {
timestamp: string;
monthlyInfraCostUsd?: number;
monthlyRevenueUsd?: number;
estimatedSavingsUsd?: number;
walletBalanceUsd?: number;
notes?: string;
history?: { label: string; balance: number; costs: number; savings: number }[];
}
export interface EventRecord {
id: string;
timestamp: string;
source: string;
type: string;
summary: string;
psShaInfinity?: string;
severity?: 'info' | 'warning' | 'error';
}
export interface RoadChainBlock {
height: number;
hash: string;
prevHash: string;
timestamp: string;
eventIds: string[];
}
export interface RoadChainBlockWithEvents extends RoadChainBlock {
events?: EventRecord[];
}
export type ApiHealth = 'ok' | 'degraded' | 'down' | 'unknown';