Merge branch origin/codex/wire-up-prism-console-for-observability into main
This commit is contained in:
@@ -11,3 +11,7 @@ AGENTS_API_URL=
|
|||||||
PUBLIC_CONSOLE_URL=
|
PUBLIC_CONSOLE_URL=
|
||||||
NEXT_PUBLIC_CORE_API_URL=
|
NEXT_PUBLIC_CORE_API_URL=
|
||||||
NEXT_PUBLIC_AGENTS_API_URL=
|
NEXT_PUBLIC_AGENTS_API_URL=
|
||||||
|
|
||||||
|
# Prism Console dashboards
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
NEXT_PUBLIC_ENV=dev
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -1,25 +1,12 @@
|
|||||||
# 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, and operator workflows.
|
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.
|
||||||
|
|
||||||
## Deployment Quick Reference (Railway)
|
## Features
|
||||||
- **Build**: `npm run build`
|
- Overview dashboard with finance summary, agent counts, and latest events.
|
||||||
- **Start**: `npm start` (runs the standalone Next.js server with `HOST=0.0.0.0` and `PORT=${PORT:-8080}`)
|
- Agents list and detail drill-down with recent tasks.
|
||||||
- **Health**: `GET /health`
|
- Finance view with cash balance, runway, forecast buckets, and example statements.
|
||||||
```json
|
- Events stream with basic filters.
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"service": "prism-console"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Required env vars** (production):
|
|
||||||
- `PORT`: provided by Railway; the app listens on this port.
|
|
||||||
- `HOST`: set to `0.0.0.0` to bind to all interfaces (defaulted in `npm start`).
|
|
||||||
- `NODE_ENV`: set to `production` in Railway.
|
|
||||||
- `PUBLIC_CONSOLE_URL`: public URL for the console (used for links and health checks).
|
|
||||||
- `NEXT_PUBLIC_OPERATOR_API_URL` / `OPERATOR_API_URL`: base URL for the Operator API (public vs server-side).
|
|
||||||
- `NEXT_PUBLIC_CORE_API_URL` / `CORE_API_URL`: base URL for the Core API (public vs server-side).
|
|
||||||
- `NEXT_PUBLIC_AGENTS_API_URL` / `AGENTS_API_URL`: base URL for the Agents API (public vs server-side).
|
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
Install dependencies and run the dev server:
|
Install dependencies and run the dev server:
|
||||||
@@ -31,7 +18,40 @@ npm run dev
|
|||||||
|
|
||||||
Visit http://localhost:3000 during development.
|
Visit http://localhost:3000 during development.
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
Set up `.env.local` (see `.env.example`) with:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
NEXT_PUBLIC_ENV=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional optional URLs remain supported:
|
||||||
|
- `NEXT_PUBLIC_OPERATOR_API_URL` / `OPERATOR_API_URL`
|
||||||
|
- `NEXT_PUBLIC_CORE_API_URL` / `CORE_API_URL`
|
||||||
|
- `NEXT_PUBLIC_AGENTS_API_URL` / `AGENTS_API_URL`
|
||||||
|
- `PUBLIC_CONSOLE_URL`
|
||||||
|
|
||||||
|
## Deployment Quick Reference (Railway)
|
||||||
|
- **Build**: `npm run build`
|
||||||
|
- **Start**: `npm start` (runs the standalone Next.js server with `HOST=0.0.0.0` and `PORT=${PORT:-8080}`)
|
||||||
|
- **Health**: `GET /health`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "prism-console"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Production tips:
|
||||||
|
- `PORT`: provided by Railway; the app listens on this port.
|
||||||
|
- `HOST`: set to `0.0.0.0` to bind to all interfaces (defaulted in `npm start`).
|
||||||
|
- `NODE_ENV`: set to `production` in Railway.
|
||||||
|
- `NEXT_PUBLIC_API_BASE_URL`: target `blackroad-os-api` environment.
|
||||||
|
- `NEXT_PUBLIC_ENV`: label displayed in the console top bar.
|
||||||
|
|
||||||
## Project Notes
|
## Project Notes
|
||||||
- Next.js 16 with the App Router and TypeScript.
|
- Next.js 16 with the App Router and TypeScript.
|
||||||
- Production build outputs a standalone server (`.next/standalone`) suitable for Railway or container runtimes.
|
- Production build outputs a standalone server (`.next/standalone`) suitable for Railway or container runtimes.
|
||||||
- Additional informational endpoints live under `/api` (e.g., `/api/health`, `/api/info`, `/api/version`).
|
- Additional informational endpoints live under `/api` (e.g., `/api/health`, `/api/info`, `/api/version`).
|
||||||
|
- See `docs/overview.md` for a product-focused overview of Prism Console.
|
||||||
|
|||||||
32
docs/overview.md
Normal file
32
docs/overview.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Prism Console Overview
|
||||||
|
|
||||||
|
Prism Console is the internal observability and control panel for BlackRoad OS. It provides a human-friendly way to inspect the state of agents, finance, and cross-system events.
|
||||||
|
|
||||||
|
## What you can see today
|
||||||
|
|
||||||
|
- **Dashboard**: A single view of finance health, agent population, and recent events.
|
||||||
|
- **Agents**: List of registered agents, their domains and capabilities, with drill-down into task history.
|
||||||
|
- **Finance**: Read-only summary of cash balance, burn, runway, cash forecast, and example statements.
|
||||||
|
- **Events**: Filterable stream of recent system and agent events.
|
||||||
|
|
||||||
|
## Connectivity
|
||||||
|
|
||||||
|
The console talks to `blackroad-os-api` via the `NEXT_PUBLIC_API_BASE_URL` environment variable. Health indicators pull from `/health` on that base URL.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure environment variables (for example in `.env.local`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
NEXT_PUBLIC_ENV=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environments
|
||||||
|
|
||||||
|
You can point the console at different stacks by updating `NEXT_PUBLIC_API_BASE_URL` (and any `NEXT_PUBLIC_*` service URLs) to staging or production endpoints. The TopBar shows the current environment label.
|
||||||
2155
package-lock.json
generated
2155
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -8,7 +8,8 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"prism",
|
"prism",
|
||||||
@@ -26,12 +27,17 @@
|
|||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.6",
|
"@types/react": "^19.2.6",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/parser": "^8.47.0",
|
"@typescript-eslint/parser": "^8.47.0",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-config-next": "^16.0.3",
|
"eslint-config-next": "^16.0.3",
|
||||||
"typescript": "^5.9.3"
|
"jsdom": "^24.1.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^2.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/app/agents/Agents.test.tsx
Normal file
39
src/app/agents/Agents.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import AgentsPage from './page';
|
||||||
|
|
||||||
|
const push = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAgents', () => ({
|
||||||
|
useAgents: () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Agent One',
|
||||||
|
status: 'online',
|
||||||
|
lastHeartbeat: '2025-01-01T00:00:00Z',
|
||||||
|
capabilities: [{ id: 'c1', name: 'Analysis' }],
|
||||||
|
domain: 'finance'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AgentsPage', () => {
|
||||||
|
it('renders the table and navigates on row click', async () => {
|
||||||
|
render(<AgentsPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Agents')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Agent One')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Agent One'));
|
||||||
|
expect(push).toHaveBeenCalledWith('/agents/agent-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/app/agents/[id]/page.tsx
Normal file
71
src/app/agents/[id]/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useAgentDetail } from '@/hooks/useAgentDetail';
|
||||||
|
|
||||||
|
export default function AgentDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const { agent, tasks, isLoading, error } = useAgentDetail(params?.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h1>Agent Detail</h1>
|
||||||
|
{isLoading && <p className="muted">Loading agent…</p>}
|
||||||
|
{error && <div className="error-banner">{error.message}</div>}
|
||||||
|
{agent && (
|
||||||
|
<>
|
||||||
|
<div className="agent-header">
|
||||||
|
<div>
|
||||||
|
<div className="agent-name">{agent.name}</div>
|
||||||
|
<div className="muted">{agent.domain || 'no domain set'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`status-pill ${agent.status}`}>{agent.status}</div>
|
||||||
|
</div>
|
||||||
|
<div className="muted">Last heartbeat: {agent.lastHeartbeat || 'n/a'}</div>
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<strong>Capabilities:</strong>
|
||||||
|
<ul>
|
||||||
|
{agent.capabilities.map((capability) => (
|
||||||
|
<li key={capability.id}>
|
||||||
|
{capability.name} {capability.description && <span className="muted">— {capability.description}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Recent Tasks</h3>
|
||||||
|
{isLoading && <p className="muted">Loading tasks…</p>}
|
||||||
|
{!isLoading && tasks.length === 0 && <p className="muted">No tasks found.</p>}
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr key={task.id}>
|
||||||
|
<td>{task.id}</td>
|
||||||
|
<td>{task.type}</td>
|
||||||
|
<td>{task.status}</td>
|
||||||
|
<td>{new Date(task.createdAt).toLocaleString()}</td>
|
||||||
|
<td>{new Date(task.updatedAt).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,109 +1,42 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { GenericTable } from '@/components/tables/GenericTable';
|
||||||
type Agent = {
|
import { useAgents } from '@/hooks/useAgents';
|
||||||
id?: string;
|
import type { AgentSummary } from '@/types/agents';
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AgentsResponse = {
|
|
||||||
agents: Agent[];
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(true);
|
const { data: agents, isLoading, error } = useAgents();
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadAgents = async () => {
|
const columns = [
|
||||||
setLoading(true);
|
{ header: 'Name', accessor: (agent: AgentSummary) => agent.name },
|
||||||
try {
|
{ header: 'Domain', accessor: (agent: AgentSummary) => agent.domain || '—' },
|
||||||
const response = await fetch('/api/agents', { cache: 'no-store' });
|
{
|
||||||
const payload = (await response.json()) as AgentsResponse;
|
header: 'Status',
|
||||||
if (!response.ok) {
|
accessor: (agent: AgentSummary) => <span className={`status-pill ${agent.status}`}>{agent.status}</span>
|
||||||
throw new Error(payload.error || 'Unable to load agents');
|
},
|
||||||
}
|
{ header: 'Last heartbeat', accessor: (agent: AgentSummary) => agent.lastHeartbeat || 'n/a' },
|
||||||
setAgents(payload.agents || []);
|
{ header: 'Capabilities', accessor: (agent: AgentSummary) => agent.capabilities?.length ?? 0 }
|
||||||
setError(null);
|
];
|
||||||
} catch (err) {
|
|
||||||
setAgents([]);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to load agents');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAgent = async (agent: Agent) => {
|
|
||||||
try {
|
|
||||||
setMessage(null);
|
|
||||||
const response = await fetch('/api/agents/run', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ agentId: agent.id || agent.name })
|
|
||||||
});
|
|
||||||
const payload = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload?.error || 'Agent run failed');
|
|
||||||
}
|
|
||||||
setMessage(`Triggered ${agent.name || agent.id || 'agent'}`);
|
|
||||||
} catch (err) {
|
|
||||||
setMessage(null);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to trigger agent');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAgents();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="card">
|
||||||
<div className="card">
|
<h1>Agents</h1>
|
||||||
<h1>Agents</h1>
|
<p className="muted">Live state of BlackRoad OS agents with their capabilities and health.</p>
|
||||||
<p className="muted">Lists agents from the configured AGENTS_API_URL and triggers /agents/run.</p>
|
{error && <div className="error-banner">{error.message}</div>}
|
||||||
<button onClick={loadAgents} disabled={loading} style={{ marginTop: 8 }}>
|
{isLoading && <p className="muted">Loading agents…</p>}
|
||||||
{loading ? 'Loading…' : 'Refresh'}
|
<div className="table-wrapper">
|
||||||
</button>
|
<GenericTable
|
||||||
{message && <p className="status-ok" style={{ marginTop: 8 }}>{message}</p>}
|
columns={columns}
|
||||||
{error && <p className="status-bad" style={{ marginTop: 8 }}>{error}</p>}
|
data={agents}
|
||||||
</div>
|
emptyText={isLoading ? 'Loading…' : 'No agents found'}
|
||||||
|
onRowClick={(agent) => router.push(`/agents/${agent.id}`)}
|
||||||
<div className="card">
|
/>
|
||||||
<h3>Agent List</h3>
|
|
||||||
{agents.length === 0 && !loading && <p className="muted">No agents found.</p>}
|
|
||||||
{loading && <p className="muted">Loading…</p>}
|
|
||||||
{agents.length > 0 && (
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{agents.map((agent) => (
|
|
||||||
<tr key={agent.id || agent.name}>
|
|
||||||
<td>{agent.name || agent.id}</td>
|
|
||||||
<td className="muted">{agent.status || 'n/a'}</td>
|
|
||||||
<td className="muted">{agent.description || '—'}</td>
|
|
||||||
<td>
|
|
||||||
<button onClick={() => runAgent(agent)}>Run</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted" style={{ marginTop: 12 }}>
|
||||||
|
Click a row to view details and recent tasks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/app/dashboard/Dashboard.test.tsx
Normal file
58
src/app/dashboard/Dashboard.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { DashboardView } from './page';
|
||||||
|
|
||||||
|
timeZoneMock();
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useFinanceSummary', () => ({
|
||||||
|
useFinanceSummary: () => ({
|
||||||
|
data: {
|
||||||
|
currency: 'USD',
|
||||||
|
cashBalance: 1500000,
|
||||||
|
monthlyBurnRate: 250000,
|
||||||
|
runwayMonths: 6,
|
||||||
|
generatedAt: '2025-01-01T00:00:00Z'
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useAgents', () => ({
|
||||||
|
useAgents: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: 'a1', name: 'Agent One', status: 'online', capabilities: [], domain: 'finance' },
|
||||||
|
{ id: 'a2', name: 'Agent Two', status: 'degraded', capabilities: [], domain: 'research' }
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/useEvents', () => ({
|
||||||
|
useEvents: () => ({
|
||||||
|
events: [
|
||||||
|
{ id: 'e1', type: 'task.completed', source: 'agent:finance', timestamp: '2025-01-01T00:00:00Z' }
|
||||||
|
],
|
||||||
|
isLoading: false,
|
||||||
|
error: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
function timeZoneMock() {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US');
|
||||||
|
formatter.format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DashboardView', () => {
|
||||||
|
it('shows finance and agent stats', () => {
|
||||||
|
render(<DashboardView />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Cash Balance')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('$1,500,000')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Runway (months)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('6.0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Total agents')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
src/app/dashboard/page.tsx
Normal file
132
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { StatCard } from '@/components/cards/StatCard';
|
||||||
|
import { useAgents } from '@/hooks/useAgents';
|
||||||
|
import { useEvents } from '@/hooks/useEvents';
|
||||||
|
import { useFinanceSummary } from '@/hooks/useFinanceSummary';
|
||||||
|
import type { AgentSummary } from '@/types/agents';
|
||||||
|
|
||||||
|
function formatCurrency(value: number | undefined, currency = 'USD') {
|
||||||
|
if (value === undefined || Number.isNaN(value)) return '—';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | undefined, digits = 1) {
|
||||||
|
if (value === undefined || Number.isNaN(value)) return '—';
|
||||||
|
return value.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardView() {
|
||||||
|
const { data: finance, isLoading: financeLoading, error: financeError } = useFinanceSummary();
|
||||||
|
const { data: agents, isLoading: agentsLoading, error: agentsError } = useAgents();
|
||||||
|
const { events, isLoading: eventsLoading, error: eventsError } = useEvents({ limit: 5 });
|
||||||
|
|
||||||
|
const topAgents = useMemo(() => agents.slice(0, 5), [agents]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid dashboard-grid">
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h1>Prism Console Overview</h1>
|
||||||
|
<p className="muted">Operational snapshot across agents, finance, and recent events.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stat-grid">
|
||||||
|
<h3>Finance</h3>
|
||||||
|
<div className="stat-grid-inner">
|
||||||
|
<StatCard
|
||||||
|
label="Cash Balance"
|
||||||
|
value={financeLoading ? 'Loading…' : formatCurrency(finance?.cashBalance, finance?.currency)}
|
||||||
|
helpText={financeError ? financeError.message : 'Live from finance summary'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Runway (months)"
|
||||||
|
value={financeLoading ? 'Loading…' : formatNumber(finance?.runwayMonths)}
|
||||||
|
helpText="How long we can operate on current burn"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Monthly Burn"
|
||||||
|
value={financeLoading ? 'Loading…' : formatCurrency(finance?.monthlyBurnRate, finance?.currency)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Active Revenue"
|
||||||
|
value={financeLoading ? 'Loading…' : finance?.mrr ? formatCurrency(finance.mrr, finance.currency) : '—'}
|
||||||
|
helpText={finance?.arr ? `ARR: ${formatCurrency(finance.arr, finance.currency)}` : 'MRR/ARR optional'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stat-grid">
|
||||||
|
<h3>Agents</h3>
|
||||||
|
<div className="stat-grid-inner">
|
||||||
|
<StatCard
|
||||||
|
label="Total agents"
|
||||||
|
value={agentsLoading ? 'Loading…' : agents.length}
|
||||||
|
helpText={agentsError ? agentsError.message : 'Online + offline agents from operator'}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Online"
|
||||||
|
value={agentsLoading ? '…' : agents.filter((a) => a.status === 'online').length}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Degraded"
|
||||||
|
value={agentsLoading ? '…' : agents.filter((a) => a.status === 'degraded').length}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Domains"
|
||||||
|
value={agentsLoading ? '…' : new Set(agents.map((a) => a.domain || 'unknown')).size}
|
||||||
|
helpText="Distinct domains covered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h3>Recent Events</h3>
|
||||||
|
{eventsError && <div className="error-banner">{eventsError.message}</div>}
|
||||||
|
{eventsLoading && <p className="muted">Loading events…</p>}
|
||||||
|
{!eventsLoading && events.length === 0 && <p className="muted">No events found.</p>}
|
||||||
|
{events.length > 0 && (
|
||||||
|
<ul className="event-list">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li key={event.id} className="event-item">
|
||||||
|
<div>
|
||||||
|
<div className="muted">{new Date(event.timestamp).toLocaleString()}</div>
|
||||||
|
<div className="event-type">{event.type}</div>
|
||||||
|
<div className="muted">{event.source}</div>
|
||||||
|
</div>
|
||||||
|
{event.summary && <div>{event.summary}</div>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Link href="/events" className="muted">
|
||||||
|
View full stream →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h3>Active Agents</h3>
|
||||||
|
{topAgents.length === 0 && !agentsLoading && <p className="muted">No agents loaded.</p>}
|
||||||
|
{agentsLoading && <p className="muted">Loading agents…</p>}
|
||||||
|
{topAgents.length > 0 && (
|
||||||
|
<ul className="agent-list">
|
||||||
|
{topAgents.map((agent: AgentSummary) => (
|
||||||
|
<li key={agent.id} className="agent-item">
|
||||||
|
<div>
|
||||||
|
<div className="agent-name">{agent.name}</div>
|
||||||
|
<div className="muted">{agent.domain || 'unspecified domain'}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`status-pill ${agent.status}`}>{agent.status}</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return <DashboardView />;
|
||||||
|
}
|
||||||
60
src/app/events/page.tsx
Normal file
60
src/app/events/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { GenericTable } from '@/components/tables/GenericTable';
|
||||||
|
import { useEvents } from '@/hooks/useEvents';
|
||||||
|
import type { EventRecord } from '@/types/events';
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
const [limit, setLimit] = useState<number | undefined>(25);
|
||||||
|
const [type, setType] = useState('');
|
||||||
|
const [source, setSource] = useState('');
|
||||||
|
const { events, isLoading, error, setParams, refetch } = useEvents({ limit });
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
setParams({ limit, type: type || undefined, source: source || undefined });
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ header: 'Timestamp', accessor: (event: EventRecord) => new Date(event.timestamp).toLocaleString() },
|
||||||
|
{ header: 'Type', accessor: (event: EventRecord) => event.type },
|
||||||
|
{ header: 'Source', accessor: (event: EventRecord) => event.source },
|
||||||
|
{ header: 'Summary', accessor: (event: EventRecord) => event.summary || '—' }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h1>Events</h1>
|
||||||
|
<p className="muted">Recent events across agents and finance systems.</p>
|
||||||
|
|
||||||
|
<div className="filter-grid">
|
||||||
|
<label>
|
||||||
|
Type
|
||||||
|
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="task.completed" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Source
|
||||||
|
<input value={source} onChange={(e) => setSource(e.target.value)} placeholder="agent:finance" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Limit
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={limit ?? 0}
|
||||||
|
onChange={(e) => setLimit(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button" onClick={applyFilters} disabled={isLoading}>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error.message}</div>}
|
||||||
|
{isLoading && <p className="muted">Loading events…</p>}
|
||||||
|
<GenericTable columns={columns} data={events} emptyText={isLoading ? 'Loading…' : 'No events found'} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/app/finance/page.tsx
Normal file
207
src/app/finance/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { StatCard } from '@/components/cards/StatCard';
|
||||||
|
import { GenericTable } from '@/components/tables/GenericTable';
|
||||||
|
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 formatMoney(amount: number | undefined, currency = 'USD') {
|
||||||
|
if (amount === undefined) return '—';
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLineItems(items: StatementLineItem[]) {
|
||||||
|
return items.map((item) => (
|
||||||
|
<tr key={item.account}>
|
||||||
|
<td>{item.label}</td>
|
||||||
|
<td>{formatMoney(item.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinancePage() {
|
||||||
|
const { data: summary, isLoading: summaryLoading, error: summaryError } = useFinanceSummary();
|
||||||
|
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(() => {
|
||||||
|
setLoadingForecast(true);
|
||||||
|
fetchCashForecast()
|
||||||
|
.then(setForecast)
|
||||||
|
.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 (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h1>Finance</h1>
|
||||||
|
<p className="muted">Runway, burn, and statements pulled from blackroad-os-api.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card stat-grid" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h3>Summary</h3>
|
||||||
|
{summaryError && <div className="error-banner">{summaryError.message}</div>}
|
||||||
|
<div className="stat-grid-inner">
|
||||||
|
<StatCard
|
||||||
|
label="Cash balance"
|
||||||
|
value={summaryLoading ? 'Loading…' : formatMoney(summary?.cashBalance, summary?.currency)}
|
||||||
|
helpText="Available cash"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Runway"
|
||||||
|
value={summaryLoading ? 'Loading…' : `${summary?.runwayMonths ?? '—'} months`}
|
||||||
|
helpText="Based on monthly burn"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Monthly burn"
|
||||||
|
value={summaryLoading ? 'Loading…' : formatMoney(summary?.monthlyBurnRate, summary?.currency)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="MRR / ARR"
|
||||||
|
value={summaryLoading ? 'Loading…' : `${formatMoney(summary?.mrr, summary?.currency)} / ${formatMoney(summary?.arr, summary?.currency)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h3>Cash Forecast</h3>
|
||||||
|
{loadingForecast && <p className="muted">Loading forecast…</p>}
|
||||||
|
{forecast && (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
<th>Net change</th>
|
||||||
|
<th>Ending balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{forecast.buckets.map((bucket) => (
|
||||||
|
<tr key={`${bucket.startDate}-${bucket.endDate}`}>
|
||||||
|
<td>{bucket.startDate}</td>
|
||||||
|
<td>{bucket.endDate}</td>
|
||||||
|
<td>{formatMoney(bucket.netChange, forecast.currency)}</td>
|
||||||
|
<td>{formatMoney(bucket.endingBalance, forecast.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<h3>Statements</h3>
|
||||||
|
<label className="muted" htmlFor="period-select">
|
||||||
|
Period
|
||||||
|
</label>
|
||||||
|
<select id="period-select" value={period} onChange={(e) => setPeriod(e.target.value)}>
|
||||||
|
{demoPeriods.map((p) => (
|
||||||
|
<option key={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{loadingStatements && <p className="muted">Loading statements…</p>}
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{statements && (
|
||||||
|
<div className="grid" style={{ marginTop: 12 }}>
|
||||||
|
<div className="card">
|
||||||
|
<h4>Income Statement</h4>
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
{renderLineItems(statements.incomeStatement.revenue)}
|
||||||
|
{renderLineItems(statements.incomeStatement.cogs)}
|
||||||
|
{renderLineItems(statements.incomeStatement.operatingExpenses)}
|
||||||
|
{renderLineItems(statements.incomeStatement.otherIncomeExpenses)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Net Income</strong>
|
||||||
|
</td>
|
||||||
|
<td>{formatMoney(statements.incomeStatement.netIncome, statements.incomeStatement.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
--accent: #7dd3fc;
|
--accent: #7dd3fc;
|
||||||
--accent-strong: #38bdf8;
|
--accent-strong: #38bdf8;
|
||||||
--border: #1f2946;
|
--border: #1f2946;
|
||||||
|
--danger: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -34,24 +35,7 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 32px;
|
padding: 24px 32px 48px;
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
background: var(--panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-links {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 32px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -68,7 +52,9 @@ nav {
|
|||||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card h3 {
|
.card h3,
|
||||||
|
.card h4,
|
||||||
|
.card h1 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +100,10 @@ nav {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-row-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -129,3 +119,218 @@ nav {
|
|||||||
.status-bad {
|
.status-bad {
|
||||||
color: #f87171;
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-subtitle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active,
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(125, 211, 252, 0.12);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(17, 22, 45, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-env {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.online {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.offline {
|
||||||
|
background: rgba(248, 113, 113, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.degraded {
|
||||||
|
background: rgba(250, 204, 21, 0.15);
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.unknown {
|
||||||
|
background: rgba(148, 163, 184, 0.2);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(56, 189, 248, 0.08), rgba(17, 24, 39, 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-delta {
|
||||||
|
color: #22c55e;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-help {
|
||||||
|
color: var(--muted);
|
||||||
|
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,
|
||||||
|
.agent-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item,
|
||||||
|
.agent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-type,
|
||||||
|
.agent-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,80 +1,5 @@
|
|||||||
import { LiveHealthCard } from '@/components/status/LiveHealthCard';
|
import { redirect } from 'next/navigation';
|
||||||
import { ServiceHealthGrid } from '@/components/status/ServiceHealthGrid';
|
|
||||||
import { getStaticServiceHealth, publicConfig, serverConfig } from '@/lib/config';
|
|
||||||
import { serviceConfig } from '@/config/serviceConfig';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const serviceHealth = getStaticServiceHealth();
|
redirect('/dashboard');
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div className="card" style={{ gridColumn: 'span 2' }}>
|
|
||||||
<h1>{serviceConfig.SERVICE_NAME}</h1>
|
|
||||||
<p className="muted">Operator-facing control panel for BlackRoad OS</p>
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<span className="badge">Service ID: {serviceConfig.SERVICE_ID}</span>
|
|
||||||
<span className="badge">Environment: {serverConfig.environment}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<div className="muted">Base URL: {serviceConfig.SERVICE_BASE_URL}</div>
|
|
||||||
<div className="muted">OS Root: {serviceConfig.OS_ROOT}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ gridColumn: 'span 2' }}>
|
|
||||||
<h2>System Status</h2>
|
|
||||||
<p className="muted">Live and static readiness signals for the Prism Console.</p>
|
|
||||||
<div className="grid">
|
|
||||||
<LiveHealthCard />
|
|
||||||
<div className="card">
|
|
||||||
<h3>Configuration Snapshot</h3>
|
|
||||||
<p className="muted">Resolved URLs from server/public configuration.</p>
|
|
||||||
<table className="table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Core API</td>
|
|
||||||
<td className="muted">{publicConfig.coreApiUrl || serverConfig.coreApiUrl || 'not set'}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Agents API</td>
|
|
||||||
<td className="muted">{publicConfig.agentsApiUrl || serverConfig.agentsApiUrl || 'not set'}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Console URL</td>
|
|
||||||
<td className="muted">{publicConfig.consoleUrl || serverConfig.consoleUrl || 'not set'}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<h3>Dependency Checklist</h3>
|
|
||||||
<p className="muted">Configuration readiness across Prism Console dependencies.</p>
|
|
||||||
<ul>
|
|
||||||
{serviceHealth.map((service) => (
|
|
||||||
<li key={service.key}>
|
|
||||||
{service.name}:{' '}
|
|
||||||
<span className={service.configured ? 'status-ok' : 'status-bad'}>
|
|
||||||
{service.configured ? 'Configured' : 'Missing'}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="muted">{service.url || 'not set'}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ServiceHealthGrid />
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h3>Operator Queue</h3>
|
|
||||||
<p className="muted">Placeholder for pending operator tasks, incidents, or approvals.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Integrate authentication for console routes.</li>
|
|
||||||
<li>Connect deployment events stream.</li>
|
|
||||||
<li>Surface observability snapshots from core services.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/components/cards/StatCard.tsx
Normal file
19
src/components/cards/StatCard.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number | ReactNode;
|
||||||
|
delta?: string;
|
||||||
|
helpText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, delta, helpText }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-label">{label}</div>
|
||||||
|
<div className="stat-value">{value}</div>
|
||||||
|
{delta && <div className="stat-delta">{delta}</div>}
|
||||||
|
{helpText && <div className="stat-help">{helpText}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,10 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import type { Route } from 'next';
|
import { Shell } from './Shell';
|
||||||
import { serverConfig } from '@/lib/config';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navLinks: { href: Route; label: string }[] = [
|
|
||||||
{ href: '/', label: 'Overview' },
|
|
||||||
{ href: '/status', label: 'Status' },
|
|
||||||
{ href: '/agents', label: 'Agents' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AppShell({ children }: Props) {
|
export default function AppShell({ children }: Props) {
|
||||||
return (
|
return <Shell>{children}</Shell>;
|
||||||
<div>
|
|
||||||
<nav>
|
|
||||||
<div className="nav-links">
|
|
||||||
<Link href="/" className="logo">
|
|
||||||
Prism Console
|
|
||||||
</Link>
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<Link key={link.href} href={link.href}>
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<span className="badge" style={{ marginLeft: 'auto' }}>
|
|
||||||
Environment: {serverConfig.environment}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<main>{children}</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/components/layout/Shell.tsx
Normal file
19
src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Shell({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="app-shell-main">
|
||||||
|
<TopBar />
|
||||||
|
<main className="app-shell-content">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/layout/Sidebar.tsx
Normal file
39
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import type { Route } from 'next';
|
||||||
|
|
||||||
|
const links: { href: Route; label: string }[] = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard' },
|
||||||
|
{ href: '/agents', label: 'Agents' },
|
||||||
|
{ href: '/finance', label: 'Finance' },
|
||||||
|
{ href: '/events', label: 'Events' },
|
||||||
|
{ href: '/status', label: 'Status' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<div className="sidebar-title">Prism Console</div>
|
||||||
|
<div className="sidebar-subtitle">BlackRoad OS</div>
|
||||||
|
</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{links.map((link) => {
|
||||||
|
const active = pathname?.startsWith(link.href);
|
||||||
|
return (
|
||||||
|
<Link key={link.href} href={link.href} className={active ? 'nav-link active' : 'nav-link'}>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="sidebar-footer">
|
||||||
|
<p className="muted">Read-only dashboards for agents, finance, and events.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/layout/TopBar.tsx
Normal file
47
src/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function getEnvironmentLabel() {
|
||||||
|
return process.env.NEXT_PUBLIC_ENV || process.env.NODE_ENV || 'development';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiStatus = 'unknown' | 'online' | 'degraded';
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const [apiStatus, setApiStatus] = useState<ApiStatus>('unknown');
|
||||||
|
const [latency, setLatency] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
if (!baseUrl) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const start = performance.now();
|
||||||
|
fetch(`${baseUrl}/health`, { signal: controller.signal })
|
||||||
|
.then((res) => {
|
||||||
|
const elapsed = Math.round(performance.now() - start);
|
||||||
|
setLatency(elapsed);
|
||||||
|
setApiStatus(res.ok ? 'online' : 'degraded');
|
||||||
|
})
|
||||||
|
.catch(() => setApiStatus('degraded'));
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Environment
|
||||||
|
</div>
|
||||||
|
<div className="topbar-env">{getEnvironmentLabel()}</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-status">
|
||||||
|
<span className={`status-pill ${apiStatus}`}>{apiStatus}</span>
|
||||||
|
{latency !== null && <span className="muted">{latency} ms</span>}
|
||||||
|
</div>
|
||||||
|
<div className="topbar-user">Cecilia • Orchestrator</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/tables/GenericTable.tsx
Normal file
40
src/components/tables/GenericTable.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
header: string;
|
||||||
|
accessor: (row: T) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenericTableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
emptyText?: string;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GenericTable<T>({ columns, data, emptyText = 'No data available', onRowClick }: GenericTableProps<T>) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <p className="muted">{emptyText}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.header}>{column.header}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row, idx) => (
|
||||||
|
<tr key={idx} onClick={() => onRowClick?.(row)} className={onRowClick ? 'table-row-clickable' : undefined}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.header}>{column.accessor(row)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/hooks/useAgentDetail.ts
Normal file
45
src/hooks/useAgentDetail.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { AgentSummary, AgentTaskSummary } from '../types/agents';
|
||||||
|
import { fetchAgentDetail, fetchAgentTasks } from '../services/agentsService';
|
||||||
|
|
||||||
|
export function useAgentDetail(id: string | undefined) {
|
||||||
|
const [agent, setAgent] = useState<AgentSummary | null>(null);
|
||||||
|
const [tasks, setTasks] = useState<AgentTaskSummary[]>([]);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
Promise.all([fetchAgentDetail(id), fetchAgentTasks(id)])
|
||||||
|
.then(([agentDetail, agentTasks]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAgent(agentDetail);
|
||||||
|
setTasks(agentTasks);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err);
|
||||||
|
setAgent(null);
|
||||||
|
setTasks([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return { agent, tasks, isLoading, error };
|
||||||
|
}
|
||||||
41
src/hooks/useAgents.ts
Normal file
41
src/hooks/useAgents.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { AgentSummary } from '../types/agents';
|
||||||
|
import { fetchAgents } from '../services/agentsService';
|
||||||
|
|
||||||
|
export function useAgents() {
|
||||||
|
const [data, setData] = useState<AgentSummary[]>([]);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
fetchAgents()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(res);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err);
|
||||||
|
setData([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, isLoading, error };
|
||||||
|
}
|
||||||
40
src/hooks/useEvents.ts
Normal file
40
src/hooks/useEvents.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { EventRecord } from '../types/events';
|
||||||
|
import { fetchEvents, type EventQueryParams } from '../services/eventsService';
|
||||||
|
|
||||||
|
export function useEvents(initialParams: EventQueryParams = {}, pollIntervalMs?: number) {
|
||||||
|
const [params, setParams] = useState<EventQueryParams>(initialParams);
|
||||||
|
const [events, setEvents] = useState<EventRecord[]>([]);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const load = (nextParams: EventQueryParams = params) => {
|
||||||
|
setLoading(true);
|
||||||
|
fetchEvents(nextParams)
|
||||||
|
.then((records) => {
|
||||||
|
setEvents(records);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setError(err);
|
||||||
|
setEvents([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load(params);
|
||||||
|
if (!pollIntervalMs) return;
|
||||||
|
const id = setInterval(() => load(params), pollIntervalMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [pollIntervalMs, params]);
|
||||||
|
|
||||||
|
const updateParams = (next: EventQueryParams) => {
|
||||||
|
setParams(next);
|
||||||
|
load(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { events, isLoading, error, params, setParams: updateParams, refetch: () => load(params) };
|
||||||
|
}
|
||||||
40
src/hooks/useFinanceSummary.ts
Normal file
40
src/hooks/useFinanceSummary.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FinanceSummary } from '../types/finance';
|
||||||
|
import { fetchFinanceSummary } from '../services/financeService';
|
||||||
|
|
||||||
|
export function useFinanceSummary() {
|
||||||
|
const [data, setData] = useState<FinanceSummary | null>(null);
|
||||||
|
const [isLoading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
fetchFinanceSummary()
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(res);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { data, isLoading, error };
|
||||||
|
}
|
||||||
16
src/lib/apiClient.ts
Normal file
16
src/lib/apiClient.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
const res = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
14
src/services/agentsService.ts
Normal file
14
src/services/agentsService.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { apiGet } from '../lib/apiClient';
|
||||||
|
import type { AgentSummary, AgentTaskSummary } from '../types/agents';
|
||||||
|
|
||||||
|
export async function fetchAgents(): Promise<AgentSummary[]> {
|
||||||
|
return apiGet<AgentSummary[]>('/internal/agents');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentDetail(id: string): Promise<AgentSummary> {
|
||||||
|
return apiGet<AgentSummary>(`/internal/agents/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentTasks(id: string): Promise<AgentTaskSummary[]> {
|
||||||
|
return apiGet<AgentTaskSummary[]>(`/internal/agents/${encodeURIComponent(id)}/tasks`);
|
||||||
|
}
|
||||||
19
src/services/eventsService.ts
Normal file
19
src/services/eventsService.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { apiGet } from '../lib/apiClient';
|
||||||
|
import type { EventRecord } from '../types/events';
|
||||||
|
|
||||||
|
export interface EventQueryParams {
|
||||||
|
limit?: number;
|
||||||
|
type?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvents(params: EventQueryParams = {}): Promise<EventRecord[]> {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params.limit !== undefined) search.set('limit', String(params.limit));
|
||||||
|
if (params.type) search.set('type', params.type);
|
||||||
|
if (params.source) search.set('source', params.source);
|
||||||
|
|
||||||
|
const qs = search.toString();
|
||||||
|
const path = qs ? `/internal/events?${qs}` : '/internal/events';
|
||||||
|
return apiGet<EventRecord[]>(path);
|
||||||
|
}
|
||||||
14
src/services/financeService.ts
Normal file
14
src/services/financeService.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { apiGet } from '../lib/apiClient';
|
||||||
|
import type { CashForecast, FinanceSummary, FinancialStatements } from '../types/finance';
|
||||||
|
|
||||||
|
export async function fetchFinanceSummary(): Promise<FinanceSummary> {
|
||||||
|
return apiGet<FinanceSummary>('/finance/summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCashForecast(): Promise<CashForecast> {
|
||||||
|
return apiGet<CashForecast>('/finance/cash-forecast');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatements(period: string): Promise<FinancialStatements> {
|
||||||
|
return apiGet<FinancialStatements>(`/finance/statements/${encodeURIComponent(period)}`);
|
||||||
|
}
|
||||||
25
src/types/agents.ts
Normal file
25
src/types/agents.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type AgentStatus = 'online' | 'offline' | 'degraded' | 'unknown';
|
||||||
|
|
||||||
|
export interface AgentCapability {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
lastHeartbeat?: string;
|
||||||
|
capabilities: AgentCapability[];
|
||||||
|
domain?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTaskSummary {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
8
src/types/events.ts
Normal file
8
src/types/events.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface EventRecord {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
timestamp: string;
|
||||||
|
summary?: string;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
62
src/types/finance.ts
Normal file
62
src/types/finance.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export interface FinanceSummary {
|
||||||
|
currency: string;
|
||||||
|
cashBalance: number;
|
||||||
|
monthlyBurnRate: number;
|
||||||
|
runwayMonths: number;
|
||||||
|
mrr?: number;
|
||||||
|
arr?: number;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashForecastBucket {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
netChange: number;
|
||||||
|
endingBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashForecast {
|
||||||
|
currency: string;
|
||||||
|
buckets: CashForecastBucket[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementLineItem {
|
||||||
|
account: string;
|
||||||
|
label: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomeStatement {
|
||||||
|
period: string;
|
||||||
|
currency: string;
|
||||||
|
revenue: StatementLineItem[];
|
||||||
|
cogs: StatementLineItem[];
|
||||||
|
operatingExpenses: StatementLineItem[];
|
||||||
|
otherIncomeExpenses: StatementLineItem[];
|
||||||
|
netIncome: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceSheet {
|
||||||
|
period: string;
|
||||||
|
currency: string;
|
||||||
|
assets: StatementLineItem[];
|
||||||
|
liabilities: StatementLineItem[];
|
||||||
|
equity: StatementLineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashFlowStatement {
|
||||||
|
period: string;
|
||||||
|
currency: string;
|
||||||
|
operatingActivities: StatementLineItem[];
|
||||||
|
investingActivities: StatementLineItem[];
|
||||||
|
financingActivities: StatementLineItem[];
|
||||||
|
netChangeInCash: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialStatements {
|
||||||
|
period: string;
|
||||||
|
incomeStatement: IncomeStatement;
|
||||||
|
balanceSheet: BalanceSheet;
|
||||||
|
cashFlowStatement: CashFlowStatement;
|
||||||
|
}
|
||||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './vitest.setup.ts',
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
1
vitest.setup.ts
Normal file
1
vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
Reference in New Issue
Block a user