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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ jobs:
- name: Run ESLint
working-directory: services/dashboard-nextjs
run: npm run lint
- name: Run tests
working-directory: services/dashboard-nextjs
run: npm test
- name: Build
working-directory: services/dashboard-nextjs
run: npm run build
Expand Down
48 changes: 25 additions & 23 deletions services/dashboard-nextjs/README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# SentinelOps Dashboard

## Getting Started
Real-time operations dashboard for the SentinelOps platform, built with Next.js 16 (App Router), TypeScript, and Tailwind CSS.

First, run the development server:
## What it does

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
- **Service health grid** — per-service event counts, error rates, and latency percentiles, polled every 15s
- **Anomaly feed** — scrolling log-tail of detected anomalies from the Rust anomaly engine, polled every 10s
- **Incident query terminal** — natural-language questions answered by the LLM/RAG service, submitted as an async job and polled to completion (local Ollama inference can take 30-90s)

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
All three panels talk to Next.js API routes (`app/api/*`), which query TimescaleDB directly (via `lib/db.ts`) or proxy to the `llm-query-python` service — credentials and internal service URLs never reach the browser.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Design system

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
IBM Plex Sans (UI) + IBM Plex Mono (data/metrics/logs), with a dark graphite/teal "SRE command center" palette defined in `tailwind.config.ts`. See the root [README](../../README.md) for the full rationale.

## Learn More
## Local development

To learn more about Next.js, take a look at the following resources:
```bash
npm install
DATABASE_URL="postgresql://sentinelops:devpassword@localhost:5432/sentinelops" \
LLM_SERVICE_URL="http://localhost:9300" \
npm run dev
```

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
Requires the rest of the SentinelOps stack running (see [infra/docker-compose.yml](../../infra/docker-compose.yml)) for the API routes to return real data.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Testing

## Deploy on Vercel
```bash
npm run lint # ESLint (flat config, eslint-config-next)
npm test # Vitest — API route logic
npm run build # Full production build (Next.js standalone output)
```

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## Deployment

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Built as a multi-stage Docker image (`Dockerfile`) using Next.js's `standalone` output mode. Runs as the `dashboard` service in [infra/docker-compose.yml](../../infra/docker-compose.yml), exposed on port 3000.
5 changes: 3 additions & 2 deletions services/dashboard-nextjs/app/api/anomalies/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { parsePositiveNumberParam } from "@/lib/queryParams";
import type { Anomaly } from "@/lib/types";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") ?? "24");
const limit = Number(searchParams.get("limit") ?? "50");
const hours = parsePositiveNumberParam(searchParams.get("hours"), 24);
const limit = parsePositiveNumberParam(searchParams.get("limit"), 50);

