@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
package/next.config.js ADDED
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ /** @type {import('next').NextConfig} */
3
+ const nextConfig = {
4
+ output: "standalone",
5
+ poweredByHeader: false,
6
+ reactStrictMode: true,
7
+
8
+ // API calls are proxied to the collector at runtime by the route handler at
9
+ // src/app/api/v1/[...path]/route.ts. (next.config rewrites bake the URL at
10
+ // build time, which breaks Docker/Helm where the collector is a service name.)
11
+
12
+ async headers() {
13
+ return [
14
+ {
15
+ source: "/:path*",
16
+ headers: [
17
+ { key: "X-Content-Type-Options", value: "nosniff" },
18
+ { key: "X-Frame-Options", value: "DENY" },
19
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
20
+ { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
21
+ { key: "X-DNS-Prefetch-Control", value: "on" },
22
+ { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
23
+ {
24
+ key: "Content-Security-Policy",
25
+ value: "default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'",
26
+ },
27
+ ],
28
+ },
29
+ ];
30
+ },
31
+ };
32
+
33
+ module.exports = nextConfig;
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@splyntra/dashboard",
3
+ "version": "0.3.0",
4
+ "description": "Splyntra open dashboard — the composable source the commercial cloud build overlays. Published as source (not a prebuilt library): consumers compose it with their overlays + `next build`.",
5
+ "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/splyntra/splyntra",
9
+ "directory": "apps/web"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "public",
17
+ "next.config.js",
18
+ "tailwind.config.js",
19
+ "postcss.config.js",
20
+ "tsconfig.json"
21
+ ],
22
+ "scripts": {
23
+ "dev": "next dev",
24
+ "build": "next build",
25
+ "start": "next start",
26
+ "lint": "next lint",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "dependencies": {
31
+ "@tanstack/react-query": "^5.40.0",
32
+ "bcryptjs": "^2.4.3",
33
+ "class-variance-authority": "^0.7.0",
34
+ "clsx": "^2.1.0",
35
+ "date-fns": "^3.6.0",
36
+ "lucide-react": "^0.390.0",
37
+ "next": "^14.2.0",
38
+ "next-auth": "^5.0.0-beta.19",
39
+ "pg": "^8.21.0",
40
+ "react": "^18.3.0",
41
+ "react-dom": "^18.3.0",
42
+ "recharts": "^2.15.4",
43
+ "tailwind-merge": "^2.3.0"
44
+ },
45
+ "devDependencies": {
46
+ "@tailwindcss/typography": "^0.5.0",
47
+ "@testing-library/jest-dom": "^6.9.1",
48
+ "@testing-library/react": "^16.3.2",
49
+ "@types/bcryptjs": "^2.4.6",
50
+ "@types/node": "^20.0.0",
51
+ "@types/pg": "^8.20.0",
52
+ "@types/react": "^18.3.0",
53
+ "@types/react-dom": "^18.3.0",
54
+ "@vitejs/plugin-react": "^4.7.0",
55
+ "autoprefixer": "^10.4.0",
56
+ "jsdom": "^24.1.3",
57
+ "postcss": "^8.4.0",
58
+ "tailwindcss": "^3.4.0",
59
+ "typescript": "^5.4.0",
60
+ "vitest": "^1.6.1"
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ module.exports = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ autoprefixer: {},
6
+ },
7
+ };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "Splyntra",
3
+ "short_name": "Splyntra",
4
+ "description": "Unified observability and security for AI agents",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#ffffff",
8
+ "theme_color": "#4c6ef5"
9
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { Suspense } from "react";
5
+ import { useFormState } from "react-dom";
6
+ import { useSearchParams } from "next/navigation";
7
+ import { signupAction } from "@/app/auth-actions";
8
+ import { AuthCard, Field } from "@/components/auth/AuthCard";
9
+
10
+ function AcceptInviteForm() {
11
+ const params = useSearchParams();
12
+ const token = params.get("token") || "";
13
+ const email = params.get("email") || "";
14
+ const [state, formAction] = useFormState(signupAction, { error: "" });
15
+
16
+ return (
17
+ <AuthCard title="Accept your invitation">
18
+ <form action={formAction} className="space-y-3">
19
+ <input type="hidden" name="invite" value={token} />
20
+ <Field name="name" type="text" label="Name" />
21
+ <Field name="email" type="email" label="Email" defaultValue={email} />
22
+ <Field name="password" type="password" label="Password (8+ chars)" />
23
+ {state?.error ? <p className="text-sm text-red-600">{state.error}</p> : null}
24
+ {!token && <p className="text-sm text-amber-600">Missing invite token.</p>}
25
+ <button
26
+ type="submit"
27
+ disabled={!token}
28
+ className="w-full rounded-lg bg-splyntra-600 py-2 text-sm font-medium text-white hover:bg-splyntra-700 disabled:opacity-50"
29
+ >
30
+ Join team
31
+ </button>
32
+ </form>
33
+ </AuthCard>
34
+ );
35
+ }
36
+
37
+ export default function AcceptInvitePage() {
38
+ return (
39
+ <Suspense fallback={<AuthCard title="Accept your invitation">Loading…</AuthCard>}>
40
+ <AcceptInviteForm />
41
+ </Suspense>
42
+ );
43
+ }
@@ -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: "Agents | Splyntra",
6
+ description: "Monitor registered AI agents across environments",
7
+ };
8
+
9
+ export default function AgentsLayout({ children }: { children: React.ReactNode }) {
10
+ return children;
11
+ }
@@ -0,0 +1,149 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ "use client";
3
+
4
+ import { useAgents } from "@/lib/hooks";
5
+ import { AgentItem } from "@/lib/api";
6
+ import { Bot, CheckCircle2, AlertCircle, ShieldAlert } from "lucide-react";
7
+ import { PageHeader, StatCard, Card, EmptyState } from "@/components/ui/primitives";
8
+
9
+ export default function AgentsPage() {
10
+ const { data, isLoading, error } = useAgents();
11
+
12
+ const agents: AgentItem[] = data?.agents || [];
13
+ const hasRealData = !error && agents.length > 0;
14
+
15
+ const totalAgents = agents.length;
16
+ const activeAgents = agents.filter((a) => {
17
+ const lastSeen = new Date(a.last_seen_at);
18
+ return Date.now() - lastSeen.getTime() < 5 * 60 * 1000;
19
+ }).length;
20
+ const errorAgents = agents.filter((a) => a.error_count > 0).length;
21
+ const avgRisk = totalAgents > 0
22
+ ? Math.round(agents.reduce((sum, a) => sum + (a.detection_count > 0 ? 50 : 0), 0) / totalAgents)
23
+ : 0;
24
+
25
+ return (
26
+ <div className="mx-auto max-w-6xl p-6">
27
+ <PageHeader
28
+ icon={Bot}
29
+ title="Agents"
30
+ subtitle="Monitor registered agents across environments"
31
+ />
32
+ {!hasRealData && !isLoading && (
33
+ <p className="-mt-2 mb-4 text-xs text-amber-600">
34
+ No agent data yet — send traces with agent names to populate.
35
+ </p>
36
+ )}
37
+
38
+ {/* Summary cards */}
39
+ <div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
40
+ <StatCard label="Total Agents" value={totalAgents} icon={Bot} />
41
+ <StatCard label="Active" value={activeAgents} icon={CheckCircle2} accent="text-emerald-600" />
42
+ <StatCard label="With Errors" value={errorAgents} icon={AlertCircle} accent="text-red-600" />
43
+ <StatCard label="Avg Risk" value={avgRisk} icon={ShieldAlert} accent="text-orange-600" />
44
+ </div>
45
+
46
+ {/* Agent table */}
47
+ <Card className="overflow-hidden">
48
+ {isLoading ? (
49
+ <div className="p-8 text-center text-gray-500">Loading agents…</div>
50
+ ) : agents.length === 0 ? (
51
+ <EmptyState icon={Bot} title="No agents found">
52
+ Send traces to your collector to see agents here.
53
+ </EmptyState>
54
+ ) : (
55
+ <table className="w-full text-sm">
56
+ <thead className="bg-gray-50 dark:bg-gray-800 border-b">
57
+ <tr>
58
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Agent</th>
59
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
60
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Traces</th>
61
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Errors</th>
62
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Avg Latency</th>
63
+ <th className="px-4 py-3 text-right font-medium text-gray-500">P95 Latency</th>
64
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Cost</th>
65
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Detections</th>
66
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Last Seen</th>
67
+ </tr>
68
+ </thead>
69
+ <tbody className="divide-y">
70
+ {agents.map((agent) => {
71
+ const errorRate = agent.trace_count > 0
72
+ ? ((agent.error_count / agent.trace_count) * 100).toFixed(1)
73
+ : "0.0";
74
+ const isActive = Date.now() - new Date(agent.last_seen_at).getTime() < 5 * 60 * 1000;
75
+ const hasErrors = agent.error_count > 0;
76
+
77
+ return (
78
+ <tr key={agent.agent_id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
79
+ <td className="px-4 py-3 font-medium">
80
+ {agent.name || agent.agent_id}
81
+ {agent.framework && (
82
+ <span className="ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium bg-splyntra-50 text-splyntra-700">
83
+ {agent.framework}
84
+ </span>
85
+ )}
86
+ </td>
87
+ <td className="px-4 py-3">
88
+ <StatusBadge status={hasErrors ? "error" : isActive ? "active" : "idle"} />
89
+ </td>
90
+ <td className="px-4 py-3 text-right text-gray-600">
91
+ {agent.trace_count.toLocaleString()}
92
+ </td>
93
+ <td className="px-4 py-3 text-right">
94
+ <span className={agent.error_count > 0 ? "text-red-600 font-medium" : "text-gray-600"}>
95
+ {agent.error_count} ({errorRate}%)
96
+ </span>
97
+ </td>
98
+ <td className="px-4 py-3 text-right text-gray-600">
99
+ {Math.round(agent.avg_latency_ms)}ms
100
+ </td>
101
+ <td className="px-4 py-3 text-right text-gray-600">
102
+ {Math.round(agent.p95_latency_ms)}ms
103
+ </td>
104
+ <td className="px-4 py-3 text-right text-gray-600">
105
+ ${agent.total_cost.toFixed(4)}
106
+ </td>
107
+ <td className="px-4 py-3 text-right">
108
+ {agent.detection_count > 0 ? (
109
+ <span className="px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
110
+ {agent.detection_count}
111
+ </span>
112
+ ) : (
113
+ <span className="text-gray-400">0</span>
114
+ )}
115
+ </td>
116
+ <td className="px-4 py-3 text-right text-xs text-gray-500">
117
+ {formatRelativeTime(agent.last_seen_at)}
118
+ </td>
119
+ </tr>
120
+ );
121
+ })}
122
+ </tbody>
123
+ </table>
124
+ )}
125
+ </Card>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ function StatusBadge({ status }: { status: "active" | "idle" | "error" }) {
131
+ const styles = {
132
+ active: "bg-green-100 text-green-700",
133
+ idle: "bg-gray-100 text-gray-600",
134
+ error: "bg-red-100 text-red-700",
135
+ };
136
+ return (
137
+ <span className={`px-2 py-0.5 rounded text-xs font-medium ${styles[status]}`}>
138
+ {status}
139
+ </span>
140
+ );
141
+ }
142
+
143
+ function formatRelativeTime(iso: string): string {
144
+ const diff = Date.now() - new Date(iso).getTime();
145
+ if (diff < 60_000) return "just now";
146
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
147
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
148
+ return `${Math.floor(diff / 86400_000)}d ago`;
149
+ }
@@ -0,0 +1,227 @@
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 { useAlerts } from "@/lib/hooks";
7
+ import { useProject } from "@/lib/project-context";
8
+ import { createAlert, deleteAlert, AlertItem, AlertEventItem } from "@/lib/api";
9
+ import { Bell } from "lucide-react";
10
+ import { PageHeader } from "@/components/ui/primitives";
11
+
12
+ const CHANNELS = ["email", "webhook", "slack"];
13
+
14
+ export default function AlertsPage() {
15
+ const { data, isLoading } = useAlerts();
16
+ const { projectId } = useProject();
17
+ const queryClient = useQueryClient();
18
+
19
+ const [name, setName] = useState("");
20
+ const [type, setType] = useState<"risk_threshold" | "cost_threshold">("risk_threshold");
21
+ const [threshold, setThreshold] = useState(70);
22
+ const [channels, setChannels] = useState<string[]>(["email"]);
23
+ const [submitting, setSubmitting] = useState(false);
24
+ const [errMsg, setErrMsg] = useState("");
25
+
26
+ const alerts: AlertItem[] = data?.alerts || [];
27
+ const events: AlertEventItem[] = data?.events || [];
28
+
29
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ["alerts"] });
30
+
31
+ async function onCreate(e: React.FormEvent) {
32
+ e.preventDefault();
33
+ setErrMsg("");
34
+ if (!name.trim()) {
35
+ setErrMsg("Name is required");
36
+ return;
37
+ }
38
+ setSubmitting(true);
39
+ try {
40
+ await createAlert({
41
+ name: name.trim(),
42
+ type,
43
+ project_id: projectId || undefined,
44
+ config: type === "cost_threshold" ? { threshold, window_sec: 86400 } : { threshold },
45
+ channels,
46
+ });
47
+ setName("");
48
+ setType("risk_threshold");
49
+ setThreshold(70);
50
+ setChannels(["email"]);
51
+ refresh();
52
+ } catch (err: any) {
53
+ setErrMsg(err?.message || "Failed to create alert");
54
+ } finally {
55
+ setSubmitting(false);
56
+ }
57
+ }
58
+
59
+ async function onDelete(id: string) {
60
+ await deleteAlert(id);
61
+ refresh();
62
+ }
63
+
64
+ function toggleChannel(ch: string) {
65
+ setChannels((prev) => (prev.includes(ch) ? prev.filter((c) => c !== ch) : [...prev, ch]));
66
+ }
67
+
68
+ return (
69
+ <div className="mx-auto max-w-5xl p-6">
70
+ <PageHeader
71
+ icon={Bell}
72
+ title="Alerts"
73
+ subtitle="Fire a notification when a trace's risk score crosses a threshold"
74
+ />
75
+
76
+ {/* Create form */}
77
+ <form
78
+ onSubmit={onCreate}
79
+ className="bg-white dark:bg-gray-900 rounded-lg border p-4 mb-6 grid gap-3 md:grid-cols-4"
80
+ >
81
+ <label className="flex flex-col text-sm md:col-span-2">
82
+ <span className="text-xs text-gray-500 mb-1">Alert name</span>
83
+ <input
84
+ value={name}
85
+ onChange={(e) => setName(e.target.value)}
86
+ placeholder="High-risk traces"
87
+ className="rounded-md border px-2 py-1.5 bg-white dark:bg-gray-800"
88
+ />
89
+ </label>
90
+ <label className="flex flex-col text-sm">
91
+ <span className="text-xs text-gray-500 mb-1">Type</span>
92
+ <select
93
+ value={type}
94
+ onChange={(e) => setType(e.target.value as "risk_threshold" | "cost_threshold")}
95
+ className="rounded-md border px-2 py-1.5 bg-white dark:bg-gray-800"
96
+ >
97
+ <option value="risk_threshold">Risk threshold</option>
98
+ <option value="cost_threshold">Cost threshold (24h $)</option>
99
+ </select>
100
+ </label>
101
+ <label className="flex flex-col text-sm">
102
+ <span className="text-xs text-gray-500 mb-1">
103
+ {type === "cost_threshold" ? "Spend limit (USD / 24h)" : "Risk threshold (0–100)"}
104
+ </span>
105
+ <input
106
+ type="number"
107
+ min={1}
108
+ value={threshold}
109
+ onChange={(e) => setThreshold(Number(e.target.value))}
110
+ className="rounded-md border px-2 py-1.5 bg-white dark:bg-gray-800"
111
+ />
112
+ </label>
113
+ <div className="flex flex-col text-sm">
114
+ <span className="text-xs text-gray-500 mb-1">Channels</span>
115
+ <div className="flex gap-2 flex-wrap items-center h-full">
116
+ {CHANNELS.map((ch) => (
117
+ <label key={ch} className="flex items-center gap-1 text-xs">
118
+ <input
119
+ type="checkbox"
120
+ checked={channels.includes(ch)}
121
+ onChange={() => toggleChannel(ch)}
122
+ />
123
+ {ch}
124
+ </label>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ <div className="md:col-span-4 flex items-center gap-3">
129
+ <button
130
+ type="submit"
131
+ disabled={submitting}
132
+ className="px-4 py-2 rounded-md bg-splyntra-500 text-white text-sm font-medium disabled:opacity-50"
133
+ >
134
+ {submitting ? "Creating…" : "Create alert"}
135
+ </button>
136
+ {errMsg && <span className="text-xs text-red-600">{errMsg}</span>}
137
+ </div>
138
+ </form>
139
+
140
+ {/* Configured alerts */}
141
+ <h2 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
142
+ Configured alerts
143
+ </h2>
144
+ <div className="bg-white dark:bg-gray-900 rounded-lg border overflow-hidden mb-8">
145
+ {isLoading ? (
146
+ <div className="p-6 text-center text-gray-500">Loading…</div>
147
+ ) : alerts.length === 0 ? (
148
+ <div className="p-6 text-center text-gray-500">No alerts configured yet.</div>
149
+ ) : (
150
+ <table className="w-full text-sm">
151
+ <thead className="bg-gray-50 dark:bg-gray-800 border-b">
152
+ <tr>
153
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Name</th>
154
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Type</th>
155
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Threshold</th>
156
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Channels</th>
157
+ <th className="px-4 py-3 text-right font-medium text-gray-500"></th>
158
+ </tr>
159
+ </thead>
160
+ <tbody className="divide-y">
161
+ {alerts.map((a) => (
162
+ <tr key={a.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
163
+ <td className="px-4 py-3 font-medium">{a.name}</td>
164
+ <td className="px-4 py-3 text-gray-600 font-mono text-xs">{a.type}</td>
165
+ <td className="px-4 py-3 text-right text-gray-600">
166
+ {String((a.config as { threshold?: number })?.threshold ?? "—")}
167
+ </td>
168
+ <td className="px-4 py-3 text-gray-600 text-xs">{a.channels.join(", ")}</td>
169
+ <td className="px-4 py-3 text-right">
170
+ <button
171
+ onClick={() => onDelete(a.id)}
172
+ className="text-xs text-red-600 hover:underline"
173
+ >
174
+ Delete
175
+ </button>
176
+ </td>
177
+ </tr>
178
+ ))}
179
+ </tbody>
180
+ </table>
181
+ )}
182
+ </div>
183
+
184
+ {/* Fired history */}
185
+ <h2 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
186
+ Triggered alerts
187
+ </h2>
188
+ <div className="bg-white dark:bg-gray-900 rounded-lg border overflow-hidden">
189
+ {events.length === 0 ? (
190
+ <div className="p-6 text-center text-gray-500">No alerts have fired yet.</div>
191
+ ) : (
192
+ <table className="w-full text-sm">
193
+ <thead className="bg-gray-50 dark:bg-gray-800 border-b">
194
+ <tr>
195
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Alert</th>
196
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Trace</th>
197
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Risk</th>
198
+ <th className="px-4 py-3 text-left font-medium text-gray-500">Severity</th>
199
+ <th className="px-4 py-3 text-right font-medium text-gray-500">Fired</th>
200
+ </tr>
201
+ </thead>
202
+ <tbody className="divide-y">
203
+ {events.map((ev) => (
204
+ <tr key={ev.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
205
+ <td className="px-4 py-3 font-medium">{ev.alert_name}</td>
206
+ <td className="px-4 py-3">
207
+ <a
208
+ href={`/traces/${ev.trace_id}`}
209
+ className="font-mono text-xs text-splyntra-600 hover:underline"
210
+ >
211
+ {ev.trace_id}
212
+ </a>
213
+ </td>
214
+ <td className="px-4 py-3 text-right font-medium">{ev.risk_score}</td>
215
+ <td className="px-4 py-3 text-xs">{ev.severity}</td>
216
+ <td className="px-4 py-3 text-right text-xs text-gray-500">
217
+ {new Date(ev.fired_at).toLocaleString()}
218
+ </td>
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ )}
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { handlers } from "@/auth";
3
+
4
+ export const { GET, POST } = handlers;
@@ -0,0 +1,61 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Runtime proxy to the evaluation service (same pattern as the collector
3
+ // proxy). Resolves the service URL from the environment per request so one
4
+ // image works for self-host and managed cloud.
5
+
6
+ import { NextRequest, NextResponse } from "next/server";
7
+ import { auth as getSession } from "@/auth";
8
+ import { roleAtLeast } from "@/lib/db";
9
+ import { resolveCollectorAuth } from "@/lib/collector-auth";
10
+
11
+ export const dynamic = "force-dynamic";
12
+
13
+ function evalBase(): string {
14
+ return (process.env.EVAL_URL || "http://localhost:8002").replace(/\/$/, "");
15
+ }
16
+
17
+ async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
18
+ if (req.method !== "GET" && req.method !== "HEAD") {
19
+ const session = await getSession();
20
+ const role = (session?.user as { role?: string })?.role;
21
+ if (!roleAtLeast(role, "member")) {
22
+ return NextResponse.json({ error: "insufficient role" }, { status: 403 });
23
+ }
24
+ }
25
+ const target = `${evalBase()}/${path.join("/")}${req.nextUrl.search}`;
26
+
27
+ const headers = new Headers();
28
+ // Authenticate on the logged-in user's behalf (see api/v1 proxy for rationale).
29
+ const session = await getSession();
30
+ const incoming = (req.headers.get("authorization") || "").replace(/^Bearer\s*/i, "").trim();
31
+ const auth = await resolveCollectorAuth(session, incoming);
32
+ for (const [k, v] of Object.entries(auth.headers)) {
33
+ headers.set(k, v);
34
+ }
35
+ headers.set("content-type", req.headers.get("content-type") || "application/json");
36
+
37
+ const init: RequestInit = { method: req.method, headers };
38
+ if (req.method !== "GET" && req.method !== "HEAD") {
39
+ init.body = await req.text();
40
+ }
41
+
42
+ try {
43
+ const res = await fetch(target, init);
44
+ const body = await res.text();
45
+ return new NextResponse(body, {
46
+ status: res.status,
47
+ headers: { "content-type": res.headers.get("content-type") || "application/json" },
48
+ });
49
+ } catch (err) {
50
+ return NextResponse.json({ error: "evaluation service unreachable", detail: String(err) }, { status: 502 });
51
+ }
52
+ }
53
+
54
+ type Ctx = { params: { path: string[] } };
55
+
56
+ export async function GET(req: NextRequest, { params }: Ctx) {
57
+ return proxy(req, params.path);
58
+ }
59
+ export async function POST(req: NextRequest, { params }: Ctx) {
60
+ return proxy(req, params.path);
61
+ }