@splyntra/dashboard 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/next.config.js +33 -0
  2. package/package.json +62 -0
  3. package/postcss.config.js +7 -0
  4. package/public/manifest.json +9 -0
  5. package/src/app/accept-invite/page.tsx +43 -0
  6. package/src/app/agents/layout.tsx +11 -0
  7. package/src/app/agents/page.tsx +149 -0
  8. package/src/app/alerts/page.tsx +227 -0
  9. package/src/app/api/auth/[...nextauth]/route.ts +4 -0
  10. package/src/app/api/eval/[...path]/route.ts +61 -0
  11. package/src/app/api/v1/[...path]/route.ts +87 -0
  12. package/src/app/auth-actions.ts +103 -0
  13. package/src/app/costs/layout.tsx +11 -0
  14. package/src/app/costs/page.tsx +155 -0
  15. package/src/app/evaluations/page.tsx +135 -0
  16. package/src/app/globals.css +42 -0
  17. package/src/app/layout.tsx +26 -0
  18. package/src/app/login/page.tsx +52 -0
  19. package/src/app/metrics/page.tsx +148 -0
  20. package/src/app/not-found.tsx +23 -0
  21. package/src/app/page.tsx +56 -0
  22. package/src/app/projects/page.tsx +130 -0
  23. package/src/app/providers.tsx +33 -0
  24. package/src/app/settings/keys/page.tsx +174 -0
  25. package/src/app/settings/team/InviteForm.tsx +44 -0
  26. package/src/app/settings/team/page.tsx +112 -0
  27. package/src/app/signup/page.tsx +33 -0
  28. package/src/app/traces/[traceId]/page.tsx +132 -0
  29. package/src/app/traces/layout.tsx +11 -0
  30. package/src/app/traces/page.tsx +31 -0
  31. package/src/auth.config.ts +60 -0
  32. package/src/auth.ts +54 -0
  33. package/src/components/auth/AuthCard.tsx +45 -0
  34. package/src/components/layout/AppShell.tsx +22 -0
  35. package/src/components/layout/Sidebar.tsx +177 -0
  36. package/src/components/trace/TraceList.tsx +81 -0
  37. package/src/components/trace/TraceViewer.test.tsx +82 -0
  38. package/src/components/trace/TraceViewer.tsx +237 -0
  39. package/src/components/ui/ErrorBoundary.tsx +57 -0
  40. package/src/components/ui/Skeleton.tsx +40 -0
  41. package/src/components/ui/primitives.tsx +171 -0
  42. package/src/global.d.ts +1 -0
  43. package/src/lib/api.test.ts +24 -0
  44. package/src/lib/api.ts +379 -0
  45. package/src/lib/auth-extensions.ts +47 -0
  46. package/src/lib/auth-providers.ts +8 -0
  47. package/src/lib/collector-auth-providers.ts +8 -0
  48. package/src/lib/collector-auth.ts +52 -0
  49. package/src/lib/db.ts +27 -0
  50. package/src/lib/features.ts +19 -0
  51. package/src/lib/hooks.ts +116 -0
  52. package/src/lib/project-context.tsx +47 -0
  53. package/src/lib/slots.ts +50 -0
  54. package/src/middleware.ts +12 -0
  55. package/src/types/trace.ts +55 -0
  56. package/tailwind.config.js +26 -0
  57. package/tsconfig.json +29 -0
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { Users } from "lucide-react";
3
+ import { auth } from "@/auth";
4
+ import { pool, roleAtLeast } from "@/lib/db";
5
+ import { updateRoleAction, removeMemberAction } from "@/app/auth-actions";
6
+ import { InviteForm } from "./InviteForm";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ interface Member {
11
+ user_id: string;
12
+ email: string;
13
+ name: string;
14
+ role: string;
15
+ created_at: string;
16
+ }
17
+
18
+ export default async function TeamPage() {
19
+ const session = await auth();
20
+ const orgId = (session?.user as { orgId?: string })?.orgId;
21
+ const myRole = (session?.user as { role?: string })?.role;
22
+ const canManage = roleAtLeast(myRole, "admin");
23
+
24
+ let members: Member[] = [];
25
+ if (orgId) {
26
+ const { rows } = await pool.query(
27
+ `SELECT u.id::text AS user_id, u.email, u.name, m.role, m.created_at
28
+ FROM memberships m JOIN users u ON u.id = m.user_id
29
+ WHERE m.org_id = $1 ORDER BY m.created_at ASC`,
30
+ [orgId]
31
+ );
32
+ members = rows;
33
+ }
34
+
35
+ return (
36
+ <div className="mx-auto max-w-4xl p-6">
37
+ <div className="mb-6 flex items-start gap-3">
38
+ <div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-lg bg-splyntra-50 text-splyntra-600 dark:bg-splyntra-900/30">
39
+ <Users className="h-5 w-5" />
40
+ </div>
41
+ <div>
42
+ <h1 className="text-xl font-semibold tracking-tight">Team</h1>
43
+ <p className="text-sm text-gray-500">
44
+ Members of your organization · your role: <span className="font-medium">{myRole || "—"}</span>
45
+ </p>
46
+ </div>
47
+ </div>
48
+
49
+ {canManage && (
50
+ <div className="mb-6">
51
+ <InviteForm />
52
+ </div>
53
+ )}
54
+
55
+ <div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
56
+ <table className="w-full text-sm">
57
+ <thead className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-800/50">
58
+ <tr className="[&>th]:px-4 [&>th]:py-3 [&>th]:font-medium [&>th]:text-gray-500">
59
+ <th>Member</th>
60
+ <th>Role</th>
61
+ <th className="text-right">Joined</th>
62
+ {canManage && <th />}
63
+ </tr>
64
+ </thead>
65
+ <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
66
+ {members.map((m) => (
67
+ <tr key={m.user_id} className="hover:bg-gray-50 dark:hover:bg-gray-800/60">
68
+ <td className="px-4 py-3">
69
+ <div className="font-medium">{m.name || m.email}</div>
70
+ <div className="text-xs text-gray-500">{m.email}</div>
71
+ </td>
72
+ <td className="px-4 py-3">
73
+ {canManage ? (
74
+ <form action={updateRoleAction} className="inline-flex items-center gap-2">
75
+ <input type="hidden" name="user_id" value={m.user_id} />
76
+ <select name="role" defaultValue={m.role} className="rounded-md border px-2 py-1 text-xs dark:bg-gray-800">
77
+ {["owner", "admin", "member", "viewer"].map((r) => (
78
+ <option key={r} value={r}>{r}</option>
79
+ ))}
80
+ </select>
81
+ <button className="text-xs text-splyntra-600 hover:underline">save</button>
82
+ </form>
83
+ ) : (
84
+ <span className="text-gray-600">{m.role}</span>
85
+ )}
86
+ </td>
87
+ <td className="px-4 py-3 text-right text-xs text-gray-500">
88
+ {new Date(m.created_at).toLocaleDateString()}
89
+ </td>
90
+ {canManage && (
91
+ <td className="px-4 py-3 text-right">
92
+ <form action={removeMemberAction}>
93
+ <input type="hidden" name="user_id" value={m.user_id} />
94
+ <button className="text-xs text-red-600 hover:underline">remove</button>
95
+ </form>
96
+ </td>
97
+ )}
98
+ </tr>
99
+ ))}
100
+ {members.length === 0 && (
101
+ <tr>
102
+ <td colSpan={4} className="px-4 py-8 text-center text-gray-500">
103
+ No members yet.
104
+ </td>
105
+ </tr>
106
+ )}
107
+ </tbody>
108
+ </table>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useFormState } from "react-dom";
5
+ import Link from "next/link";
6
+ import { signupAction } from "@/app/auth-actions";
7
+ import { AuthCard, Field } from "@/components/auth/AuthCard";
8
+
9
+ export default function SignupPage() {
10
+ const [state, formAction] = useFormState(signupAction, { error: "" });
11
+ return (
12
+ <AuthCard title="Create your Splyntra account">
13
+ <form action={formAction} className="space-y-3">
14
+ <Field name="name" type="text" label="Name" />
15
+ <Field name="email" type="email" label="Email" />
16
+ <Field name="password" type="password" label="Password (8+ chars)" />
17
+ {state?.error ? <p className="text-sm text-red-600">{state.error}</p> : null}
18
+ <button
19
+ type="submit"
20
+ className="w-full rounded-lg bg-splyntra-600 py-2 text-sm font-medium text-white hover:bg-splyntra-700"
21
+ >
22
+ Create account
23
+ </button>
24
+ </form>
25
+ <p className="mt-4 text-center text-sm text-gray-500">
26
+ Already have an account?{" "}
27
+ <Link href="/login" className="text-splyntra-600 hover:underline">
28
+ Sign in
29
+ </Link>
30
+ </p>
31
+ </AuthCard>
32
+ );
33
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useParams } from "next/navigation";
5
+ import { useTrace } from "@/lib/hooks";
6
+ import { TraceViewer } from "@/components/trace/TraceViewer";
7
+ import { Trace, Span, Detection } from "@/types/trace";
8
+ import { TraceDetailResponse, SpanItem, DetectionItem } from "@/lib/api";
9
+ import Link from "next/link";
10
+ import { ArrowLeft, FileSearch } from "lucide-react";
11
+ import { EmptyState } from "@/components/ui/primitives";
12
+
13
+ // Map API response to the Trace type expected by TraceViewer
14
+ function apiToTrace(traceId: string, data: TraceDetailResponse): Trace {
15
+ const spans: Span[] = (data.spans || []).map((s: SpanItem) => ({
16
+ spanId: s.span_id,
17
+ parentSpanId: s.parent_span_id || undefined,
18
+ type: (s.type as Span["type"]) || "step",
19
+ name: s.name,
20
+ status: (s.status as "ok" | "error") || "ok",
21
+ latencyMs: s.latency_ms,
22
+ tokens: s.prompt_tokens
23
+ ? {
24
+ promptTokens: s.prompt_tokens,
25
+ completionTokens: s.completion_tokens,
26
+ totalTokens: s.prompt_tokens + s.completion_tokens,
27
+ model: s.model || "unknown",
28
+ costUsd: s.cost_usd,
29
+ }
30
+ : undefined,
31
+ input: s.input_preview ? { content: s.input_preview } : undefined,
32
+ output: s.output_preview ? { content: s.output_preview } : undefined,
33
+ detections: (data.detections || [])
34
+ .filter((d: DetectionItem) => d.span_id === s.span_id)
35
+ .map(mapDetection),
36
+ startedAt: s.started_at,
37
+ }));
38
+
39
+ const detections: Detection[] = (data.detections || []).map(mapDetection);
40
+
41
+ const totalLatency = spans.length > 0 ? Math.max(...spans.map((s) => s.latencyMs)) : 0;
42
+ const totalTokens = spans.reduce(
43
+ (sum, s) => sum + (s.tokens?.totalTokens || 0),
44
+ 0
45
+ );
46
+ const totalCost = spans.reduce((sum, s) => sum + (s.tokens?.costUsd || 0), 0);
47
+ const maxSeverity = getMaxSeverity(detections);
48
+ const riskScore = computeRiskScore(detections);
49
+
50
+ return {
51
+ traceId,
52
+ agentId: spans[0]?.name || "unknown",
53
+ status: spans.some((s) => s.status === "error") ? "error" : "ok",
54
+ latencyMs: totalLatency,
55
+ totalTokens,
56
+ costUsd: totalCost,
57
+ riskScore,
58
+ riskSeverity: maxSeverity,
59
+ detections,
60
+ spans,
61
+ startedAt: spans[0]?.startedAt || new Date().toISOString(),
62
+ completedAt: new Date().toISOString(),
63
+ orgId: "",
64
+ projectId: "",
65
+ environment: "",
66
+ };
67
+ }
68
+
69
+ function mapDetection(d: DetectionItem): Detection {
70
+ return {
71
+ detector: d.detector as Detection["detector"],
72
+ category: d.category,
73
+ severity: d.severity as Detection["severity"],
74
+ confidence: d.confidence,
75
+ description: d.description,
76
+ beta: d.is_beta === 1,
77
+ spanId: d.span_id,
78
+ };
79
+ }
80
+
81
+ function getMaxSeverity(detections: Detection[]): Trace["riskSeverity"] {
82
+ const order: Trace["riskSeverity"][] = ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"];
83
+ let max = 0;
84
+ for (const d of detections) {
85
+ const idx = order.indexOf(d.severity);
86
+ if (idx > max) max = idx;
87
+ }
88
+ return order[max];
89
+ }
90
+
91
+ function computeRiskScore(detections: Detection[]): number {
92
+ const weights: Record<string, number> = { LOW: 10, MEDIUM: 25, HIGH: 50, CRITICAL: 90 };
93
+ let score = 0;
94
+ for (const d of detections) {
95
+ score += (weights[d.severity] || 0) * d.confidence;
96
+ }
97
+ return Math.min(Math.round(score), 100);
98
+ }
99
+
100
+ export default function TraceDetailPage() {
101
+ const params = useParams();
102
+ const traceId = params.traceId as string;
103
+ const { data, isLoading, error } = useTrace(traceId);
104
+
105
+ const hasSpans = !!data && (data.spans?.length || 0) > 0;
106
+ const trace = hasSpans ? apiToTrace(traceId, data!) : null;
107
+
108
+ return (
109
+ <div className="mx-auto max-w-6xl p-6">
110
+ <div className="mb-4">
111
+ <Link
112
+ href="/traces"
113
+ className="inline-flex items-center gap-1.5 text-sm text-splyntra-600 hover:text-splyntra-700 hover:underline dark:text-splyntra-300"
114
+ >
115
+ <ArrowLeft className="h-4 w-4" />
116
+ Back to traces
117
+ </Link>
118
+ </div>
119
+
120
+ {isLoading ? (
121
+ <div className="py-12 text-center text-gray-500">Loading trace…</div>
122
+ ) : trace ? (
123
+ <TraceViewer trace={trace} />
124
+ ) : (
125
+ <EmptyState icon={FileSearch} title="Trace not found">
126
+ {error ? "Could not reach the collector." : `No trace with id ${traceId} in this project.`}
127
+ <code className="mt-2 block text-xs text-gray-400">{traceId}</code>
128
+ </EmptyState>
129
+ )}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import type { Metadata } from "next";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "Traces | Splyntra",
6
+ description: "View agent execution traces with unified risk scoring",
7
+ };
8
+
9
+ export default function TracesLayout({ children }: { children: React.ReactNode }) {
10
+ return children;
11
+ }
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useTraces } from "@/lib/hooks";
5
+ import { TraceList } from "@/components/trace/TraceList";
6
+ import { TableSkeleton } from "@/components/ui/Skeleton";
7
+ import { PageHeader } from "@/components/ui/primitives";
8
+ import { Activity } from "lucide-react";
9
+
10
+ export default function TracesPage() {
11
+ const { data, isLoading, error } = useTraces();
12
+
13
+ const traces = data?.traces || [];
14
+
15
+ return (
16
+ <div className="mx-auto max-w-6xl p-6">
17
+ <PageHeader
18
+ icon={Activity}
19
+ title="Traces"
20
+ subtitle="Agent execution traces with unified risk scoring"
21
+ />
22
+ {error && !isLoading && (
23
+ <p className="mb-4 text-xs text-red-500">
24
+ Could not reach the collector. Check that the stack is running.
25
+ </p>
26
+ )}
27
+
28
+ {isLoading ? <TableSkeleton rows={5} cols={8} /> : <TraceList traces={traces} />}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import type { NextAuthConfig } from "next-auth";
3
+
4
+ // Edge-safe config (no DB / bcrypt). Shared by the middleware and the full
5
+ // Node instance. The `authorized` callback gates every app route behind login.
6
+ const PUBLIC = ["/login", "/signup", "/accept-invite"];
7
+
8
+ export const authConfig: NextAuthConfig = {
9
+ // Self-hosted (Docker/Helm) serves behind a service name / arbitrary host, so
10
+ // trust the incoming Host. Read the secret from either env name (Auth.js v5
11
+ // defaults to AUTH_SECRET; our deploy sets NEXTAUTH_SECRET).
12
+ trustHost: true,
13
+ // Auth.js v5 defaults to AUTH_SECRET; our deploy sets NEXTAUTH_SECRET. In
14
+ // local dev (npm run dev) neither may be set, so fall back to a fixed dev
15
+ // secret — but in production an unset secret is a hard error.
16
+ // The insecure dev secret activates ONLY when NODE_ENV is explicitly
17
+ // "development". In production (or an unset NODE_ENV) it stays undefined, so
18
+ // next-auth hard-fails at startup rather than silently using a known secret.
19
+ secret:
20
+ process.env.AUTH_SECRET ||
21
+ process.env.NEXTAUTH_SECRET ||
22
+ (process.env.NODE_ENV === "development" ? "splyntra-dev-insecure-secret" : undefined),
23
+ pages: { signIn: "/login" },
24
+ session: { strategy: "jwt" },
25
+ providers: [], // real providers are added in auth.ts (Node runtime)
26
+ callbacks: {
27
+ authorized({ auth, request: { nextUrl } }) {
28
+ const loggedIn = !!auth?.user;
29
+ const isPublic =
30
+ PUBLIC.some((p) => nextUrl.pathname.startsWith(p)) ||
31
+ nextUrl.pathname.startsWith("/api/auth");
32
+ if (isPublic) return true;
33
+ return loggedIn; // redirects to signIn page when false
34
+ },
35
+ jwt({ token, user, trigger, session }) {
36
+ if (user) {
37
+ token.userId = (user as { id?: string }).id;
38
+ token.orgId = (user as { orgId?: string }).orgId;
39
+ token.role = (user as { role?: string }).role;
40
+ }
41
+ // Allow a session update() to set the active org/role without re-login —
42
+ // used by the cloud edition after org creation / org switching. Edge-safe
43
+ // (no DB): the caller passes the already-resolved values.
44
+ if (trigger === "update" && session) {
45
+ const s = session as { orgId?: string; role?: string };
46
+ if (s.orgId) token.orgId = s.orgId;
47
+ if (s.role) token.role = s.role;
48
+ }
49
+ return token;
50
+ },
51
+ session({ session, token }) {
52
+ if (session.user) {
53
+ (session.user as { id?: string }).id = token.userId as string;
54
+ (session.user as { orgId?: string }).orgId = token.orgId as string;
55
+ (session.user as { role?: string }).role = token.role as string;
56
+ }
57
+ return session;
58
+ },
59
+ },
60
+ };
package/src/auth.ts ADDED
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import NextAuth from "next-auth";
3
+ import Credentials from "next-auth/providers/credentials";
4
+ import bcrypt from "bcryptjs";
5
+ import { authConfig } from "@/auth.config";
6
+ import { pool } from "@/lib/db";
7
+ // Side-effect import: a no-op in the open edition; the cloud build's overlay
8
+ // replaces this module to register OAuth providers + the org-onboarding hook.
9
+ import "@/lib/auth-providers";
10
+ import { registeredAuthProviders, registeredSignInHooks } from "@/lib/auth-extensions";
11
+
12
+ export const { handlers, auth, signIn, signOut } = NextAuth({
13
+ ...authConfig,
14
+ events: {
15
+ async signIn({ user, account }) {
16
+ for (const hook of registeredSignInHooks()) {
17
+ await hook(user as { id?: string; email?: string | null }, account);
18
+ }
19
+ },
20
+ },
21
+ providers: [
22
+ Credentials({
23
+ credentials: { email: {}, password: {} },
24
+ async authorize(creds) {
25
+ const email = String(creds?.email || "").toLowerCase().trim();
26
+ const password = String(creds?.password || "");
27
+ if (!email || !password) return null;
28
+
29
+ const { rows } = await pool.query(
30
+ `SELECT u.id::text, u.email, u.name, u.password_hash,
31
+ m.org_id::text AS org_id, m.role
32
+ FROM users u
33
+ LEFT JOIN memberships m ON m.user_id = u.id
34
+ WHERE u.email = $1
35
+ ORDER BY m.created_at ASC
36
+ LIMIT 1`,
37
+ [email]
38
+ );
39
+ const row = rows[0];
40
+ if (!row || !(await bcrypt.compare(password, row.password_hash))) return null;
41
+
42
+ return {
43
+ id: row.id,
44
+ email: row.email,
45
+ name: row.name,
46
+ orgId: row.org_id,
47
+ role: row.role || "member",
48
+ };
49
+ },
50
+ }),
51
+ // Extension providers (OAuth in the cloud build; none in the open edition).
52
+ ...registeredAuthProviders(),
53
+ ],
54
+ });
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { ShieldCheck } from "lucide-react";
5
+
6
+ export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
7
+ return (
8
+ <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
9
+ <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
10
+ <div className="mb-5 flex items-center gap-2">
11
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-splyntra-600 text-white">
12
+ <ShieldCheck className="h-5 w-5" />
13
+ </div>
14
+ <h1 className="text-lg font-semibold tracking-tight">{title}</h1>
15
+ </div>
16
+ {children}
17
+ </div>
18
+ </div>
19
+ );
20
+ }
21
+
22
+ export function Field({
23
+ name,
24
+ type,
25
+ label,
26
+ defaultValue,
27
+ }: {
28
+ name: string;
29
+ type: string;
30
+ label: string;
31
+ defaultValue?: string;
32
+ }) {
33
+ return (
34
+ <label className="block text-sm">
35
+ <span className="mb-1 block text-gray-600 dark:text-gray-300">{label}</span>
36
+ <input
37
+ name={name}
38
+ type={type}
39
+ defaultValue={defaultValue}
40
+ required
41
+ className="w-full rounded-lg border border-gray-200 px-3 py-2 outline-none focus:border-splyntra-400 focus:ring-2 focus:ring-splyntra-100 dark:border-gray-700 dark:bg-gray-800"
42
+ />
43
+ </label>
44
+ );
45
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { usePathname } from "next/navigation";
5
+ import { Sidebar } from "./Sidebar";
6
+
7
+ const AUTH_ROUTES = ["/login", "/signup", "/accept-invite"];
8
+
9
+ /** Renders the full app chrome (sidebar) except on auth routes, which are
10
+ * standalone centered pages. */
11
+ export function AppShell({ children }: { children: React.ReactNode }) {
12
+ const pathname = usePathname();
13
+ if (AUTH_ROUTES.some((p) => pathname.startsWith(p))) {
14
+ return <div className="min-h-screen">{children}</div>;
15
+ }
16
+ return (
17
+ <div className="flex h-screen">
18
+ <Sidebar />
19
+ <main className="flex-1 overflow-auto">{children}</main>
20
+ </div>
21
+ );
22
+ }
@@ -0,0 +1,177 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import { useSession, signOut } from "next-auth/react";
7
+ import {
8
+ LayoutDashboard,
9
+ Activity,
10
+ Bot,
11
+ DollarSign,
12
+ LineChart,
13
+ ClipboardCheck,
14
+ FolderKanban,
15
+ Bell,
16
+ Users,
17
+ ScrollText,
18
+ Scale,
19
+ KeyRound,
20
+ CreditCard,
21
+ Building2,
22
+ ShieldCheck,
23
+ ChevronDown,
24
+ LogOut,
25
+ type LucideIcon,
26
+ } from "lucide-react";
27
+ import { useProjects } from "@/lib/hooks";
28
+ import { useProject } from "@/lib/project-context";
29
+ import { features } from "@/lib/features";
30
+ import { navSlotItems, slotWidgets } from "@/lib/slots";
31
+
32
+ // Icons available to slot-contributed nav items (referenced by name so the
33
+ // slots module stays free of React/icon imports).
34
+ const ICONS: Record<string, LucideIcon> = {
35
+ ScrollText,
36
+ Scale,
37
+ KeyRound,
38
+ CreditCard,
39
+ Building2,
40
+ Users,
41
+ };
42
+
43
+ // Core (open-source) navigation. Commercial sections (governance: ledger,
44
+ // policies, delegation) are contributed by extension slots in the cloud build.
45
+ const navItems: { href: string; label: string; icon: LucideIcon }[] = [
46
+ { href: "/", label: "Home", icon: LayoutDashboard },
47
+ { href: "/traces", label: "Traces", icon: Activity },
48
+ { href: "/agents", label: "Agents", icon: Bot },
49
+ { href: "/metrics", label: "Metrics", icon: LineChart },
50
+ { href: "/evaluations", label: "Evaluation", icon: ClipboardCheck },
51
+ { href: "/costs", label: "Costs", icon: DollarSign },
52
+ { href: "/projects", label: "Projects", icon: FolderKanban },
53
+ { href: "/alerts", label: "Alerts", icon: Bell },
54
+ { href: "/settings/keys", label: "API Keys", icon: KeyRound },
55
+ { href: "/settings/team", label: "Team", icon: Users },
56
+ ];
57
+
58
+ // Merge core nav with slot-contributed items whose feature flag is enabled.
59
+ function resolveNavItems(): { href: string; label: string; icon: LucideIcon }[] {
60
+ const slotted = navSlotItems()
61
+ .filter((i) => !i.feature || features[i.feature as keyof typeof features])
62
+ .map((i) => ({ href: i.href, label: i.label, icon: ICONS[i.icon] ?? LayoutDashboard }));
63
+ return [...navItems, ...slotted];
64
+ }
65
+
66
+ export function Sidebar() {
67
+ const pathname = usePathname();
68
+ const items = resolveNavItems();
69
+
70
+ return (
71
+ <aside className="flex w-60 flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
72
+ {/* Logo */}
73
+ <div className="flex h-16 items-center border-b border-gray-200 px-4 dark:border-gray-800">
74
+ <Link href="/" className="flex items-center gap-2.5">
75
+ <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-splyntra-600 text-white shadow-sm">
76
+ <ShieldCheck className="h-5 w-5" />
77
+ </div>
78
+ <div className="leading-tight">
79
+ <span className="block font-semibold tracking-tight text-splyntra-900 dark:text-white">Splyntra</span>
80
+ <span className="block text-[10px] uppercase tracking-wider text-gray-400">Observability + Security</span>
81
+ </div>
82
+ </Link>
83
+ </div>
84
+
85
+ {/* Sidebar-top widgets (e.g. org switcher in the cloud build) + project selector */}
86
+ <div className="space-y-3 border-b border-gray-100 px-3 py-3 dark:border-gray-800">
87
+ {slotWidgets("sidebarTop").map((W, i) => (
88
+ <W key={i} />
89
+ ))}
90
+ <ProjectSelector />
91
+ </div>
92
+
93
+ {/* Nav */}
94
+ <nav className="flex-1 space-y-0.5 p-3">
95
+ {items.map((item) => {
96
+ const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
97
+ const Icon = item.icon;
98
+ return (
99
+ <Link
100
+ key={item.href}
101
+ href={item.href}
102
+ className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
103
+ isActive
104
+ ? "bg-splyntra-50 font-medium text-splyntra-700 dark:bg-splyntra-900/30 dark:text-splyntra-100"
105
+ : "text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
106
+ }`}
107
+ >
108
+ <Icon className={`h-4 w-4 ${isActive ? "text-splyntra-600 dark:text-splyntra-300" : "text-gray-400 group-hover:text-gray-500"}`} />
109
+ {item.label}
110
+ </Link>
111
+ );
112
+ })}
113
+ </nav>
114
+
115
+ {/* Footer */}
116
+ <div className="border-t border-gray-200 px-3 py-3 dark:border-gray-800">
117
+ <UserFooter />
118
+ <div className="mt-2 px-1 text-xs text-gray-400">
119
+ <span className="inline-flex items-center gap-1.5">
120
+ <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> dev
121
+ </span>
122
+ <span className="ml-2">v0.1.0</span>
123
+ </div>
124
+ </div>
125
+ </aside>
126
+ );
127
+ }
128
+
129
+ function UserFooter() {
130
+ const { data: session } = useSession();
131
+ const user = session?.user as { email?: string; role?: string } | undefined;
132
+ if (!user?.email) return null;
133
+ return (
134
+ <div className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5">
135
+ <div className="min-w-0">
136
+ <div className="truncate text-xs font-medium text-gray-700 dark:text-gray-200">{user.email}</div>
137
+ {user.role && <div className="text-[10px] uppercase tracking-wide text-gray-400">{user.role}</div>}
138
+ </div>
139
+ <button
140
+ onClick={() => signOut({ callbackUrl: "/login" })}
141
+ title="Sign out"
142
+ className="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800"
143
+ >
144
+ <LogOut className="h-4 w-4" />
145
+ </button>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function ProjectSelector() {
151
+ const { data } = useProjects();
152
+ const { projectId, setProjectId } = useProject();
153
+ const projects = data?.projects || [];
154
+
155
+ if (projects.length === 0) return null;
156
+
157
+ return (
158
+ <label className="block">
159
+ <span className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-gray-400">Project</span>
160
+ <div className="relative">
161
+ <select
162
+ value={projectId}
163
+ onChange={(e) => setProjectId(e.target.value)}
164
+ className="w-full appearance-none rounded-lg border border-gray-200 bg-white py-1.5 pl-2.5 pr-8 text-sm text-gray-700 outline-none focus:border-splyntra-400 focus:ring-2 focus:ring-splyntra-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
165
+ >
166
+ <option value="">All / default</option>
167
+ {projects.map((p) => (
168
+ <option key={p.id} value={p.id}>
169
+ {p.name} ({p.environment})
170
+ </option>
171
+ ))}
172
+ </select>
173
+ <ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
174
+ </div>
175
+ </label>
176
+ );
177
+ }