Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm verify

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @sentinel/dashboard exec playwright install --with-deps chromium
- run: pnpm test:e2e
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ node_modules/
dist/
coverage/
*.tsbuildinfo
playwright-report/
test-results/
.last-run.json

# Local data
*.sqlite
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A self-hostable **verifying LLM gateway** — a drop-in, OpenAI-compatible proxy that **routes** (cheapest capable model + fallback), **semantically caches**, and **verifies** (deterministic guardrails inline + a local Ollama judge) every LLM call, with full OpenTelemetry tracing. Unlike after-the-fact observability tools, it can flag or block a bad response _before it returns_.

> 🚧 **Early development.** Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md). Currently at **Phase 5 — verification: guardrails + judge** (a quality dashboard lands next).
> 🚧 **Early development.** Product spec in [`PRP_SPEC.md`](./PRP_SPEC.md), phased build in [`ROADMAP.md`](./ROADMAP.md), contributor/agent guidance in [`CLAUDE.md`](./CLAUDE.md). The gateway is feature-complete (proxy, tracing, cache, routing, verification) and now ships a **Phase 6** dashboard; hardening + launch (Phase 7) is next.

## What works today (Phase 1)

Expand Down Expand Up @@ -125,6 +125,17 @@ Sentinel can check a response's quality **in the request path**, not just after

Verdicts are queryable: `GET /traces?guardrailStatus=block`, `?judgeScoreMax=2`, `?promptFingerprint=…`. Inline guardrails apply to non-streaming responses; streamed responses are judged from their buffered output after they complete.

## Dashboard

A read-only **React + Vite** dashboard (`packages/dashboard`) visualizes the trace API: request volume over time, error / cache-hit / fallback rates, latency p95, token usage, provider / model / status distributions, a judge-score histogram, guardrail breakdown, a recent-requests table, and quality regressions. It aggregates client-side from `GET /traces` + `GET /regression`, so it needs only your gateway's `SENTINEL_ADMIN_KEY`.

```bash
pnpm dev # gateway on :8080 (set SENTINEL_ADMIN_KEY to enable the admin API)
pnpm dev:dashboard # dashboard on :5173 (dev-proxies the admin API to :8080)
```

Open <http://localhost:5173>, paste your admin key, and hit Refresh. The dashboard is end-to-end tested with Playwright (`pnpm test:e2e`).

## Development