try {
const result = await pool.query<Anomaly>(
Expand Down
20 changes: 3 additions & 17 deletions services/dashboard-nextjs/app/api/services/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import type { ServiceHealth } from "@/lib/types";
import { parseHoursParam, toServiceHealth } from "@/lib/serviceHealth";

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const hours = Number(searchParams.get("hours") ?? "1");
const hours = parseHoursParam(searchParams.get("hours"), 1);

try {
const result = await pool.query(
Expand All @@ -19,21 +19,7 @@ export async function GET(request: Request) {
ORDER BY service`,
[hours]
);

const services: ServiceHealth[] = result.rows.map((r) => {
const totalEvents = Number(r.total_events) || 0;
const totalErrors = Number(r.total_errors) || 0;
return {
service: r.service,
total_events: totalEvents,
total_errors: totalErrors,
error_rate: totalEvents > 0 ? totalErrors / totalEvents : 0,
avg_p95_latency_ms: r.avg_p95_latency_ms !== null ? Number(r.avg_p95_latency_ms) : null,
max_latency_ms: r.max_latency_ms !== null ? Number(r.max_latency_ms) : null,
};
});

return NextResponse.json(services);
return NextResponse.json(toServiceHealth(result.rows));
} catch (error) {
console.error("Failed to fetch service health", error);
return NextResponse.json({ error: "Failed to fetch service health" }, { status: 500 });
Expand Down
25 changes: 25 additions & 0 deletions services/dashboard-nextjs/lib/queryParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { parsePositiveNumberParam } from "./queryParams";

describe("parsePositiveNumberParam", () => {
it("returns the fallback when value is null", () => {
expect(parsePositiveNumberParam(null, 50)).toBe(50);
});

it("parses a valid numeric string", () => {
expect(parsePositiveNumberParam("30", 50)).toBe(30);
});

it("returns the fallback for non-numeric input", () => {
expect(parsePositiveNumberParam("abc", 50)).toBe(50);
});

it("returns the fallback for zero or negative values", () => {
expect(parsePositiveNumberParam("0", 50)).toBe(50);
expect(parsePositiveNumberParam("-10", 50)).toBe(50);
});

it("returns the fallback for empty string", () => {
expect(parsePositiveNumberParam("", 50)).toBe(50);
});
});
11 changes: 11 additions & 0 deletions services/dashboard-nextjs/lib/queryParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Parses a numeric query parameter, falling back to a default when missing,
* non-numeric, or non-positive. Used across API routes instead of a bare
* `Number(x ?? default)`, which silently produces NaN or accepts negative/zero
* values for parameters like `hours` or `limit` that must be positive.
*/
export function parsePositiveNumberParam(value: string | null, fallback: number): number {
if (value === null) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
84 changes: 84 additions & 0 deletions services/dashboard-nextjs/lib/serviceHealth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { parseHoursParam, toServiceHealth } from "./serviceHealth";

describe("toServiceHealth", () => {
it("computes error rate correctly from string-typed pg aggregate results", () => {
const result = toServiceHealth([
{
service: "checkout",
total_events: "200",
total_errors: "20",
avg_p95_latency_ms: "150.5",
max_latency_ms: "300.25",
},
]);
expect(result).toEqual([
{
service: "checkout",
total_events: 200,
total_errors: 20,
error_rate: 0.1,
avg_p95_latency_ms: 150.5,
max_latency_ms: 300.25,
},
]);
});

it("does not divide by zero when there are no events", () => {
const result = toServiceHealth([
{
service: "idle-service",
total_events: 0,
total_errors: 0,
avg_p95_latency_ms: null,
max_latency_ms: null,
},
]);
expect(result[0].error_rate).toBe(0);
expect(result[0].avg_p95_latency_ms).toBeNull();
expect(result[0].max_latency_ms).toBeNull();
});

it("treats null total_events/total_errors as zero", () => {
const result = toServiceHealth([
{
service: "new-service",
total_events: null,
total_errors: null,
avg_p95_latency_ms: null,
max_latency_ms: null,
},
]);
expect(result[0].total_events).toBe(0);
expect(result[0].total_errors).toBe(0);
expect(result[0].error_rate).toBe(0);
});

it("handles multiple services independently", () => {
const result = toServiceHealth([
{ service: "a", total_events: 100, total_errors: 50, avg_p95_latency_ms: 1, max_latency_ms: 2 },
{ service: "b", total_events: 100, total_errors: 1, avg_p95_latency_ms: 1, max_latency_ms: 2 },
]);
expect(result[0].error_rate).toBe(0.5);
expect(result[1].error_rate).toBe(0.01);
});
});

describe("parseHoursParam", () => {
it("returns the fallback when value is null", () => {
expect(parseHoursParam(null, 24)).toBe(24);
});

it("parses a valid numeric string", () => {
expect(parseHoursParam("6", 24)).toBe(6);
});

it("returns the fallback for non-numeric input", () => {
expect(parseHoursParam("not-a-number", 24)).toBe(24);
});

it("returns the fallback for zero or negative values", () => {
expect(parseHoursParam("0", 24)).toBe(24);
expect(parseHoursParam("-5", 24)).toBe(24);
});
});
31 changes: 31 additions & 0 deletions services/dashboard-nextjs/lib/serviceHealth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ServiceHealth } from "@/lib/types";

interface RawServiceStatsRow {
service: string;
total_events: string | number | null;
total_errors: string | number | null;
avg_p95_latency_ms: string | number | null;
max_latency_ms: string | number | null;
}

/**
* Transforms raw aggregate rows from log_events_1min into ServiceHealth
* objects, computing error_rate safely (no division by zero for services
* with no events in the window).
*/
export function toServiceHealth(rows: RawServiceStatsRow[]): ServiceHealth[] {
return rows.map((r) => {
const totalEvents = Number(r.total_events) || 0;
const totalErrors = Number(r.total_errors) || 0;
return {
service: r.service,
total_events: totalEvents,
total_errors: totalErrors,
error_rate: totalEvents > 0 ? totalErrors / totalEvents : 0,
avg_p95_latency_ms: r.avg_p95_latency_ms !== null ? Number(r.avg_p95_latency_ms) : null,
max_latency_ms: r.max_latency_ms !== null ? Number(r.max_latency_ms) : null,
};
});
}

export { parsePositiveNumberParam as parseHoursParam } from "./queryParams";
Loading
Loading