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
32 changes: 30 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ jobs:
- name: Run tests
working-directory: services/llm-query-python
run: python -m pytest tests/ -v
lint-build-dashboard:
name: Lint & Build (Next.js Dashboard)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: services/dashboard-nextjs/package-lock.json
- name: Install dependencies
working-directory: services/dashboard-nextjs
run: npm ci

- name: Run ESLint
working-directory: services/dashboard-nextjs
run: npm run lint
- name: Build
working-directory: services/dashboard-nextjs
run: npm run build
test-go-integration:
name: Integration Test (Go + TimescaleDB)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -162,7 +184,7 @@ jobs:
docker-build:
name: Docker Build Validation
runs-on: ubuntu-latest
needs: [test-go, lint-test-rust, test-python]
needs: [test-go, lint-test-rust, test-python, lint-build-dashboard]
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -194,11 +216,17 @@ jobs:
context: services/llm-query-python
push: false
tags: sentinelops/llm-query-python:ci
- name: Build dashboard image
uses: docker/build-push-action@v6
with:
context: services/dashboard-nextjs
push: false
tags: sentinelops/dashboard-nextjs:ci
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs:
[lint-go, test-go, test-go-integration, lint-test-rust, lint-python, test-python, docker-build]
[lint-go, test-go, test-go-integration, lint-test-rust, lint-python, test-python, lint-build-dashboard, docker-build]
if: always()
steps:
- name: Check all jobs succeeded
Expand Down
1 change: 1 addition & 0 deletions docs/MILESTONES.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Status and scope for each phase of SentinelOps. Dates are approximate; this is a

## Known gaps (tracked, not blocking)
- No Grafana/Prometheus scrape config yet — metrics are exposed but not yet centrally collected
- Sustained 40-49% error rates across all services for over an hour post-recovery (not resolving over time as event counts grew 10x) — this looks like a real bug (possibly in the `/api/services` error-rate calculation, or an actual issue in the log producer/ingestion path) rather than a transient catch-up artifact. Needs investigation as the next priority after Milestone 4.

## Recently closed
- `anomaly-rust`: added `detect()` end-to-end tests, including a regression guard for the directional-severity bug
Expand Down
25 changes: 24 additions & 1 deletion infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,30 @@ services:
- "9300:9300"
restart: unless-stopped
networks: [sentinelops-net]

dashboard:
build:
context: ../services/dashboard-nextjs
dockerfile: Dockerfile
container_name: sentinelops-dashboard
depends_on:
timescaledb:
condition: service_healthy
llm-query:
condition: service_started
environment:
DATABASE_URL: postgres://sentinelops:${TIMESCALE_PASSWORD:-devpassword}@timescaledb:5432/sentinelops
LLM_SERVICE_URL: http://llm-query:9300
HOSTNAME: "0.0.0.0"
ports:
- "3000:3000"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
networks: [sentinelops-net]
networks:
sentinelops-net:
driver: bridge
Expand Down
4 changes: 4 additions & 0 deletions services/dashboard-nextjs/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.next/
.git/
*.md
36 changes: 36 additions & 0 deletions services/dashboard-nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
29 changes: 29 additions & 0 deletions services/dashboard-nextjs/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN groupadd --system --gid 1001 nodejs && \
useradd --system --uid 1001 --gid nodejs nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000

CMD ["node", "server.js"]
36 changes: 36 additions & 0 deletions services/dashboard-nextjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

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

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.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [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.

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

## Deploy on Vercel

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.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
25 changes: 25 additions & 0 deletions services/dashboard-nextjs/app/api/anomalies/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
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");

try {
const result = await pool.query<Anomaly>(
`SELECT id, detected_at, bucket_ts, service, anomaly_type,
metric_value, baseline_mean, baseline_stddev, z_score, severity
FROM anomalies
WHERE detected_at >= now() - ($1 || ' hours')::interval
ORDER BY detected_at DESC
LIMIT $2`,
[hours, limit]
);
return NextResponse.json(result.rows);
} catch (error) {
console.error("Failed to fetch anomalies", error);
return NextResponse.json({ error: "Failed to fetch anomalies" }, { status: 500 });
}
}
5 changes: 5 additions & 0 deletions services/dashboard-nextjs/app/api/healthz/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { NextResponse } from "next/server";

export async function GET() {
return NextResponse.json({ status: "ok" });
}
18 changes: 18 additions & 0 deletions services/dashboard-nextjs/app/api/query/[jobId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";

const LLM_SERVICE_URL = process.env.LLM_SERVICE_URL ?? "http://localhost:9300";

export async function GET(
request: Request,
{ params }: { params: Promise<{ jobId: string }> }
) {
const { jobId } = await params;
try {
const response = await fetch(`${LLM_SERVICE_URL}/query/${jobId}`);
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("Failed to fetch query result", error);
return NextResponse.json({ error: "LLM service unreachable" }, { status: 502 });
}
}
19 changes: 19 additions & 0 deletions services/dashboard-nextjs/app/api/query/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";

const LLM_SERVICE_URL = process.env.LLM_SERVICE_URL ?? "http://localhost:9300";

export async function POST(request: Request) {
const body = await request.json();
try {
const response = await fetch(`${LLM_SERVICE_URL}/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("Failed to submit query", error);
return NextResponse.json({ error: "LLM service unreachable" }, { status: 502 });
}
}
41 changes: 41 additions & 0 deletions services/dashboard-nextjs/app/api/services/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import type { ServiceHealth } from "@/lib/types";

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

try {
const result = await pool.query(
`SELECT service,
sum(event_count) AS total_events,
sum(error_count) AS total_errors,
avg(p95_latency_ms) AS avg_p95_latency_ms,
max(max_latency_ms) AS max_latency_ms
FROM log_events_1min
WHERE bucket >= now() - ($1 || ' hours')::interval
GROUP BY service
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);
} catch (error) {
console.error("Failed to fetch service health", error);
return NextResponse.json({ error: "Failed to fetch service health" }, { status: 500 });
}
}
Binary file added services/dashboard-nextjs/app/favicon.ico
Binary file not shown.
Binary file not shown.
Binary file added services/dashboard-nextjs/app/fonts/GeistVF.woff
Binary file not shown.
20 changes: 20 additions & 0 deletions services/dashboard-nextjs/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
* {
@apply border-border;
}
}

@layer utilities {
.text-balance {
text-wrap: balance;
}
}

::selection {
background-color: theme("colors.accent");
color: theme("colors.bg");
}
30 changes: 30 additions & 0 deletions services/dashboard-nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";

const plexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
display: "swap",
});

const plexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-mono",
display: "swap",
});

export const metadata: Metadata = {
title: "SentinelOps",
description: "AI-powered distributed log intelligence and anomaly detection platform.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${plexSans.variable} ${plexMono.variable}`}>
<body className="bg-bg text-text font-sans antialiased">{children}</body>
</html>
);
}
Loading
Loading