@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,24 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { getActiveProject, setActiveProject } from "./api";
4
+
5
+ describe("active project persistence", () => {
6
+ beforeEach(() => {
7
+ localStorage.clear();
8
+ });
9
+
10
+ it("defaults to empty (API key's default project)", () => {
11
+ expect(getActiveProject()).toBe("");
12
+ });
13
+
14
+ it("round-trips a selected project id", () => {
15
+ setActiveProject("proj-123");
16
+ expect(getActiveProject()).toBe("proj-123");
17
+ });
18
+
19
+ it("clearing resets to default", () => {
20
+ setActiveProject("proj-123");
21
+ setActiveProject("");
22
+ expect(getActiveProject()).toBe("");
23
+ });
24
+ });
package/src/lib/api.ts ADDED
@@ -0,0 +1,379 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Use Next.js API proxy in production, direct collector URL in development
3
+ const API_BASE =
4
+ typeof window !== "undefined"
5
+ ? window.location.origin + "/api"
6
+ : process.env.NEXT_PUBLIC_API_URL || "http://localhost:4318";
7
+
8
+ const ACTIVE_PROJECT_KEY = "splyntra_active_project";
9
+
10
+ /** Active project id (empty = the API key's default project). */
11
+ export function getActiveProject(): string {
12
+ if (typeof window !== "undefined") {
13
+ return localStorage.getItem(ACTIVE_PROJECT_KEY) || "";
14
+ }
15
+ return "";
16
+ }
17
+
18
+ export function setActiveProject(projectId: string): void {
19
+ if (typeof window !== "undefined") {
20
+ if (projectId) localStorage.setItem(ACTIVE_PROJECT_KEY, projectId);
21
+ else localStorage.removeItem(ACTIVE_PROJECT_KEY);
22
+ }
23
+ }
24
+
25
+ /** Append the active (or supplied) project id as a query param when set. */
26
+ function withProject(url: string, projectId?: string): string {
27
+ const pid = projectId ?? getActiveProject();
28
+ if (!pid) return url;
29
+ return url + (url.includes("?") ? "&" : "?") + `project_id=${encodeURIComponent(pid)}`;
30
+ }
31
+
32
+ // Exported so commercial dashboard screens (composed in at cloud-build time)
33
+ // can reuse the same authenticated fetch helpers.
34
+ export async function apiGet<T>(path: string): Promise<T> {
35
+ const controller = new AbortController();
36
+ const timeout = setTimeout(() => controller.abort(), 15000);
37
+ try {
38
+ const res = await fetch(`${API_BASE}${path}`, {
39
+ headers: {
40
+ Authorization: `Bearer ${getApiKey()}`,
41
+ "Content-Type": "application/json",
42
+ },
43
+ signal: controller.signal,
44
+ });
45
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
46
+ return res.json();
47
+ } finally {
48
+ clearTimeout(timeout);
49
+ }
50
+ }
51
+
52
+ export interface TraceListResponse {
53
+ traces: TraceListItem[];
54
+ total: number;
55
+ }
56
+
57
+ export interface TraceListItem {
58
+ trace_id: string;
59
+ agent_id: string;
60
+ workflow_id: string;
61
+ status: string;
62
+ latency_ms: number;
63
+ total_tokens: number;
64
+ cost_usd: number;
65
+ risk_score: number;
66
+ risk_severity: string;
67
+ detection_count: number;
68
+ span_count: number;
69
+ started_at: string;
70
+ completed_at: string;
71
+ }
72
+
73
+ export interface TraceDetailResponse {
74
+ trace_id: string;
75
+ spans: SpanItem[];
76
+ detections: DetectionItem[];
77
+ }
78
+
79
+ export interface SpanItem {
80
+ trace_id: string;
81
+ span_id: string;
82
+ parent_span_id: string;
83
+ type: string;
84
+ name: string;
85
+ status: string;
86
+ latency_ms: number;
87
+ model: string;
88
+ prompt_tokens: number;
89
+ completion_tokens: number;
90
+ cost_usd: number;
91
+ input_preview: string;
92
+ output_preview: string;
93
+ attributes: Record<string, string>;
94
+ started_at: string;
95
+ }
96
+
97
+ export interface DetectionItem {
98
+ trace_id: string;
99
+ span_id: string;
100
+ detector: string;
101
+ category: string;
102
+ severity: string;
103
+ confidence: number;
104
+ description: string;
105
+ is_beta: number;
106
+ detected_at: string;
107
+ }
108
+
109
+ export async function fetchTraces(limit = 50): Promise<TraceListResponse> {
110
+ return apiGet<TraceListResponse>(withProject(`/v1/traces?limit=${limit}`));
111
+ }
112
+
113
+ export async function fetchTrace(traceId: string): Promise<TraceDetailResponse> {
114
+ return apiGet<TraceDetailResponse>(withProject(`/v1/traces/${encodeURIComponent(traceId)}`));
115
+ }
116
+
117
+ function getApiKey(): string {
118
+ if (typeof window !== "undefined") {
119
+ return localStorage.getItem("splyntra_api_key") || "";
120
+ }
121
+ return process.env.SPLYNTRA_API_KEY || "";
122
+ }
123
+
124
+ // ─── Agents API ─────────────────────────────────────────────────────────────
125
+
126
+ export interface AgentItem {
127
+ agent_id: string;
128
+ name?: string;
129
+ framework?: string;
130
+ trace_count: number;
131
+ error_count: number;
132
+ avg_latency_ms: number;
133
+ p95_latency_ms: number;
134
+ total_tokens: number;
135
+ total_cost: number;
136
+ detection_count: number;
137
+ last_seen_at: string;
138
+ }
139
+
140
+ export interface AgentsResponse {
141
+ agents: AgentItem[];
142
+ total: number;
143
+ }
144
+
145
+ export async function fetchAgents(): Promise<AgentsResponse> {
146
+ return apiGet<AgentsResponse>(withProject(`/v1/agents`));
147
+ }
148
+
149
+ // ─── Costs API ──────────────────────────────────────────────────────────────
150
+
151
+ export interface CostModelItem {
152
+ model: string;
153
+ call_count: number;
154
+ total_prompt_tokens: number;
155
+ total_completion_tokens: number;
156
+ total_cost: number;
157
+ avg_cost_per_call: number;
158
+ }
159
+
160
+ export interface CostSummary {
161
+ total_cost: number;
162
+ total_calls: number;
163
+ total_tokens: number;
164
+ avg_cost_per_call: number;
165
+ }
166
+
167
+ export interface ProjectCostItem {
168
+ project_id: string;
169
+ call_count: number;
170
+ total_tokens: number;
171
+ total_cost: number;
172
+ }
173
+
174
+ export interface CostsResponse {
175
+ models: CostModelItem[];
176
+ summary: CostSummary;
177
+ by_project: ProjectCostItem[];
178
+ }
179
+
180
+ export async function fetchCosts(): Promise<CostsResponse> {
181
+ return apiGet<CostsResponse>(withProject(`/v1/costs`));
182
+ }
183
+
184
+ // ─── Evaluation API (separate service, proxied via /api/eval) ───────────────
185
+
186
+ const EVAL_BASE =
187
+ typeof window !== "undefined" ? window.location.origin + "/api/eval" : process.env.EVAL_URL || "http://localhost:8002";
188
+
189
+ export interface EvalDataset {
190
+ id: string;
191
+ name: string;
192
+ slug: string;
193
+ description: string;
194
+ latest_version: number;
195
+ item_count: number;
196
+ created_at: string;
197
+ }
198
+
199
+ export interface EvalRun {
200
+ id: string;
201
+ dataset_id: string;
202
+ score: number;
203
+ item_count: number;
204
+ passed: boolean;
205
+ regression: boolean;
206
+ per_scorer: Record<string, number>;
207
+ created_at: string;
208
+ }
209
+
210
+ async function evalGet<T>(path: string): Promise<T> {
211
+ const res = await fetch(`${EVAL_BASE}${path}`, {
212
+ headers: { Authorization: `Bearer ${getApiKey()}`, "Content-Type": "application/json" },
213
+ });
214
+ if (!res.ok) throw new Error(`Eval request failed: ${res.status}`);
215
+ return res.json();
216
+ }
217
+
218
+ export async function fetchDatasets(): Promise<{ datasets: EvalDataset[] }> {
219
+ return evalGet(`/v1/datasets`);
220
+ }
221
+
222
+ export async function fetchEvalRuns(datasetId?: string): Promise<{ runs: EvalRun[] }> {
223
+ return evalGet(`/v1/evaluations${datasetId ? `?dataset_id=${encodeURIComponent(datasetId)}` : ""}`);
224
+ }
225
+
226
+ export async function apiSend(path: string, method: string, body?: unknown): Promise<any> {
227
+ const res = await fetch(`${API_BASE}${path}`, {
228
+ method,
229
+ headers: { Authorization: `Bearer ${getApiKey()}`, "Content-Type": "application/json" },
230
+ body: body ? JSON.stringify(body) : undefined,
231
+ });
232
+ if (!res.ok && res.status !== 204) throw new Error(`Request failed: ${res.status}`);
233
+ return res.status === 204 ? null : res.json().catch(() => null);
234
+ }
235
+
236
+ // ─── Metrics API ─────────────────────────────────────────────────────────────
237
+
238
+ export interface MetricPoint {
239
+ bucket: string;
240
+ trace_count: number;
241
+ error_count: number;
242
+ avg_latency_ms: number;
243
+ p95_latency_ms: number;
244
+ total_tokens: number;
245
+ total_cost: number;
246
+ }
247
+
248
+ export interface MetricsResponse {
249
+ points: MetricPoint[];
250
+ window: number;
251
+ interval: number;
252
+ }
253
+
254
+ export async function fetchMetrics(windowSec = 86400, intervalSec = 300): Promise<MetricsResponse> {
255
+ return apiGet<MetricsResponse>(withProject(`/v1/metrics?window=${windowSec}&interval=${intervalSec}`));
256
+ }
257
+
258
+ // ─── Projects API ─────────────────────────────────────────────────────────
259
+
260
+ export interface ProjectItem {
261
+ id: string;
262
+ name: string;
263
+ slug: string;
264
+ environment: string;
265
+ created_at: string;
266
+ }
267
+
268
+ export interface ProjectsResponse {
269
+ projects: ProjectItem[];
270
+ total: number;
271
+ }
272
+
273
+ export async function fetchProjects(): Promise<ProjectsResponse> {
274
+ return apiGet<ProjectsResponse>(`/v1/projects`);
275
+ }
276
+
277
+ export async function createProject(input: {
278
+ name: string;
279
+ slug?: string;
280
+ environment?: string;
281
+ }): Promise<ProjectItem> {
282
+ return apiSend(`/v1/projects`, "POST", input);
283
+ }
284
+
285
+ // ─── API keys (provisioning; requires an admin-scoped key) ───────────────────
286
+
287
+ export interface ApiKeyItem {
288
+ id: string;
289
+ name: string;
290
+ project_id: string;
291
+ key_prefix: string;
292
+ scopes: string[];
293
+ is_active: boolean;
294
+ last_used_at: string | null;
295
+ created_at: string;
296
+ }
297
+
298
+ export async function fetchKeys(): Promise<{ keys: ApiKeyItem[] }> {
299
+ return apiGet<{ keys: ApiKeyItem[] }>(`/v1/keys`);
300
+ }
301
+
302
+ // Returns the plaintext key exactly once (in `key`).
303
+ export async function createKey(input: {
304
+ name: string;
305
+ project_id?: string;
306
+ scopes?: string[];
307
+ }): Promise<{ key: string; meta: ApiKeyItem }> {
308
+ return apiSend(`/v1/keys`, "POST", input);
309
+ }
310
+
311
+ export async function revokeKey(id: string): Promise<void> {
312
+ await apiSend(`/v1/keys/${encodeURIComponent(id)}`, "DELETE");
313
+ }
314
+
315
+ export async function rotateKey(id: string): Promise<{ key: string }> {
316
+ return apiSend(`/v1/keys/${encodeURIComponent(id)}/rotate`, "POST");
317
+ }
318
+
319
+ // ─── Alerts API ─────────────────────────────────────────────────────────────
320
+
321
+ export interface AlertItem {
322
+ id: string;
323
+ org_id: string;
324
+ project_id: string;
325
+ name: string;
326
+ type: string;
327
+ config: Record<string, unknown>;
328
+ channels: string[];
329
+ is_active: boolean;
330
+ created_at: string;
331
+ }
332
+
333
+ export interface AlertEventItem {
334
+ id: string;
335
+ alert_id: string;
336
+ alert_name: string;
337
+ trace_id: string;
338
+ risk_score: number;
339
+ severity: string;
340
+ fired_at: string;
341
+ }
342
+
343
+ export interface AlertsResponse {
344
+ alerts: AlertItem[];
345
+ events: AlertEventItem[];
346
+ }
347
+
348
+ export async function fetchAlerts(): Promise<AlertsResponse> {
349
+ return apiGet<AlertsResponse>(withProject(`/v1/alerts`));
350
+ }
351
+
352
+ export interface CreateAlertInput {
353
+ name: string;
354
+ type: string;
355
+ project_id?: string;
356
+ config: Record<string, unknown>;
357
+ channels: string[];
358
+ }
359
+
360
+ export async function createAlert(input: CreateAlertInput): Promise<{ id: string }> {
361
+ const res = await fetch(`${API_BASE}/v1/alerts`, {
362
+ method: "POST",
363
+ headers: {
364
+ Authorization: `Bearer ${getApiKey()}`,
365
+ "Content-Type": "application/json",
366
+ },
367
+ body: JSON.stringify(input),
368
+ });
369
+ if (!res.ok) throw new Error(`Failed to create alert: ${res.status}`);
370
+ return res.json();
371
+ }
372
+
373
+ export async function deleteAlert(alertId: string): Promise<void> {
374
+ const res = await fetch(`${API_BASE}/v1/alerts/${encodeURIComponent(alertId)}`, {
375
+ method: "DELETE",
376
+ headers: { Authorization: `Bearer ${getApiKey()}` },
377
+ });
378
+ if (!res.ok && res.status !== 204) throw new Error(`Failed to delete alert: ${res.status}`);
379
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Auth extension seam (mirrors lib/slots.ts). Lets a build inject additional
3
+ // next-auth providers and post-sign-in hooks without forking the open auth
4
+ // setup. The open edition registers nothing here (Credentials + the implicit
5
+ // single org); the commercial build's overlay registers OAuth providers
6
+ // (Google/GitHub/OIDC) and an org-onboarding hook.
7
+ //
8
+ // auth.ts imports lib/auth-providers (a no-op file in the open repo, replaced by
9
+ // the cloud overlay) for its registration side effects, then reads the values
10
+ // below when constructing NextAuth.
11
+ import type { NextAuthConfig } from "next-auth";
12
+
13
+ type Provider = NonNullable<NextAuthConfig["providers"]>[number];
14
+ type SignInUser = { id?: string; email?: string | null; name?: string | null };
15
+ type SignInHook = (user: SignInUser, account: unknown) => Promise<void> | void;
16
+
17
+ const extraProviders: Provider[] = [];
18
+ const signInHooks: SignInHook[] = [];
19
+ let onboardingPath: string | null = null;
20
+
21
+ /** Register one or more next-auth providers (called from the cloud overlay). */
22
+ export function registerAuthProviders(...providers: Provider[]): void {
23
+ extraProviders.push(...providers);
24
+ }
25
+
26
+ /** Providers contributed by extensions (empty in the open edition). */
27
+ export function registeredAuthProviders(): Provider[] {
28
+ return extraProviders;
29
+ }
30
+
31
+ /** Register a hook run after a successful sign-in (e.g. link OAuth identity). */
32
+ export function registerSignInHook(fn: SignInHook): void {
33
+ signInHooks.push(fn);
34
+ }
35
+
36
+ export function registeredSignInHooks(): SignInHook[] {
37
+ return signInHooks;
38
+ }
39
+
40
+ /** The cloud build sets this so users without an org are sent to onboarding. */
41
+ export function setOnboardingRedirect(path: string): void {
42
+ onboardingPath = path;
43
+ }
44
+
45
+ export function onboardingRedirect(): string | null {
46
+ return onboardingPath;
47
+ }
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Extension point: the open edition registers no extra auth providers (it uses
3
+ // email/password Credentials with a single implicit organization). The
4
+ // commercial cloud build replaces this file in its composition step to register
5
+ // OAuth providers (Google / GitHub / Microsoft-OIDC) and an org-onboarding hook
6
+ // via lib/auth-extensions. Importing this module for its side effects is a no-op
7
+ // here, which keeps the open build standalone.
8
+ export {};
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Extension point: the open edition registers no BFF auth resolver (single
3
+ // implicit org → the server key is correct). The commercial cloud build
4
+ // replaces this file in its composition step to register a resolver that scopes
5
+ // each collector request to the logged-in user's active org via a trusted
6
+ // service token + X-Splyntra-Org-Id headers. No-op here keeps the open build
7
+ // standalone.
8
+ export {};
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Resolves how the dashboard BFF authenticates to the collector / evaluation
3
+ // service on behalf of the logged-in user. This is the multi-tenant seam.
4
+ //
5
+ // Open (Community) default: a single implicit org, so the BFF attaches the
6
+ // server-side org key (SPLYNTRA_API_KEY) — or, only outside production, the dev
7
+ // key. The commercial Cloud build registers a resolver (see the no-op
8
+ // collector-auth-providers below, replaced by the cloud overlay) that instead
9
+ // uses a trusted service token + X-Splyntra-Org-Id headers so each request is
10
+ // scoped to the user's ACTIVE org (api_keys store only hashes, so a per-org key
11
+ // can't be replayed). The collector honors that header path only when its
12
+ // COLLECTOR_SERVICE_TOKEN matches.
13
+ import "@/lib/collector-auth-providers"; // side-effect: cloud overlay registers its resolver
14
+
15
+ type SessionLike = { user?: { id?: string; orgId?: string; role?: string } } | null | undefined;
16
+ export interface CollectorAuth {
17
+ headers: Record<string, string>;
18
+ }
19
+ // May be async: the cloud resolver verifies org membership in Postgres before
20
+ // trusting session.orgId (so a forged JWT orgId can't reach another tenant).
21
+ type Resolver = (
22
+ session: SessionLike,
23
+ incomingKey: string
24
+ ) => Promise<CollectorAuth | null> | CollectorAuth | null;
25
+
26
+ let resolver: Resolver | null = null;
27
+
28
+ /** Register the BFF auth resolver (called by the cloud overlay). */
29
+ export function registerCollectorAuthResolver(fn: Resolver): void {
30
+ resolver = fn;
31
+ }
32
+
33
+ // Community default: explicit client key, else the server org key, else the dev
34
+ // key in non-production. Never falls back to the dev key in production.
35
+ function defaultAuth(incomingKey: string): CollectorAuth {
36
+ // The dev key activates ONLY when NODE_ENV is explicitly "development" — an
37
+ // unset NODE_ENV in production must NOT silently enable it (fail closed).
38
+ const serverKey =
39
+ process.env.SPLYNTRA_API_KEY ||
40
+ (process.env.NODE_ENV === "development" ? "splyntra_dev_key" : "");
41
+ const key = incomingKey || serverKey;
42
+ return { headers: key ? { authorization: `Bearer ${key}` } : {} };
43
+ }
44
+
45
+ /** Resolve the auth headers the BFF must attach for this request. */
46
+ export async function resolveCollectorAuth(session: unknown, incomingKey: string): Promise<CollectorAuth> {
47
+ if (resolver) {
48
+ const r = await resolver(session as SessionLike, incomingKey);
49
+ if (r) return r;
50
+ }
51
+ return defaultAuth(incomingKey);
52
+ }
package/src/lib/db.ts ADDED
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { Pool } from "pg";
3
+
4
+ // Server-only Postgres pool for user/team management (next-auth lives in the
5
+ // BFF, so user auth talks to Postgres directly rather than via the collector).
6
+ const globalForPg = globalThis as unknown as { _pgPool?: Pool };
7
+
8
+ export const pool =
9
+ globalForPg._pgPool ??
10
+ new Pool({
11
+ connectionString:
12
+ process.env.POSTGRES_DSN ||
13
+ "postgres://splyntra:splyntra@localhost:5432/splyntra?sslmode=disable",
14
+ max: 5,
15
+ });
16
+
17
+ if (process.env.NODE_ENV !== "production") globalForPg._pgPool = pool;
18
+
19
+ export type Role = "owner" | "admin" | "member" | "viewer";
20
+
21
+ const RANK: Record<Role, number> = { viewer: 0, member: 1, admin: 2, owner: 3 };
22
+
23
+ /** True if `role` is at least as privileged as `min`. */
24
+ export function roleAtLeast(role: string | undefined, min: Role): boolean {
25
+ if (!role || !(role in RANK)) return false;
26
+ return RANK[role as Role] >= RANK[min];
27
+ }
@@ -0,0 +1,19 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Feature flags. All false in the open-source build. The cloud/enterprise build
3
+ // flips the relevant flags via injected runtime config (NEXT_PUBLIC_FEATURE_*),
4
+ // which lights up nav slots whose screens are composed in from the private
5
+ // frontend/cloud-screens package. Flags gate VISIBILITY of code that may ship;
6
+ // truly private screens ship only via slots (see slots.ts).
7
+ function flag(name: string): boolean {
8
+ // NEXT_PUBLIC_* are inlined at build time; unset → false in OSS.
9
+ return process.env[`NEXT_PUBLIC_FEATURE_${name}`] === "true";
10
+ }
11
+
12
+ export const features = {
13
+ governance: flag("GOVERNANCE"), // policy engine, delegation, activity ledger
14
+ sso: flag("SSO"),
15
+ billing: flag("BILLING"),
16
+ identity: flag("IDENTITY"),
17
+ } as const;
18
+
19
+ export type FeatureName = keyof typeof features;
@@ -0,0 +1,116 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import {
6
+ fetchTraces,
7
+ fetchTrace,
8
+ fetchAgents,
9
+ fetchCosts,
10
+ fetchProjects,
11
+ fetchAlerts,
12
+ fetchMetrics,
13
+ fetchDatasets,
14
+ fetchEvalRuns,
15
+ fetchKeys,
16
+ EvalDataset,
17
+ EvalRun,
18
+ ApiKeyItem,
19
+ TraceListResponse,
20
+ TraceDetailResponse,
21
+ AgentsResponse,
22
+ CostsResponse,
23
+ ProjectsResponse,
24
+ AlertsResponse,
25
+ MetricsResponse,
26
+ } from "@/lib/api";
27
+ import { useProject } from "@/lib/project-context";
28
+
29
+ export function useTraces(limit = 50) {
30
+ const { projectId } = useProject();
31
+ return useQuery<TraceListResponse>({
32
+ queryKey: ["traces", limit, projectId],
33
+ queryFn: () => fetchTraces(limit),
34
+ retry: 1,
35
+ });
36
+ }
37
+
38
+ export function useTrace(traceId: string) {
39
+ const { projectId } = useProject();
40
+ return useQuery<TraceDetailResponse>({
41
+ queryKey: ["trace", traceId, projectId],
42
+ queryFn: () => fetchTrace(traceId),
43
+ enabled: !!traceId,
44
+ retry: 1,
45
+ });
46
+ }
47
+
48
+ export function useAgents() {
49
+ const { projectId } = useProject();
50
+ return useQuery<AgentsResponse>({
51
+ queryKey: ["agents", projectId],
52
+ queryFn: () => fetchAgents(),
53
+ retry: 1,
54
+ });
55
+ }
56
+
57
+ export function useCosts() {
58
+ const { projectId } = useProject();
59
+ return useQuery<CostsResponse>({
60
+ queryKey: ["costs", projectId],
61
+ queryFn: () => fetchCosts(),
62
+ retry: 1,
63
+ });
64
+ }
65
+
66
+ export function useMetrics(windowSec = 86400, intervalSec = 300) {
67
+ const { projectId } = useProject();
68
+ return useQuery<MetricsResponse>({
69
+ queryKey: ["metrics", windowSec, intervalSec, projectId],
70
+ queryFn: () => fetchMetrics(windowSec, intervalSec),
71
+ retry: 1,
72
+ });
73
+ }
74
+
75
+ export function useDatasets() {
76
+ return useQuery<{ datasets: EvalDataset[] }>({
77
+ queryKey: ["eval-datasets"],
78
+ queryFn: () => fetchDatasets(),
79
+ retry: 1,
80
+ });
81
+ }
82
+
83
+ export function useEvalRuns(datasetId?: string) {
84
+ return useQuery<{ runs: EvalRun[] }>({
85
+ queryKey: ["eval-runs", datasetId ?? "all"],
86
+ queryFn: () => fetchEvalRuns(datasetId),
87
+ retry: 1,
88
+ });
89
+ }
90
+
91
+ export function useKeys() {
92
+ return useQuery<{ keys: ApiKeyItem[] }>({
93
+ queryKey: ["keys"],
94
+ queryFn: () => fetchKeys(),
95
+ retry: 1,
96
+ });
97
+ }
98
+
99
+
100
+ export function useProjects() {
101
+ return useQuery<ProjectsResponse>({
102
+ queryKey: ["projects"],
103
+ queryFn: () => fetchProjects(),
104
+ retry: 1,
105
+ refetchInterval: false,
106
+ });
107
+ }
108
+
109
+ export function useAlerts() {
110
+ const { projectId } = useProject();
111
+ return useQuery<AlertsResponse>({
112
+ queryKey: ["alerts", projectId],
113
+ queryFn: () => fetchAlerts(),
114
+ retry: 1,
115
+ });
116
+ }