Merge branch origin/codex/wire-up-prism-console-for-observability into main

This commit is contained in:
Alexa Amundson
2025-11-23 15:06:54 -06:00
33 changed files with 3568 additions and 245 deletions

View File

@@ -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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View 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');
});
});

View 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>
);
}

View File

@@ -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');
}
setAgents(payload.agents || []);
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 }) { header: 'Last heartbeat', accessor: (agent: AgentSummary) => agent.lastHeartbeat || 'n/a' },
}); { header: 'Capabilities', accessor: (agent: AgentSummary) => agent.capabilities?.length ?? 0 }
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">Lists agents from the configured AGENTS_API_URL and triggers /agents/run.</p> <p className="muted">Live state of BlackRoad OS agents with their capabilities and health.</p>
<button onClick={loadAgents} disabled={loading} style={{ marginTop: 8 }}> {error && <div className="error-banner">{error.message}</div>}
{loading ? 'Loading…' : 'Refresh'} {isLoading && <p className="muted">Loading agents</p>}
</button> <div className="table-wrapper">
{message && <p className="status-ok" style={{ marginTop: 8 }}>{message}</p>} <GenericTable
{error && <p className="status-bad" style={{ marginTop: 8 }}>{error}</p>} columns={columns}
</div> data={agents}
emptyText={isLoading ? 'Loading…' : 'No agents found'}
<div className="card"> onRowClick={(agent) => router.push(`/agents/${agent.id}`)}
<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>
); );
} }

View 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
View 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
View 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
View 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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
} }

View 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>
);
}

View File

@@ -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>
);
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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) };
}

View 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
View 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>;
}

View 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`);
}

View 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);
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';