Skip to content
Open
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
11 changes: 3 additions & 8 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
# URL of the NexusRAG API (server-side proxy target)
NEXUSRAG_API_URL=http://localhost:8000

# API key passed as Bearer token from the browser (public — no secrets here)
NEXT_PUBLIC_API_KEY=your-api-key-here

# Corpus ID to use in the /run page
NEXT_PUBLIC_DEFAULT_CORPUS_ID=c1
# This dashboard requires no environment variables to build or run.
# All configuration is sourced from src/lib/project.ts and API responses.
# When adding public vars in the future, use the NEXT_PUBLIC_ prefix.
4 changes: 4 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals",
"root": true
}
18 changes: 18 additions & 0 deletions .github/workflows/nextjs-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Next.js CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm install --no-audit --no-fund
- run: npm run lint
- run: npm run type-check
- run: npm run test:coverage
- run: npm run build
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ __pycache__/
dist/
build/

# Test coverage
coverage/

# OS
.DS_Store

Expand Down
4 changes: 2 additions & 2 deletions api/_telemetry_static.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"lines_of_code": 1177,
"built_at": "2026-04-27T18:41:57Z"
"lines_of_code": 7937,
"built_at": "2026-06-09T18:16:02Z"
}
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
"name": "evalops-dashboard",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"next": "14.2.5",
Expand Down Expand Up @@ -55,6 +59,7 @@
"eslint-config-next": "14.2.5",
"vitest": "^2.0.5",
"@vitest/ui": "^2.0.5",
"@vitest/coverage-v8": "^2.0.5",
"@testing-library/react": "^16.0.1",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/user-event": "^14.5.2",
Expand Down
14 changes: 14 additions & 0 deletions scripts/compute_telemetry_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@
"dist",
"build",
".idea",
".next",
"coverage",
}
)

EXCLUDE_FILES = frozenset(
{
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"tsconfig.tsbuildinfo",
"_telemetry_static.json",
}
)

Expand All @@ -46,6 +58,8 @@ def count_lines(root: Path) -> int:
continue
if any(part in EXCLUDE_DIRS for part in path.parts):
continue
if path.name in EXCLUDE_FILES:
continue
if path.suffix not in SOURCE_EXTS:
continue
# Exclude the build artifact itself so each run is stable.
Expand Down
215 changes: 215 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import {
fetchPublicStats,
fetchBenchmarkLatest,
PublicStats,
PublicBenchmark,
} from "./api";

describe("api.ts", () => {
afterEach(() => {
vi.restoreAllMocks();
});

describe("fetchPublicStats", () => {
it("fetches and parses stats from /api/stats", async () => {
const mockStats: PublicStats = {
system: "evalops",
mode: "showcase",
status: "operational",
last_deployed_at: "2026-04-28T12:00:00Z",
last_active_at: "2026-04-28T12:00:00Z",
metrics: {
eval_runs_total: 100,
eval_runs_24h: 10,
},
schema_version: 1,
generated_at: "2026-04-28T12:00:00Z",
};

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockStats), { status: 200 })
)
);

const result = await fetchPublicStats();
expect(result).toEqual(mockStats);
expect(fetch).toHaveBeenCalledWith("/api/stats", {
headers: { "Content-Type": "application/json" },
});
});

it("throws on non-ok response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not Found",
})
)
);

await expect(fetchPublicStats()).rejects.toThrow(
"Public API 404: Not Found"
);
});

it("propagates network errors", async () => {
const networkError = new Error("Network failed");
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(networkError));

await expect(fetchPublicStats()).rejects.toThrow("Network failed");
});

it("handles 500 server error", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Internal error" }), {
status: 500,
statusText: "Internal Server Error",
})
)
);

await expect(fetchPublicStats()).rejects.toThrow(
"Public API 500: Internal Server Error"
);
});
});

describe("fetchBenchmarkLatest", () => {
it("fetches and parses benchmark from /api/benchmark-latest", async () => {
const mockBenchmark: PublicBenchmark = {
system: "evalops",
benchmark_type: "standard",
run_id: "run_001",
metrics: {
n_cases: 10,
baseline_variant: "v1",
candidate_variant: "v2",
baseline_pass_rate: 0.5,
candidate_pass_rate: 1.0,
baseline_avg_score: 0.5,
candidate_avg_score: 1.0,
pass_rate_delta: 0.5,
avg_score_delta: 0.5,
regressions: 0,
improvements: 5,
gate_verdict: "pass",
},
schema_version: 1,
generated_at: "2026-04-28T12:00:00Z",
};

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockBenchmark), { status: 200 })
)
);

const result = await fetchBenchmarkLatest();
expect(result).toEqual(mockBenchmark);
expect(fetch).toHaveBeenCalledWith("/api/benchmark-latest", {
headers: { "Content-Type": "application/json" },
});
});

it("throws on non-ok response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not Found",
})
)
);

await expect(fetchBenchmarkLatest()).rejects.toThrow(
"Public API 404: Not Found"
);
});

it("propagates network errors", async () => {
const networkError = new Error("Network timeout");
vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(networkError));

await expect(fetchBenchmarkLatest()).rejects.toThrow(
"Network timeout"
);
});

it("handles 503 service unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ error: "Service unavailable" }), {
status: 503,
statusText: "Service Unavailable",
})
)
);

await expect(fetchBenchmarkLatest()).rejects.toThrow(
"Public API 503: Service Unavailable"
);
});

it("handles null run_id gracefully", async () => {
const mockBenchmark: PublicBenchmark = {
system: "evalops",
benchmark_type: "standard",
run_id: null,
metrics: null,
schema_version: 1,
generated_at: "2026-04-28T12:00:00Z",
};

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockBenchmark), { status: 200 })
)
);

const result = await fetchBenchmarkLatest();
expect(result.run_id).toBeNull();
expect(result.metrics).toBeNull();
});
});

describe("fetch error edge cases", () => {
it("preserves custom headers alongside Content-Type", async () => {
const mockStats: PublicStats = {
system: "evalops",
status: "operational",
last_deployed_at: null,
metrics: {},
schema_version: 1,
generated_at: "2026-04-28T12:00:00Z",
};

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(mockStats), { status: 200 })
)
);

await fetchPublicStats();
expect(fetch).toHaveBeenCalledWith(
"/api/stats",
expect.objectContaining({
headers: expect.objectContaining({
"Content-Type": "application/json",
}),
})
);
});
});
});
Loading
Loading