@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,148 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useState } from "react";
5
+ import {
6
+ ResponsiveContainer,
7
+ LineChart,
8
+ Line,
9
+ AreaChart,
10
+ Area,
11
+ BarChart,
12
+ Bar,
13
+ XAxis,
14
+ YAxis,
15
+ Tooltip,
16
+ CartesianGrid,
17
+ } from "recharts";
18
+ import { LineChart as LineChartIcon, Clock, Activity, AlertTriangle, Coins } from "lucide-react";
19
+ import { useMetrics } from "@/lib/hooks";
20
+ import { MetricPoint } from "@/lib/api";
21
+ import { PageHeader, Card, StatCard } from "@/components/ui/primitives";
22
+
23
+ const WINDOWS: { label: string; window: number; interval: number }[] = [
24
+ { label: "1h", window: 3600, interval: 60 },
25
+ { label: "24h", window: 86400, interval: 300 },
26
+ { label: "7d", window: 604800, interval: 3600 },
27
+ ];
28
+
29
+ export default function MetricsPage() {
30
+ const [w, setW] = useState(WINDOWS[1]);
31
+ const { data, isLoading, error } = useMetrics(w.window, w.interval);
32
+
33
+ const points: MetricPoint[] = data?.points || [];
34
+ const rows = points.map((p) => ({
35
+ t: new Date(p.bucket).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
36
+ avg: Math.round(p.avg_latency_ms),
37
+ p95: Math.round(p.p95_latency_ms),
38
+ throughput: p.trace_count,
39
+ errorRate: p.trace_count > 0 ? +((p.error_count / p.trace_count) * 100).toFixed(1) : 0,
40
+ tokens: p.total_tokens,
41
+ cost: +p.total_cost.toFixed(4),
42
+ }));
43
+
44
+ const totals = points.reduce(
45
+ (a, p) => ({
46
+ traces: a.traces + p.trace_count,
47
+ errors: a.errors + p.error_count,
48
+ tokens: a.tokens + p.total_tokens,
49
+ cost: a.cost + p.total_cost,
50
+ }),
51
+ { traces: 0, errors: 0, tokens: 0, cost: 0 }
52
+ );
53
+ const errorRate = totals.traces > 0 ? ((totals.errors / totals.traces) * 100).toFixed(1) : "0.0";
54
+
55
+ return (
56
+ <div className="mx-auto max-w-6xl p-6">
57
+ <PageHeader
58
+ icon={LineChartIcon}
59
+ title="Metrics"
60
+ subtitle="Latency, throughput, error rate, and spend over time"
61
+ action={
62
+ <div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 dark:border-gray-700">
63
+ {WINDOWS.map((opt) => (
64
+ <button
65
+ key={opt.label}
66
+ onClick={() => setW(opt)}
67
+ className={`rounded-md px-2.5 py-1 text-xs font-medium ${
68
+ w.label === opt.label
69
+ ? "bg-splyntra-600 text-white"
70
+ : "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
71
+ }`}
72
+ >
73
+ {opt.label}
74
+ </button>
75
+ ))}
76
+ </div>
77
+ }
78
+ />
79
+
80
+ {error && !isLoading && (
81
+ <p className="-mt-2 mb-4 text-xs text-red-500">Could not reach the collector.</p>
82
+ )}
83
+
84
+ <div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
85
+ <StatCard label="Total Runs" value={totals.traces.toLocaleString()} icon={Activity} />
86
+ <StatCard label="Error Rate" value={`${errorRate}%`} icon={AlertTriangle} accent={totals.errors > 0 ? "text-red-600" : undefined} />
87
+ <StatCard label="Tokens" value={totals.tokens.toLocaleString()} icon={Coins} />
88
+ <StatCard label="Spend" value={`$${totals.cost.toFixed(2)}`} icon={Clock} />
89
+ </div>
90
+
91
+ {isLoading ? (
92
+ <div className="py-16 text-center text-gray-500">Loading metrics…</div>
93
+ ) : rows.length === 0 ? (
94
+ <Card className="p-10 text-center text-gray-500">No metrics in this window yet — send some traces.</Card>
95
+ ) : (
96
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
97
+ <ChartCard title="Latency (ms)">
98
+ <LineChart data={rows}>
99
+ {grid()}
100
+ <Line type="monotone" dataKey="avg" name="avg" stroke="#4c6ef5" dot={false} strokeWidth={2} />
101
+ <Line type="monotone" dataKey="p95" name="p95" stroke="#f59f00" dot={false} strokeWidth={2} />
102
+ </LineChart>
103
+ </ChartCard>
104
+ <ChartCard title="Throughput (runs)">
105
+ <BarChart data={rows}>
106
+ {grid()}
107
+ <Bar dataKey="throughput" name="runs" fill="#4c6ef5" radius={[2, 2, 0, 0]} />
108
+ </BarChart>
109
+ </ChartCard>
110
+ <ChartCard title="Error rate (%)">
111
+ <AreaChart data={rows}>
112
+ {grid()}
113
+ <Area type="monotone" dataKey="errorRate" name="error %" stroke="#fa5252" fill="#ffc9c9" />
114
+ </AreaChart>
115
+ </ChartCard>
116
+ <ChartCard title="Spend ($)">
117
+ <AreaChart data={rows}>
118
+ {grid()}
119
+ <Area type="monotone" dataKey="cost" name="cost" stroke="#37b24d" fill="#b2f2bb" />
120
+ </AreaChart>
121
+ </ChartCard>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ function grid() {
129
+ return (
130
+ <>
131
+ <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
132
+ <XAxis dataKey="t" tick={{ fontSize: 11 }} stroke="#9ca3af" />
133
+ <YAxis tick={{ fontSize: 11 }} stroke="#9ca3af" width={40} />
134
+ <Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
135
+ </>
136
+ );
137
+ }
138
+
139
+ function ChartCard({ title, children }: { title: string; children: React.ReactElement }) {
140
+ return (
141
+ <Card className="p-4">
142
+ <h3 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">{title}</h3>
143
+ <div style={{ width: "100%", height: 220 }}>
144
+ <ResponsiveContainer>{children}</ResponsiveContainer>
145
+ </div>
146
+ </Card>
147
+ );
148
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import Link from "next/link";
3
+ import { Compass, ArrowLeft } from "lucide-react";
4
+
5
+ export default function NotFound() {
6
+ return (
7
+ <div className="flex min-h-[60vh] flex-col items-center justify-center p-8 text-center">
8
+ <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-gray-100 text-gray-400 dark:bg-gray-800">
9
+ <Compass className="h-7 w-7" />
10
+ </div>
11
+ <div className="text-5xl font-bold tracking-tight text-gray-200 dark:text-gray-700">404</div>
12
+ <h2 className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">Page not found</h2>
13
+ <p className="mb-6 mt-1 text-gray-500">The page you&apos;re looking for doesn&apos;t exist.</p>
14
+ <Link
15
+ href="/"
16
+ className="inline-flex items-center gap-1.5 rounded-lg bg-splyntra-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-splyntra-700"
17
+ >
18
+ <ArrowLeft className="h-4 w-4" />
19
+ Back to dashboard
20
+ </Link>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import Link from "next/link";
3
+ import {
4
+ Activity,
5
+ Bot,
6
+ DollarSign,
7
+ FolderKanban,
8
+ Bell,
9
+ ShieldCheck,
10
+ ArrowRight,
11
+ type LucideIcon,
12
+ } from "lucide-react";
13
+
14
+ const cards: { href: string; title: string; desc: string; icon: LucideIcon }[] = [
15
+ { href: "/traces", title: "Traces", desc: "Full execution traces with unified risk scores", icon: Activity },
16
+ { href: "/agents", title: "Agents", desc: "Monitor registered agents, latency, and errors", icon: Bot },
17
+ { href: "/costs", title: "Costs", desc: "Token spend by run, model, and project", icon: DollarSign },
18
+ { href: "/projects", title: "Projects", desc: "Scope every view to a project", icon: FolderKanban },
19
+ { href: "/alerts", title: "Alerts", desc: "Fire on risk thresholds; view history", icon: Bell },
20
+ ];
21
+
22
+ export default function Home() {
23
+ return (
24
+ <div className="mx-auto flex min-h-full max-w-4xl flex-col justify-center px-6 py-16">
25
+ <div className="mb-10 text-center">
26
+ <div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl bg-splyntra-600 text-white shadow-sm">
27
+ <ShieldCheck className="h-7 w-7" />
28
+ </div>
29
+ <h1 className="text-3xl font-bold tracking-tight text-splyntra-900 dark:text-white">Splyntra</h1>
30
+ <p className="mx-auto mt-3 max-w-xl text-base text-gray-600 dark:text-gray-400">
31
+ Unified observability and security for AI agents. See what your agents did and
32
+ whether it was safe — in one view.
33
+ </p>
34
+ </div>
35
+
36
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
37
+ {cards.map(({ href, title, desc, icon: Icon }) => (
38
+ <Link
39
+ key={href}
40
+ href={href}
41
+ className="group rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-all hover:border-splyntra-400 hover:shadow-md dark:border-gray-800 dark:bg-gray-900"
42
+ >
43
+ <div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-splyntra-50 text-splyntra-600 dark:bg-splyntra-900/30 dark:text-splyntra-100">
44
+ <Icon className="h-5 w-5" />
45
+ </div>
46
+ <h2 className="flex items-center gap-1 font-semibold text-gray-900 dark:text-white">
47
+ {title}
48
+ <ArrowRight className="h-4 w-4 -translate-x-1 text-gray-300 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100" />
49
+ </h2>
50
+ <p className="mt-1 text-sm text-gray-500">{desc}</p>
51
+ </Link>
52
+ ))}
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useState } from "react";
5
+ import { useQueryClient } from "@tanstack/react-query";
6
+ import { useProjects } from "@/lib/hooks";
7
+ import { useProject } from "@/lib/project-context";
8
+ import { ProjectItem, createProject } from "@/lib/api";
9
+ import { FolderKanban } from "lucide-react";
10
+ import { PageHeader, Card, EmptyState } from "@/components/ui/primitives";
11
+
12
+ export default function ProjectsPage() {
13
+ const { data, isLoading, error } = useProjects();
14
+ const { projectId, setProjectId } = useProject();
15
+ const queryClient = useQueryClient();
16
+
17
+ const [name, setName] = useState("");
18
+ const [environment, setEnvironment] = useState("development");
19
+ const [submitting, setSubmitting] = useState(false);
20
+ const [errMsg, setErrMsg] = useState("");
21
+
22
+ const projects: ProjectItem[] = data?.projects || [];
23
+
24
+ async function onCreate(e: React.FormEvent) {
25
+ e.preventDefault();
26
+ setSubmitting(true);
27
+ setErrMsg("");
28
+ try {
29
+ await createProject({ name, environment });
30
+ setName("");
31
+ queryClient.invalidateQueries({ queryKey: ["projects"] });
32
+ } catch {
33
+ setErrMsg("Could not create project — an admin-scoped session/key is required.");
34
+ } finally {
35
+ setSubmitting(false);
36
+ }
37
+ }
38
+
39
+ return (
40
+ <div className="mx-auto max-w-5xl p-6">
41
+ <PageHeader
42
+ icon={FolderKanban}
43
+ title="Projects"
44
+ subtitle="Create and select projects to scope traces, agents, costs, and alerts"
45
+ />
46
+ {error && !isLoading && (
47
+ <p className="-mt-2 mb-4 text-xs text-amber-600">
48
+ Could not load projects — is the collector reachable?
49
+ </p>
50
+ )}
51
+
52
+ <Card className="mb-6 p-4">
53
+ <form onSubmit={onCreate} className="flex flex-wrap items-end gap-3">
54
+ <label className="flex-1 min-w-[200px]">
55
+ <span className="mb-1 block text-xs font-medium text-gray-500">New project name</span>
56
+ <input value={name} onChange={(e) => setName(e.target.value)} required placeholder="Checkout Agent"
57
+ className="w-full rounded-lg border px-3 py-2 text-sm dark:bg-gray-800" />
58
+ </label>
59
+ <label className="min-w-[150px]">
60
+ <span className="mb-1 block text-xs font-medium text-gray-500">Environment</span>
61
+ <select value={environment} onChange={(e) => setEnvironment(e.target.value)}
62
+ className="w-full rounded-lg border px-3 py-2 text-sm dark:bg-gray-800">
63
+ <option value="development">development</option>
64
+ <option value="staging">staging</option>
65
+ <option value="production">production</option>
66
+ </select>
67
+ </label>
68
+ <button disabled={submitting || !name} className="rounded-lg bg-splyntra-600 px-4 py-2 text-sm font-medium text-white hover:bg-splyntra-700 disabled:opacity-50">
69
+ {submitting ? "Creating…" : "Create project"}
70
+ </button>
71
+ </form>
72
+ {errMsg && <p className="mt-2 text-xs text-amber-600">{errMsg}</p>}
73
+ </Card>
74
+
75
+ <Card className="overflow-hidden">
76
+ {isLoading ? (
77
+ <div className="p-8 text-center text-gray-500">Loading projects…</div>
78
+ ) : projects.length === 0 ? (
79
+ <EmptyState icon={FolderKanban} title="No projects found">
80
+ Projects are provisioned in the metadata store (see migrations/postgres)
81
+ and created when API keys are issued.
82
+ </EmptyState>
83
+ ) : (
84
+ <table className="w-full text-sm">
85
+ <thead className="bg-gray-50 dark:bg-gray-800 border-b">
86
+ <tr>
87
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
88
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Slug</th>
89
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Environment</th>
90
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Created</th>
91
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Active</th>
92
+ </tr>
93
+ </thead>
94
+ <tbody className="divide-y">
95
+ {projects.map((p) => {
96
+ const isActive = projectId === p.id;
97
+ return (
98
+ <tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
99
+ <td className="px-4 py-3 font-medium">{p.name}</td>
100
+ <td className="px-4 py-3 font-mono text-xs text-gray-600">{p.slug}</td>
101
+ <td className="px-4 py-3">
102
+ <span className="px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
103
+ {p.environment}
104
+ </span>
105
+ </td>
106
+ <td className="px-4 py-3 text-xs text-gray-500">
107
+ {new Date(p.created_at).toLocaleDateString()}
108
+ </td>
109
+ <td className="px-4 py-3 text-right">
110
+ <button
111
+ onClick={() => setProjectId(isActive ? "" : p.id)}
112
+ className={`px-3 py-1 rounded text-xs font-medium ${
113
+ isActive
114
+ ? "bg-splyntra-500 text-white"
115
+ : "bg-gray-100 text-gray-700 hover:bg-gray-200"
116
+ }`}
117
+ >
118
+ {isActive ? "Selected" : "Select"}
119
+ </button>
120
+ </td>
121
+ </tr>
122
+ );
123
+ })}
124
+ </tbody>
125
+ </table>
126
+ )}
127
+ </Card>
128
+ </div>
129
+ );
130
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { useState } from "react";
6
+ import { SessionProvider } from "next-auth/react";
7
+ import { ErrorBoundary } from "@/components/ui/ErrorBoundary";
8
+ import { ProjectProvider } from "@/lib/project-context";
9
+
10
+ export function Providers({ children }: { children: React.ReactNode }) {
11
+ const [queryClient] = useState(
12
+ () =>
13
+ new QueryClient({
14
+ defaultOptions: {
15
+ queries: {
16
+ staleTime: 5_000,
17
+ refetchInterval: 10_000,
18
+ retry: 1,
19
+ },
20
+ },
21
+ })
22
+ );
23
+
24
+ return (
25
+ <ErrorBoundary>
26
+ <SessionProvider>
27
+ <QueryClientProvider client={queryClient}>
28
+ <ProjectProvider>{children}</ProjectProvider>
29
+ </QueryClientProvider>
30
+ </SessionProvider>
31
+ </ErrorBoundary>
32
+ );
33
+ }
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useState } from "react";
5
+ import { useQueryClient } from "@tanstack/react-query";
6
+ import { useKeys, useProjects } from "@/lib/hooks";
7
+ import { createKey, revokeKey, rotateKey, ApiKeyItem } from "@/lib/api";
8
+ import { KeyRound, Copy, Check } from "lucide-react";
9
+ import { PageHeader, Card, EmptyState } from "@/components/ui/primitives";
10
+
11
+ export default function KeysPage() {
12
+ const { data, isLoading, error } = useKeys();
13
+ const { data: projectsData } = useProjects();
14
+ const queryClient = useQueryClient();
15
+
16
+ const [name, setName] = useState("");
17
+ const [projectId, setProjectId] = useState("");
18
+ const [scopeIngest, setScopeIngest] = useState(true);
19
+ const [scopeRead, setScopeRead] = useState(true);
20
+ const [scopeAdmin, setScopeAdmin] = useState(false);
21
+ const [submitting, setSubmitting] = useState(false);
22
+ const [errMsg, setErrMsg] = useState("");
23
+ const [plaintext, setPlaintext] = useState<string | null>(null);
24
+ const [copied, setCopied] = useState(false);
25
+
26
+ const keys: ApiKeyItem[] = data?.keys || [];
27
+ const projects = projectsData?.projects || [];
28
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ["keys"] });
29
+
30
+ async function onCreate(e: React.FormEvent) {
31
+ e.preventDefault();
32
+ setSubmitting(true);
33
+ setErrMsg("");
34
+ try {
35
+ const scopes = [
36
+ ...(scopeIngest ? ["ingest"] : []),
37
+ ...(scopeRead ? ["read"] : []),
38
+ ...(scopeAdmin ? ["admin"] : []),
39
+ ];
40
+ const res = await createKey({ name: name || "API Key", project_id: projectId || undefined, scopes });
41
+ setPlaintext(res.key);
42
+ setName("");
43
+ refresh();
44
+ } catch {
45
+ setErrMsg("Could not create key — you need an admin-scoped session/key.");
46
+ } finally {
47
+ setSubmitting(false);
48
+ }
49
+ }
50
+
51
+ async function onRotate(id: string) {
52
+ setErrMsg("");
53
+ try {
54
+ const res = await rotateKey(id);
55
+ setPlaintext(res.key);
56
+ refresh();
57
+ } catch {
58
+ setErrMsg("Rotate failed.");
59
+ }
60
+ }
61
+
62
+ async function onRevoke(id: string) {
63
+ setErrMsg("");
64
+ try {
65
+ await revokeKey(id);
66
+ refresh();
67
+ } catch {
68
+ setErrMsg("Revoke failed.");
69
+ }
70
+ }
71
+
72
+ function copy() {
73
+ if (plaintext) {
74
+ navigator.clipboard.writeText(plaintext);
75
+ setCopied(true);
76
+ setTimeout(() => setCopied(false), 1500);
77
+ }
78
+ }
79
+
80
+ return (
81
+ <div className="mx-auto max-w-5xl p-6">
82
+ <PageHeader icon={KeyRound} title="API Keys" subtitle="Issue, rotate, and revoke ingestion keys for this organization" />
83
+
84
+ {plaintext && (
85
+ <Card className="mb-4 border-emerald-300 bg-emerald-50 p-4 dark:bg-emerald-900/20">
86
+ <p className="mb-2 text-sm font-medium text-emerald-800 dark:text-emerald-200">
87
+ Copy your new key now — it is shown only once.
88
+ </p>
89
+ <div className="flex items-center gap-2">
90
+ <code className="flex-1 truncate rounded bg-white px-3 py-2 font-mono text-xs dark:bg-gray-900">{plaintext}</code>
91
+ <button onClick={copy} className="inline-flex items-center gap-1 rounded-md border px-2 py-2 text-xs hover:bg-white dark:hover:bg-gray-800">
92
+ {copied ? <Check className="h-4 w-4 text-emerald-600" /> : <Copy className="h-4 w-4" />}
93
+ {copied ? "Copied" : "Copy"}
94
+ </button>
95
+ <button onClick={() => setPlaintext(null)} className="rounded-md px-2 py-2 text-xs text-gray-500 hover:bg-white dark:hover:bg-gray-800">Dismiss</button>
96
+ </div>
97
+ </Card>
98
+ )}
99
+
100
+ <Card className="mb-6 p-4">
101
+ <form onSubmit={onCreate} className="flex flex-wrap items-end gap-3">
102
+ <label className="flex-1 min-w-[180px]">
103
+ <span className="mb-1 block text-xs font-medium text-gray-500">Name</span>
104
+ <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Production ingest"
105
+ className="w-full rounded-lg border px-3 py-2 text-sm dark:bg-gray-800" />
106
+ </label>
107
+ <label className="min-w-[160px]">
108
+ <span className="mb-1 block text-xs font-medium text-gray-500">Project</span>
109
+ <select value={projectId} onChange={(e) => setProjectId(e.target.value)}
110
+ className="w-full rounded-lg border px-3 py-2 text-sm dark:bg-gray-800">
111
+ <option value="">Org-wide</option>
112
+ {projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
113
+ </select>
114
+ </label>
115
+ <div className="flex items-center gap-3 text-xs">
116
+ <label className="flex items-center gap-1"><input type="checkbox" checked={scopeIngest} onChange={(e) => setScopeIngest(e.target.checked)} /> ingest</label>
117
+ <label className="flex items-center gap-1"><input type="checkbox" checked={scopeRead} onChange={(e) => setScopeRead(e.target.checked)} /> read</label>
118
+ <label className="flex items-center gap-1"><input type="checkbox" checked={scopeAdmin} onChange={(e) => setScopeAdmin(e.target.checked)} /> admin</label>
119
+ </div>
120
+ <button disabled={submitting} className="rounded-lg bg-splyntra-600 px-4 py-2 text-sm font-medium text-white hover:bg-splyntra-700 disabled:opacity-50">
121
+ {submitting ? "Creating…" : "Create key"}
122
+ </button>
123
+ </form>
124
+ {errMsg && <p className="mt-2 text-xs text-amber-600">{errMsg}</p>}
125
+ </Card>
126
+
127
+ <Card className="overflow-hidden">
128
+ {isLoading ? (
129
+ <div className="p-8 text-center text-gray-500">Loading keys…</div>
130
+ ) : error ? (
131
+ <EmptyState icon={KeyRound} title="Keys unavailable">
132
+ Listing keys requires an admin-scoped session/key.
133
+ </EmptyState>
134
+ ) : keys.length === 0 ? (
135
+ <EmptyState icon={KeyRound} title="No API keys yet">Create one above to start ingesting.</EmptyState>
136
+ ) : (
137
+ <table className="w-full text-sm">
138
+ <thead className="border-b bg-gray-50 dark:bg-gray-800">
139
+ <tr>
140
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
141
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
142
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Scopes</th>
143
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
144
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Actions</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody className="divide-y">
148
+ {keys.map((k) => (
149
+ <tr key={k.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
150
+ <td className="px-4 py-3 font-medium">{k.name}</td>
151
+ <td className="px-4 py-3 font-mono text-xs text-gray-600">{k.key_prefix}…</td>
152
+ <td className="px-4 py-3 text-xs text-gray-600">{(k.scopes || []).join(", ")}</td>
153
+ <td className="px-4 py-3">
154
+ <span className={`rounded px-2 py-0.5 text-xs font-medium ${k.is_active ? "bg-emerald-100 text-emerald-700" : "bg-gray-200 text-gray-500"}`}>
155
+ {k.is_active ? "active" : "revoked"}
156
+ </span>
157
+ </td>
158
+ <td className="px-4 py-3 text-right">
159
+ {k.is_active && (
160
+ <>
161
+ <button onClick={() => onRotate(k.id)} className="mr-3 text-xs text-splyntra-600 hover:underline">Rotate</button>
162
+ <button onClick={() => onRevoke(k.id)} className="text-xs text-red-600 hover:underline">Revoke</button>
163
+ </>
164
+ )}
165
+ </td>
166
+ </tr>
167
+ ))}
168
+ </tbody>
169
+ </table>
170
+ )}
171
+ </Card>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useFormState } from "react-dom";
5
+ import { useState } from "react";
6
+ import { inviteMemberAction } from "@/app/auth-actions";
7
+
8
+ export function InviteForm() {
9
+ const [state, action] = useFormState(inviteMemberAction, { error: "", token: "" });
10
+ const [origin, setOrigin] = useState("");
11
+ if (typeof window !== "undefined" && !origin) setOrigin(window.location.origin);
12
+
13
+ return (
14
+ <div className="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
15
+ <h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Invite a teammate</h2>
16
+ <form action={action} className="flex flex-wrap items-end gap-3">
17
+ <label className="flex flex-col text-sm">
18
+ <span className="mb-1 text-xs text-gray-500">Email</span>
19
+ <input name="email" type="email" required className="rounded-md border px-2 py-1.5 dark:bg-gray-800" />
20
+ </label>
21
+ <label className="flex flex-col text-sm">
22
+ <span className="mb-1 text-xs text-gray-500">Role</span>
23
+ <select name="role" className="rounded-md border px-2 py-1.5 dark:bg-gray-800">
24
+ <option value="viewer">viewer</option>
25
+ <option value="member">member</option>
26
+ <option value="admin">admin</option>
27
+ </select>
28
+ </label>
29
+ <button className="rounded-lg bg-splyntra-600 px-4 py-2 text-sm font-medium text-white hover:bg-splyntra-700">
30
+ Create invite
31
+ </button>
32
+ </form>
33
+ {state?.error ? <p className="mt-2 text-sm text-red-600">{state.error}</p> : null}
34
+ {state?.token ? (
35
+ <div className="mt-3 rounded-lg bg-gray-50 p-2 text-xs dark:bg-gray-800">
36
+ Share this invite link:
37
+ <code className="ml-1 break-all text-splyntra-600">
38
+ {origin}/accept-invite?token={state.token}
39
+ </code>
40
+ </div>
41
+ ) : null}
42
+ </div>
43
+ );
44
+ }