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
34 changes: 34 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Node
node_modules
npm-debug.log*
.npm

# Next.js build output (rebuilt inside Docker)
.next
out

# Git
.git
.gitignore
.github

# Environment files (secrets — never bake into image)
.env
.env.*
!.env.example

# Dev tooling
.eslintcache
*.log

# Python
apps/api/__pycache__
apps/api/.ruff_cache
apps/**/__pycache__
apps/**/*.pyc
apps/**/*.pyo

# Misc
*.md
.claude
infra
6 changes: 6 additions & 0 deletions Dockerfile.frontend
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_LIVEBOARD_API_KEY
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_LIVEBOARD_API_KEY=$NEXT_PUBLIC_LIVEBOARD_API_KEY
RUN npm run build

# Production runtime
Expand Down
9 changes: 5 additions & 4 deletions app/(dashboard)/overview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ export default function OverviewPage() {
const { data: chartData } = useChartData(hours);
const { data: incidents } = useIncidents();

// Live stat cards + incidents via Socket.io — falls back to placeholders while connecting
const apiKey = process.env.NEXT_PUBLIC_LIVEBOARD_API_KEY ?? null;
const cards = useMetrics(apiKey);
const liveIncidents = useIncidentSocket(apiKey);
// Live stat cards + incidents via Socket.io — project-scoped realtime token
// fetched from the BFF; falls back to demo data while connecting / keyless.
const projectId = activeProject?.id ?? null;
const cards = useMetrics(projectId);
const liveIncidents = useIncidentSocket(projectId);

return (
<div className="flex flex-col min-h-screen">
Expand Down
10 changes: 8 additions & 2 deletions app/(dashboard)/traces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,15 @@ export default function TracesPage() {
const { data: traces } = useTraces();
const [selectedTrace, setSelectedTrace] = useState<Trace | null>(null);

// Auto-select first trace when data first arrives
// Keep the selection in sync with the current trace list: clear it when the
// list is empty (e.g. a real project with no spans, or after switching
// projects), and pick the first trace when the current one is gone.
useEffect(() => {
if (traces.length > 0 && !selectedTrace) setSelectedTrace(traces[0]);
if (traces.length === 0) {
setSelectedTrace(null);
} else if (!selectedTrace || !traces.some((t) => t.id === selectedTrace.id)) {
setSelectedTrace(traces[0]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [traces]);
const [selectedSpan, setSelectedSpan] = useState<TraceSpan | null>(null);
Expand Down
62 changes: 62 additions & 0 deletions app/api/lb/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";

/**
* Session-scoped read/write proxy for dashboard telemetry.
*
* browser (no key) → here (NextAuth session) → backend (internal token + project)
*
* Enforces that the requested `?project=<id>` belongs to the signed-in user
* (via `session.projectIds`), then forwards to the FastAPI backend with the
* server-only internal token and an explicit `x-project-id`. The browser never
* holds a project API key or the internal token.
*/

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
function internalToken(): string {
return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? "";
}

async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const reqUrl = new URL(req.url);
const project = reqUrl.searchParams.get("project");
if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 });
if (!session.projectIds?.includes(project)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}

const { path } = await ctx.params;
reqUrl.searchParams.delete("project");
const qs = reqUrl.searchParams.toString();
const target = `${API_URL}/${path.join("/")}${qs ? `?${qs}` : ""}`;

const init: RequestInit = {
method: req.method,
headers: {
"x-internal-token": internalToken(),
"x-project-id": project,
"content-type": "application/json",
},
cache: "no-store",
};
if (req.method !== "GET" && req.method !== "HEAD") {
const body = await req.text();
if (body) init.body = body;
}

const res = await fetch(target, init);
const text = await res.text();
return new NextResponse(text || null, {
status: res.status,
headers: { "content-type": res.headers.get("content-type") ?? "application/json" },
});
}

export const GET = proxy;
export const POST = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
29 changes: 29 additions & 0 deletions app/api/projects/[id]/rotate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
function internalToken(): string {
return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? "";
}

export async function POST(
_req: Request,
ctx: { params: Promise<{ id: string }> }
) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const { id } = await ctx.params;
const res = await fetch(
`${API_URL}/v1/internal/projects/${encodeURIComponent(id)}/rotate`,
{
method: "POST",
headers: { "content-type": "application/json", "x-internal-token": internalToken() },
body: JSON.stringify({ email }),
cache: "no-store",
}
);
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
23 changes: 23 additions & 0 deletions app/api/projects/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
function internalToken(): string {
return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? "";
}

export async function DELETE(
_req: Request,
ctx: { params: Promise<{ id: string }> }
) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const { id } = await ctx.params;
const res = await fetch(
`${API_URL}/v1/internal/projects/${encodeURIComponent(id)}?email=${encodeURIComponent(email)}`,
{ method: "DELETE", headers: { "x-internal-token": internalToken() }, cache: "no-store" }
);
return new NextResponse(null, { status: res.ok ? 204 : res.status });
}
44 changes: 44 additions & 0 deletions app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";

// BFF: dashboard → here (session-authed) → backend internal endpoints (token-authed).
// The browser never sees the internal token or a project API key.

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
function internalToken(): string {
return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? "";
}

export async function GET() {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const res = await fetch(
`${API_URL}/v1/internal/projects?email=${encodeURIComponent(email)}`,
{ headers: { "x-internal-token": internalToken() }, cache: "no-store" }
);
if (!res.ok) return NextResponse.json({ error: "backend_error" }, { status: 502 });
return NextResponse.json(await res.json());
}

export async function POST(req: Request) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const body = await req.json().catch(() => ({}));
const name = String(body?.name ?? "").trim();
if (name.length < 2) {
return NextResponse.json({ error: "invalid_name" }, { status: 422 });
}

const res = await fetch(`${API_URL}/v1/internal/projects`, {
method: "POST",
headers: { "content-type": "application/json", "x-internal-token": internalToken() },
body: JSON.stringify({ email, name }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
32 changes: 32 additions & 0 deletions app/api/realtime-token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";

// Mints a short-lived, project-scoped realtime token for the browser's
// Socket.io / SSE connections — only after verifying the signed-in user is a
// member of the requested project. The browser never holds an API key.

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
function internalToken(): string {
return process.env.INTERNAL_SERVICE_TOKEN ?? process.env.API_SECRET_KEY ?? "";
}

export async function GET(req: Request) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.json({ error: "unauthenticated" }, { status: 401 });

const project = new URL(req.url).searchParams.get("project");
if (!project) return NextResponse.json({ error: "missing_project" }, { status: 400 });
if (!session.projectIds?.includes(project)) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}

const res = await fetch(`${API_URL}/v1/internal/realtime-token`, {
method: "POST",
headers: { "content-type": "application/json", "x-internal-token": internalToken() },
body: JSON.stringify({ project_id: project }),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
13 changes: 10 additions & 3 deletions app/auth/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { Suspense, useState } from "react";
import { LiveboardIcon } from "@/components/logo";

const hasGoogle =
Boolean(process.env.NEXT_PUBLIC_GOOGLE_ENABLED) ||
// fallback: let the server tell us via the error message
false;

const isDev = process.env.NODE_ENV !== "production";
Expand All @@ -20,7 +19,7 @@ const ERROR_MESSAGES: Record<string, string> = {
Configuration: "Auth is not fully configured yet.",
};

export default function SignInPage() {
function SignInForm() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/overview";
const error = searchParams.get("error");
Expand Down Expand Up @@ -139,6 +138,14 @@ export default function SignInPage() {
);
}

export default function SignInPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#0A0A0A]" />}>
<SignInForm />
</Suspense>
);
}

function GoogleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
Expand Down
Loading
Loading