```bash
Expand Down
11 changes: 11 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export default tseslint.config(
],
},
},
{
// Dashboard React components (browser, JSX). Default exports allowed for entry/lazy chunks.
files: ['**/*.tsx'],
extends: [...tseslint.configs.strict, ...tseslint.configs.stylistic],
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
{
// Tooling config files conventionally require a default export.
files: ['**/*.config.ts'],
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p packages/dashboard/tsconfig.json --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage",
"test:e2e": "node -e \"console.log('e2e suite lands in ROADMAP Phase 6')\"",
"test:e2e": "pnpm --filter @sentinel/dashboard test:e2e",
"dev": "tsx watch packages/gateway/src/main.ts",
"dev:dashboard": "pnpm --filter @sentinel/dashboard dev",
"build:dashboard": "pnpm --filter @sentinel/dashboard build",
"start": "tsx packages/gateway/src/main.ts",
"verify": "pnpm run typecheck && pnpm run lint && pnpm run test:cov",
"ollama:pull": "ollama pull qwen2.5:7b && ollama pull nomic-embed-text"
Expand Down
57 changes: 57 additions & 0 deletions packages/dashboard/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect } from '@playwright/test';

function sampleTrace(over: Record<string, unknown>): Record<string, unknown> {
return {
id: 'x',
traceId: 't',
timestamp: 1_700_000_000_000,
durationMs: 120,
model: 'gpt-4o-mini',
provider: 'openai',
stream: false,
status: 200,
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
errorType: null,
errorMessage: null,
apiKeyHash: null,
cacheHit: false,
routedProvider: null,
routedModel: null,
fallbackUsed: false,
retryCount: 0,
guardrailStatus: null,
guardrailViolations: null,
judgeScore: null,
judgeReason: null,
judgeError: null,
promptFingerprint: null,
...over,
};
}

const traces = [
sampleTrace({ id: 'a', status: 200, cacheHit: true, judgeScore: 5 }),
sampleTrace({ id: 'b', status: 500, errorType: 'upstream_error' }),
sampleTrace({ id: 'c', status: 200, provider: 'groq', fallbackUsed: true, judgeScore: 3 }),
];

test('renders aggregated stats from the trace API', async ({ page }) => {
await page.route('**/traces*', (route) => route.fulfill({ json: traces }));
await page.route('**/regression*', (route) => route.fulfill({ json: [] }));
await page.addInitScript(() => {
window.localStorage.setItem('sentinel.adminKey', 'test-admin');
window.localStorage.setItem('sentinel.baseUrl', '');
});

await page.goto('/');

await expect(page.getByRole('heading', { name: 'Sentinel' })).toBeVisible();
await expect(page.getByTestId('stat-total')).toContainText('3');
await expect(page.getByTestId('stat-cache')).toContainText('33');
// 'groq' appears in both the provider bar (with a title) and the table; assert the bar.
await expect(page.getByTitle('groq')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Recent requests' })).toBeVisible();
await expect(page.getByTestId('stat-judge')).toContainText('4'); // mean of [5,3]
});
12 changes: 12 additions & 0 deletions packages/dashboard/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sentinel Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@sentinel/dashboard",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Sentinel dashboard — a read-only React UI over the gateway's admin trace API.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 4173",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
15 changes: 15 additions & 0 deletions packages/dashboard/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test';

// Hermetic E2E: builds the dashboard, serves the static preview, and the spec
// stubs the gateway's admin API with seeded traces (no real gateway needed).
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: { baseURL: 'http://localhost:4173' },
webServer: {
command: 'pnpm build && pnpm preview',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
148 changes: 148 additions & 0 deletions packages/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { fetchRegression, fetchTraces } from './api';
import type { ApiConfig } from './api';
import { computeStats } from './aggregate';
import type { RegressionGroup, TraceRecord } from './types';
import {
BarChart,
Histogram,
RegressionPanel,
Sparkline,
StatCard,
TracesTable,
} from './components';

const KEY_ADMIN = 'sentinel.adminKey';
const KEY_BASE = 'sentinel.baseUrl';

function pct(value: number): string {
return `${String(Math.round(value * 1000) / 10)}%`;
}

export function App() {
const [baseUrl, setBaseUrl] = useState(() => localStorage.getItem(KEY_BASE) ?? '');
const [adminKey, setAdminKey] = useState(() => localStorage.getItem(KEY_ADMIN) ?? '');
const [traces, setTraces] = useState<TraceRecord[]>([]);
const [regression, setRegression] = useState<RegressionGroup[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

const load = useCallback(async () => {
if (adminKey === '') {
setError('Enter your gateway admin key to load traces.');
return;
}
const config: ApiConfig = { baseUrl, adminKey };
setLoading(true);
setError(null);
try {
const [t, r] = await Promise.all([fetchTraces(config), fetchRegression(config)]);
setTraces(t);
setRegression(r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [baseUrl, adminKey]);

useEffect(() => {
localStorage.setItem(KEY_BASE, baseUrl);
localStorage.setItem(KEY_ADMIN, adminKey);
}, [baseUrl, adminKey]);

// Auto-load once on mount if an admin key is already stored; later loads are via Refresh.
useEffect(() => {
if (adminKey !== '') void load();
}, []);

const stats = useMemo(() => computeStats(traces), [traces]);

return (
<div className="app">
<header className="topbar">
<h1>Sentinel</h1>
<div className="controls">
<input
aria-label="Gateway URL"
placeholder="gateway URL (blank = same origin)"
value={baseUrl}
onChange={(e) => {
setBaseUrl(e.target.value);
}}
/>
<input
aria-label="Admin key"
type="password"
placeholder="admin key"
value={adminKey}
onChange={(e) => {
setAdminKey(e.target.value);
}}
/>
<button
onClick={() => {
void load();
}}
disabled={loading}
>
{loading ? 'Loading…' : 'Refresh'}
</button>
</div>
</header>

{error !== null ? (
<div className="banner err" role="alert">
{error}
</div>
) : null}

<section className="stats-row">
<StatCard testId="stat-total" label="Requests" value={String(stats.total)} />
<StatCard
testId="stat-error"
label="Error rate"
value={pct(stats.errorRate)}
sub={`${String(stats.errorCount)} errors`}
/>
<StatCard
testId="stat-cache"
label="Cache hit rate"
value={pct(stats.cacheHitRate)}
sub={`${String(stats.cacheHits)} hits`}
/>
<StatCard
testId="stat-fallback"
label="Fallback rate"
value={pct(stats.fallbackRate)}
sub={`${String(stats.fallbacks)} fell back`}
/>
<StatCard
testId="stat-latency"
label="Latency p95"
value={`${String(stats.p95LatencyMs)} ms`}
sub={`avg ${String(stats.avgLatencyMs)} ms`}
/>
<StatCard testId="stat-tokens" label="Tokens" value={stats.totalTokens.toLocaleString()} />
<StatCard
testId="stat-judge"
label="Avg judge score"
value={stats.avgJudgeScore !== null ? String(stats.avgJudgeScore) : '—'}
sub={`${String(stats.judgeScoredCount)} scored`}
/>
</section>

<section className="grid">
<Sparkline title="Requests over time" buckets={stats.overTime} />
<BarChart title="By provider" data={stats.byProvider} />
<BarChart title="By model" data={stats.byModel} />
<BarChart title="By status" data={stats.byStatusClass} />
<BarChart title="Guardrails" data={stats.byGuardrail} />
<Histogram title="Judge scores" bins={stats.judgeHistogram} />
</section>

<RegressionPanel groups={regression} />
<TracesTable traces={traces} />
</div>
);
}
Loading
Loading