Merge commit '5c82a49a063dd86b4703690a277a2862482069fb'
This commit is contained in:
60
README.md
60
README.md
@@ -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.
|
|
||||||
|
|||||||
16
docs/PRISM_CONSOLE_ROADMAP.md
Normal file
16
docs/PRISM_CONSOLE_ROADMAP.md
Normal 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.
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<h1>Agents</h1>
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
<p className="muted">Live state of BlackRoad OS agents with their capabilities and health.</p>
|
<h1>Agents</h1>
|
||||||
{error && <div className="error-banner">{error.message}</div>}
|
<p className="muted">
|
||||||
{isLoading && <p className="muted">Loading agents…</p>}
|
Fleet of orchestrators and domain specialists powered by blackroad-os-operator. Filter by status or tags and open
|
||||||
<div className="table-wrapper">
|
a row for metadata.
|
||||||
<GenericTable
|
</p>
|
||||||
columns={columns}
|
</div>
|
||||||
data={agents}
|
|
||||||
emptyText={isLoading ? 'Loading…' : 'No agents found'}
|
<div className="card">
|
||||||
onRowClick={(agent) => router.push(`/agents/${agent.id}`)}
|
<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>}
|
||||||
|
{isLoading && <p className="muted">Loading agents…</p>}
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<GenericTable
|
||||||
|
columns={columns}
|
||||||
|
data={filtered}
|
||||||
|
emptyText={isLoading ? 'Loading…' : 'No agents found'}
|
||||||
|
onRowClick={(agent) => setSelected(agent)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
<p className="muted" style={{ marginTop: 12 }}>
|
|
||||||
Click a row to view details and recent tasks.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
<h1>Prism Console Overview</h1>
|
<div>
|
||||||
<p className="muted">Operational snapshot across agents, finance, and recent events.</p>
|
<p className="muted">BlackRoad OS – Master Orchestration</p>
|
||||||
|
<h1>Prism Console Overview</h1>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
'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 [limit, setLimit] = useState<number | undefined>(25);
|
||||||
@@ -14,46 +17,155 @@ export default function EventsPage() {
|
|||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
setParams({ limit, type: type || undefined, source: source || undefined });
|
setParams({ limit, type: type || undefined, source: source || undefined });
|
||||||
};
|
};
|
||||||
|
const [tab, setTab] = useState<'events' | 'roadchain'>('events');
|
||||||
|
const [severity, setSeverity] = useState<EventRecord['severity'] | 'all'>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBlocksLoading(true);
|
||||||
|
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]);
|
||||||
|
|
||||||
<div className="filter-grid">
|
return (
|
||||||
<label>
|
<div className="page-grid">
|
||||||
Type
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="task.completed" />
|
<h1>Events / RoadChain</h1>
|
||||||
</label>
|
<p className="muted">
|
||||||
<label>
|
Stream of operator, agent, and finance events plus RoadChain block explorer. Filters are client-side until the API
|
||||||
Source
|
provides query parameters.
|
||||||
<input value={source} onChange={(e) => setSource(e.target.value)} placeholder="agent:finance" />
|
</p>
|
||||||
</label>
|
<div className="tab-row">
|
||||||
<label>
|
<button className={tab === 'events' ? 'pill active' : 'pill'} onClick={() => setTab('events')}>
|
||||||
Limit
|
Events
|
||||||
<input
|
</button>
|
||||||
type="number"
|
<button className={tab === 'roadchain' ? 'pill active' : 'pill'} onClick={() => setTab('roadchain')}>
|
||||||
min={1}
|
RoadChain
|
||||||
max={200}
|
</button>
|
||||||
value={limit ?? 0}
|
</div>
|
||||||
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>}
|
{tab === 'events' && (
|
||||||
{isLoading && <p className="muted">Loading events…</p>}
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
<GenericTable columns={columns} data={events} emptyText={isLoading ? 'Loading…' : 'No events found'} />
|
<div className="filter-grid">
|
||||||
|
<label>
|
||||||
|
Severity
|
||||||
|
<select value={severity} onChange={(e) => setSeverity(e.target.value as EventRecord['severity'] | 'all')}>
|
||||||
|
{severities.map((level) => (
|
||||||
|
<option key={level}>{level}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Search
|
||||||
|
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search summary or source" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error.message}</div>}
|
||||||
|
{isLoading && <p className="muted">Loading events…</p>}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<div className="card" style={{ gridColumn: 'span 2' }}>
|
<div className="card">
|
||||||
<h3>Cash Forecast</h3>
|
<h3>Wallet trend</h3>
|
||||||
{loadingForecast && <p className="muted">Loading forecast…</p>}
|
{isLoading && <p className="muted">Loading history…</p>}
|
||||||
{forecast && (
|
{data?.history ? <TrendList history={data.history} /> : <p className="muted">No trend data yet.</p>}
|
||||||
<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>
|
||||||
|
|
||||||
<div className="card" style={{ gridColumn: 'span 2' }}>
|
<div className="card">
|
||||||
<h3>Statements</h3>
|
<h3>Notes</h3>
|
||||||
<label className="muted" htmlFor="period-select">
|
<p className="muted">{financeNotes}</p>
|
||||||
Period
|
<ul className="muted small">
|
||||||
</label>
|
<li>Automation offsetting SaaS/tooling costs</li>
|
||||||
<select id="period-select" value={period} onChange={(e) => setPeriod(e.target.value)}>
|
<li>Operator time saved for incidents and provisioning</li>
|
||||||
{demoPeriods.map((p) => (
|
<li>Wallet framing for treasury visibility</li>
|
||||||
<option key={p}>{p}</option>
|
</ul>
|
||||||
))}
|
|
||||||
</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 className="card">
|
|
||||||
<h4>Balance Sheet</h4>
|
|
||||||
<table className="table">
|
|
||||||
<tbody>
|
|
||||||
<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 className="card">
|
|
||||||
<h4>Cash Flow</h4>
|
|
||||||
<table className="table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={2}>
|
|
||||||
<strong>Operating</strong>
|
|
||||||
</td>
|
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
setError(null);
|
setTasks(mockTasks);
|
||||||
}
|
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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/hooks/useFinanceSnapshot.ts
Normal file
36
src/hooks/useFinanceSnapshot.ts
Normal 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 };
|
||||||
|
}
|
||||||
38
src/hooks/useSystemOverview.ts
Normal file
38
src/hooks/useSystemOverview.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
method: 'GET',
|
const DEFAULT_TIMEOUT_MS = 8000;
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json'
|
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',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`API error ${response.status}: ${text}`);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
return (await response.json()) as T;
|
||||||
const text = await res.text().catch(() => '');
|
} finally {
|
||||||
throw new Error(`API error ${res.status}: ${text}`);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
1
src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './prism';
|
||||||
64
src/types/prism.ts
Normal file
64
src/types/prism.ts
Normal 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';
|
||||||
Reference in New Issue
Block a user