@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.
- package/next.config.js +33 -0
- package/package.json +62 -0
- package/postcss.config.js +7 -0
- package/public/manifest.json +9 -0
- package/src/app/accept-invite/page.tsx +43 -0
- package/src/app/agents/layout.tsx +11 -0
- package/src/app/agents/page.tsx +149 -0
- package/src/app/alerts/page.tsx +227 -0
- package/src/app/api/auth/[...nextauth]/route.ts +4 -0
- package/src/app/api/eval/[...path]/route.ts +61 -0
- package/src/app/api/v1/[...path]/route.ts +87 -0
- package/src/app/auth-actions.ts +103 -0
- package/src/app/costs/layout.tsx +11 -0
- package/src/app/costs/page.tsx +155 -0
- package/src/app/evaluations/page.tsx +135 -0
- package/src/app/globals.css +42 -0
- package/src/app/layout.tsx +26 -0
- package/src/app/login/page.tsx +52 -0
- package/src/app/metrics/page.tsx +148 -0
- package/src/app/not-found.tsx +23 -0
- package/src/app/page.tsx +56 -0
- package/src/app/projects/page.tsx +130 -0
- package/src/app/providers.tsx +33 -0
- package/src/app/settings/keys/page.tsx +174 -0
- package/src/app/settings/team/InviteForm.tsx +44 -0
- package/src/app/settings/team/page.tsx +112 -0
- package/src/app/signup/page.tsx +33 -0
- package/src/app/traces/[traceId]/page.tsx +132 -0
- package/src/app/traces/layout.tsx +11 -0
- package/src/app/traces/page.tsx +31 -0
- package/src/auth.config.ts +60 -0
- package/src/auth.ts +54 -0
- package/src/components/auth/AuthCard.tsx +45 -0
- package/src/components/layout/AppShell.tsx +22 -0
- package/src/components/layout/Sidebar.tsx +177 -0
- package/src/components/trace/TraceList.tsx +81 -0
- package/src/components/trace/TraceViewer.test.tsx +82 -0
- package/src/components/trace/TraceViewer.tsx +237 -0
- package/src/components/ui/ErrorBoundary.tsx +57 -0
- package/src/components/ui/Skeleton.tsx +40 -0
- package/src/components/ui/primitives.tsx +171 -0
- package/src/global.d.ts +1 -0
- package/src/lib/api.test.ts +24 -0
- package/src/lib/api.ts +379 -0
- package/src/lib/auth-extensions.ts +47 -0
- package/src/lib/auth-providers.ts +8 -0
- package/src/lib/collector-auth-providers.ts +8 -0
- package/src/lib/collector-auth.ts +52 -0
- package/src/lib/db.ts +27 -0
- package/src/lib/features.ts +19 -0
- package/src/lib/hooks.ts +116 -0
- package/src/lib/project-context.tsx +47 -0
- package/src/lib/slots.ts +50 -0
- package/src/middleware.ts +12 -0
- package/src/types/trace.ts +55 -0
- package/tailwind.config.js +26 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Runtime proxy to the collector.
|
|
3
|
+
//
|
|
4
|
+
// Next.js evaluates next.config.js `rewrites()` at BUILD time, which bakes the
|
|
5
|
+
// collector URL into the image and breaks any deployment where the collector is
|
|
6
|
+
// reached by a service name (Docker Compose, Helm). This route handler instead
|
|
7
|
+
// resolves the collector URL from the environment on every request, so the same
|
|
8
|
+
// image works for self-host and managed cloud.
|
|
9
|
+
|
|
10
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
11
|
+
import { auth as getSession } from "@/auth";
|
|
12
|
+
import { roleAtLeast } from "@/lib/db";
|
|
13
|
+
import { resolveCollectorAuth } from "@/lib/collector-auth";
|
|
14
|
+
|
|
15
|
+
export const dynamic = "force-dynamic";
|
|
16
|
+
|
|
17
|
+
function collectorBase(): string {
|
|
18
|
+
return (
|
|
19
|
+
process.env.COLLECTOR_URL ||
|
|
20
|
+
process.env.NEXT_PUBLIC_API_URL ||
|
|
21
|
+
"http://localhost:4318"
|
|
22
|
+
).replace(/\/$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Mutations require at least 'member'; viewers are read-only.
|
|
26
|
+
async function forbidViewerMutation(req: NextRequest): Promise<NextResponse | null> {
|
|
27
|
+
if (req.method === "GET" || req.method === "HEAD") return null;
|
|
28
|
+
const session = await getSession();
|
|
29
|
+
const role = (session?.user as { role?: string })?.role;
|
|
30
|
+
if (!roleAtLeast(role, "member")) {
|
|
31
|
+
return NextResponse.json({ error: "insufficient role" }, { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
|
|
37
|
+
const denied = await forbidViewerMutation(req);
|
|
38
|
+
if (denied) return denied;
|
|
39
|
+
const target = `${collectorBase()}/v1/${path.join("/")}${req.nextUrl.search}`;
|
|
40
|
+
|
|
41
|
+
const headers = new Headers();
|
|
42
|
+
// Authenticate to the collector on the logged-in user's behalf. The auth seam
|
|
43
|
+
// attaches the right credentials per edition: the server org key (Community)
|
|
44
|
+
// or a service token + active-org headers (Cloud, so data is scoped to the
|
|
45
|
+
// user's org). A logged-in user never pastes an API key to see their data.
|
|
46
|
+
const session = await getSession();
|
|
47
|
+
const incoming = (req.headers.get("authorization") || "").replace(/^Bearer\s*/i, "").trim();
|
|
48
|
+
const auth = await resolveCollectorAuth(session, incoming);
|
|
49
|
+
for (const [k, v] of Object.entries(auth.headers)) {
|
|
50
|
+
headers.set(k, v);
|
|
51
|
+
}
|
|
52
|
+
headers.set("content-type", req.headers.get("content-type") || "application/json");
|
|
53
|
+
|
|
54
|
+
const init: RequestInit = { method: req.method, headers };
|
|
55
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
56
|
+
init.body = await req.text();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(target, init);
|
|
61
|
+
const body = await res.text();
|
|
62
|
+
return new NextResponse(body, {
|
|
63
|
+
status: res.status,
|
|
64
|
+
headers: { "content-type": res.headers.get("content-type") || "application/json" },
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return NextResponse.json(
|
|
68
|
+
{ error: "collector unreachable", detail: String(err) },
|
|
69
|
+
{ status: 502 }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Ctx = { params: { path: string[] } };
|
|
75
|
+
|
|
76
|
+
export async function GET(req: NextRequest, { params }: Ctx) {
|
|
77
|
+
return proxy(req, params.path);
|
|
78
|
+
}
|
|
79
|
+
export async function POST(req: NextRequest, { params }: Ctx) {
|
|
80
|
+
return proxy(req, params.path);
|
|
81
|
+
}
|
|
82
|
+
export async function DELETE(req: NextRequest, { params }: Ctx) {
|
|
83
|
+
return proxy(req, params.path);
|
|
84
|
+
}
|
|
85
|
+
export async function PUT(req: NextRequest, { params }: Ctx) {
|
|
86
|
+
return proxy(req, params.path);
|
|
87
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use server";
|
|
3
|
+
|
|
4
|
+
import bcrypt from "bcryptjs";
|
|
5
|
+
import { pool, roleAtLeast } from "@/lib/db";
|
|
6
|
+
import { auth, signIn } from "@/auth";
|
|
7
|
+
|
|
8
|
+
// The seeded dev organization (migrations/postgres/001_init.sql). First signup
|
|
9
|
+
// joins it as owner; subsequent signups join as members.
|
|
10
|
+
const DEV_ORG = "00000000-0000-0000-0000-000000000001";
|
|
11
|
+
|
|
12
|
+
export async function signupAction(_prev: unknown, formData: FormData) {
|
|
13
|
+
const email = String(formData.get("email") || "").toLowerCase().trim();
|
|
14
|
+
const password = String(formData.get("password") || "");
|
|
15
|
+
const name = String(formData.get("name") || "").trim();
|
|
16
|
+
const inviteToken = String(formData.get("invite") || "").trim();
|
|
17
|
+
|
|
18
|
+
if (!email || password.length < 8) {
|
|
19
|
+
return { error: "Email and an 8+ char password are required." };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const client = await pool.connect();
|
|
23
|
+
try {
|
|
24
|
+
const existing = await client.query("SELECT 1 FROM users WHERE email = $1", [email]);
|
|
25
|
+
if (existing.rowCount) return { error: "An account with that email already exists." };
|
|
26
|
+
|
|
27
|
+
// Resolve org + role from an invite, else default to the dev org.
|
|
28
|
+
let orgId = DEV_ORG;
|
|
29
|
+
let role = "member";
|
|
30
|
+
if (inviteToken) {
|
|
31
|
+
const inv = await client.query(
|
|
32
|
+
"SELECT org_id::text, role FROM invitations WHERE token = $1 AND accepted_at IS NULL AND expires_at > NOW()",
|
|
33
|
+
[inviteToken]
|
|
34
|
+
);
|
|
35
|
+
if (!inv.rowCount) return { error: "Invite is invalid or expired." };
|
|
36
|
+
orgId = inv.rows[0].org_id;
|
|
37
|
+
role = inv.rows[0].role;
|
|
38
|
+
} else {
|
|
39
|
+
// First user in the org becomes owner.
|
|
40
|
+
const members = await client.query("SELECT count(*)::int AS n FROM memberships WHERE org_id = $1", [orgId]);
|
|
41
|
+
if (members.rows[0].n === 0) role = "owner";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hash = await bcrypt.hash(password, 10);
|
|
45
|
+
const u = await client.query(
|
|
46
|
+
"INSERT INTO users (email, password_hash, name) VALUES ($1,$2,$3) RETURNING id::text",
|
|
47
|
+
[email, hash, name]
|
|
48
|
+
);
|
|
49
|
+
await client.query(
|
|
50
|
+
"INSERT INTO memberships (user_id, org_id, role) VALUES ($1,$2,$3)",
|
|
51
|
+
[u.rows[0].id, orgId, role]
|
|
52
|
+
);
|
|
53
|
+
if (inviteToken) {
|
|
54
|
+
await client.query("UPDATE invitations SET accepted_at = NOW() WHERE token = $1", [inviteToken]);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
client.release();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await signIn("credentials", { email, password, redirectTo: "/" });
|
|
61
|
+
return { error: "" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function requireAdminOrg(): Promise<string> {
|
|
65
|
+
const session = await auth();
|
|
66
|
+
const role = (session?.user as { role?: string })?.role;
|
|
67
|
+
const orgId = (session?.user as { orgId?: string })?.orgId;
|
|
68
|
+
if (!orgId || !roleAtLeast(role, "admin")) throw new Error("forbidden");
|
|
69
|
+
return orgId;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function randomToken(): string {
|
|
73
|
+
// 32 hex chars; crypto is available in the Node server runtime.
|
|
74
|
+
const bytes = new Uint8Array(16);
|
|
75
|
+
crypto.getRandomValues(bytes);
|
|
76
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function inviteMemberAction(_prev: unknown, formData: FormData) {
|
|
80
|
+
const orgId = await requireAdminOrg();
|
|
81
|
+
const email = String(formData.get("email") || "").toLowerCase().trim();
|
|
82
|
+
const role = String(formData.get("role") || "member");
|
|
83
|
+
if (!email) return { error: "Email required." };
|
|
84
|
+
const token = randomToken();
|
|
85
|
+
await pool.query(
|
|
86
|
+
"INSERT INTO invitations (org_id, email, role, token) VALUES ($1,$2,$3,$4)",
|
|
87
|
+
[orgId, email, role, token]
|
|
88
|
+
);
|
|
89
|
+
return { error: "", token };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function updateRoleAction(formData: FormData) {
|
|
93
|
+
const orgId = await requireAdminOrg();
|
|
94
|
+
const userId = String(formData.get("user_id") || "");
|
|
95
|
+
const role = String(formData.get("role") || "member");
|
|
96
|
+
await pool.query("UPDATE memberships SET role = $1 WHERE user_id = $2 AND org_id = $3", [role, userId, orgId]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function removeMemberAction(formData: FormData) {
|
|
100
|
+
const orgId = await requireAdminOrg();
|
|
101
|
+
const userId = String(formData.get("user_id") || "");
|
|
102
|
+
await pool.query("DELETE FROM memberships WHERE user_id = $1 AND org_id = $2", [userId, orgId]);
|
|
103
|
+
}
|
|
@@ -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: "Costs | Splyntra",
|
|
6
|
+
description: "Token spend analytics by model, agent, and project",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function CostsLayout({ children }: { children: React.ReactNode }) {
|
|
10
|
+
return children;
|
|
11
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useCosts } from "@/lib/hooks";
|
|
5
|
+
import { CostModelItem, ProjectCostItem } from "@/lib/api";
|
|
6
|
+
import { DollarSign, Coins, Hash, Calculator } from "lucide-react";
|
|
7
|
+
import { PageHeader, StatCard } from "@/components/ui/primitives";
|
|
8
|
+
|
|
9
|
+
export default function CostsPage() {
|
|
10
|
+
const { data, isLoading, error } = useCosts();
|
|
11
|
+
|
|
12
|
+
const models: CostModelItem[] = data?.models || [];
|
|
13
|
+
const byProject: ProjectCostItem[] = data?.by_project || [];
|
|
14
|
+
const summary = data?.summary || { total_cost: 0, total_calls: 0, total_tokens: 0, avg_cost_per_call: 0 };
|
|
15
|
+
const hasRealData = !error && models.length > 0;
|
|
16
|
+
|
|
17
|
+
const totalCost = summary.total_cost;
|
|
18
|
+
const totalCalls = summary.total_calls;
|
|
19
|
+
const totalTokens = summary.total_tokens;
|
|
20
|
+
const avgCostPerCall = totalCalls > 0 ? summary.avg_cost_per_call : 0;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="mx-auto max-w-6xl p-6">
|
|
24
|
+
<PageHeader icon={DollarSign} title="Costs" subtitle="Token spend by run, model, and project" />
|
|
25
|
+
{!hasRealData && !isLoading && (
|
|
26
|
+
<p className="-mt-2 mb-4 text-xs text-amber-600">
|
|
27
|
+
No cost data yet — send LLM traces with model info to see cost breakdown.
|
|
28
|
+
</p>
|
|
29
|
+
)}
|
|
30
|
+
|
|
31
|
+
{/* Summary cards */}
|
|
32
|
+
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
33
|
+
<StatCard label="Total Spend" value={`$${totalCost.toFixed(2)}`} icon={DollarSign} />
|
|
34
|
+
<StatCard label="Total Tokens" value={totalTokens.toLocaleString()} icon={Coins} />
|
|
35
|
+
<StatCard label="LLM Calls" value={totalCalls.toLocaleString()} icon={Hash} />
|
|
36
|
+
<StatCard label="Avg Cost/Call" value={`$${avgCostPerCall.toFixed(4)}`} icon={Calculator} />
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{/* Per-project breakdown */}
|
|
40
|
+
{byProject.length > 0 && (
|
|
41
|
+
<div className="mb-6">
|
|
42
|
+
<h2 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
|
|
43
|
+
Cost by Project
|
|
44
|
+
</h2>
|
|
45
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
46
|
+
{byProject.map((p) => (
|
|
47
|
+
<div
|
|
48
|
+
key={p.project_id}
|
|
49
|
+
className="bg-white dark:bg-gray-900 rounded-lg border p-4"
|
|
50
|
+
>
|
|
51
|
+
<div className="text-xs font-medium font-mono truncate" title={p.project_id}>
|
|
52
|
+
{p.project_id}
|
|
53
|
+
</div>
|
|
54
|
+
<div className="text-xl font-bold mt-1">${p.total_cost.toFixed(4)}</div>
|
|
55
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
56
|
+
{p.call_count.toLocaleString()} calls · {p.total_tokens.toLocaleString()} tokens
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{/* Model breakdown */}
|
|
65
|
+
{models.length > 0 && (
|
|
66
|
+
<div className="mb-6">
|
|
67
|
+
<h2 className="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">
|
|
68
|
+
Cost by Model
|
|
69
|
+
</h2>
|
|
70
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
71
|
+
{models.map((m) => (
|
|
72
|
+
<div
|
|
73
|
+
key={m.model}
|
|
74
|
+
className="bg-white dark:bg-gray-900 rounded-lg border p-4"
|
|
75
|
+
>
|
|
76
|
+
<div className="text-sm font-medium font-mono">{m.model}</div>
|
|
77
|
+
<div className="text-xl font-bold mt-1">${m.total_cost.toFixed(4)}</div>
|
|
78
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
79
|
+
{m.call_count.toLocaleString()} calls ·{" "}
|
|
80
|
+
{(m.total_prompt_tokens + m.total_completion_tokens).toLocaleString()} tokens
|
|
81
|
+
</div>
|
|
82
|
+
<div className="mt-2 h-2 bg-gray-100 dark:bg-gray-800 rounded">
|
|
83
|
+
<div
|
|
84
|
+
className="h-full bg-splyntra-500 rounded"
|
|
85
|
+
style={{ width: `${totalCost > 0 ? (m.total_cost / totalCost) * 100 : 0}%` }}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{/* Detailed table */}
|
|
95
|
+
<div className="bg-white dark:bg-gray-900 rounded-lg border overflow-hidden">
|
|
96
|
+
{isLoading ? (
|
|
97
|
+
<div className="p-8 text-center text-gray-500">Loading costs...</div>
|
|
98
|
+
) : models.length === 0 ? (
|
|
99
|
+
<div className="p-8 text-center text-gray-500">
|
|
100
|
+
No LLM usage data yet. Send traces with model information to see cost analytics.
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
<table className="w-full text-sm">
|
|
104
|
+
<thead className="bg-gray-50 dark:bg-gray-800 border-b">
|
|
105
|
+
<tr>
|
|
106
|
+
<th className="px-4 py-3 text-left font-medium text-gray-500">Model</th>
|
|
107
|
+
<th className="px-4 py-3 text-right font-medium text-gray-500">Calls</th>
|
|
108
|
+
<th className="px-4 py-3 text-right font-medium text-gray-500">Prompt Tokens</th>
|
|
109
|
+
<th className="px-4 py-3 text-right font-medium text-gray-500">Completion Tokens</th>
|
|
110
|
+
<th className="px-4 py-3 text-right font-medium text-gray-500">Total Cost</th>
|
|
111
|
+
<th className="px-4 py-3 text-right font-medium text-gray-500">Avg/Call</th>
|
|
112
|
+
</tr>
|
|
113
|
+
</thead>
|
|
114
|
+
<tbody className="divide-y">
|
|
115
|
+
{models.map((m) => (
|
|
116
|
+
<tr key={m.model} className="hover:bg-gray-50 dark:hover:bg-gray-800">
|
|
117
|
+
<td className="px-4 py-3 font-medium font-mono text-xs">{m.model}</td>
|
|
118
|
+
<td className="px-4 py-3 text-right text-gray-600">
|
|
119
|
+
{m.call_count.toLocaleString()}
|
|
120
|
+
</td>
|
|
121
|
+
<td className="px-4 py-3 text-right text-gray-600">
|
|
122
|
+
{m.total_prompt_tokens.toLocaleString()}
|
|
123
|
+
</td>
|
|
124
|
+
<td className="px-4 py-3 text-right text-gray-600">
|
|
125
|
+
{m.total_completion_tokens.toLocaleString()}
|
|
126
|
+
</td>
|
|
127
|
+
<td className="px-4 py-3 text-right font-medium">
|
|
128
|
+
${m.total_cost.toFixed(4)}
|
|
129
|
+
</td>
|
|
130
|
+
<td className="px-4 py-3 text-right text-gray-600">
|
|
131
|
+
${m.avg_cost_per_call.toFixed(4)}
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
))}
|
|
135
|
+
</tbody>
|
|
136
|
+
<tfoot className="bg-gray-50 dark:bg-gray-800 border-t font-medium">
|
|
137
|
+
<tr>
|
|
138
|
+
<td className="px-4 py-3">Total</td>
|
|
139
|
+
<td className="px-4 py-3 text-right">{totalCalls.toLocaleString()}</td>
|
|
140
|
+
<td className="px-4 py-3 text-right">
|
|
141
|
+
{models.reduce((s, m) => s + m.total_prompt_tokens, 0).toLocaleString()}
|
|
142
|
+
</td>
|
|
143
|
+
<td className="px-4 py-3 text-right">
|
|
144
|
+
{models.reduce((s, m) => s + m.total_completion_tokens, 0).toLocaleString()}
|
|
145
|
+
</td>
|
|
146
|
+
<td className="px-4 py-3 text-right">${totalCost.toFixed(4)}</td>
|
|
147
|
+
<td className="px-4 py-3 text-right">${avgCostPerCall.toFixed(4)}</td>
|
|
148
|
+
</tr>
|
|
149
|
+
</tfoot>
|
|
150
|
+
</table>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, ReferenceLine } from "recharts";
|
|
5
|
+
import { ClipboardCheck, Database, CheckCircle2, AlertTriangle } from "lucide-react";
|
|
6
|
+
import { useDatasets, useEvalRuns } from "@/lib/hooks";
|
|
7
|
+
import { EvalDataset, EvalRun } from "@/lib/api";
|
|
8
|
+
import { PageHeader, Card, StatCard, EmptyState } from "@/components/ui/primitives";
|
|
9
|
+
|
|
10
|
+
export default function EvaluationsPage() {
|
|
11
|
+
const { data: dsData, isLoading: dsLoading } = useDatasets();
|
|
12
|
+
const { data: runData } = useEvalRuns();
|
|
13
|
+
|
|
14
|
+
const datasets: EvalDataset[] = dsData?.datasets || [];
|
|
15
|
+
const runs: EvalRun[] = runData?.runs || [];
|
|
16
|
+
|
|
17
|
+
const latest = runs[0];
|
|
18
|
+
const regressions = runs.filter((r) => r.regression).length;
|
|
19
|
+
const trend = [...runs]
|
|
20
|
+
.reverse()
|
|
21
|
+
.map((r) => ({ t: new Date(r.created_at).toLocaleDateString(), score: +(r.score * 100).toFixed(1) }));
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="mx-auto max-w-6xl p-6">
|
|
25
|
+
<PageHeader icon={ClipboardCheck} title="Evaluation" subtitle="Datasets, benchmark runs, and regression gates" />
|
|
26
|
+
|
|
27
|
+
<div className="mb-6 grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
28
|
+
<StatCard label="Datasets" value={datasets.length} icon={Database} />
|
|
29
|
+
<StatCard label="Runs" value={runs.length} icon={ClipboardCheck} />
|
|
30
|
+
<StatCard
|
|
31
|
+
label="Latest score"
|
|
32
|
+
value={latest ? `${(latest.score * 100).toFixed(1)}%` : "—"}
|
|
33
|
+
icon={CheckCircle2}
|
|
34
|
+
accent={latest && latest.passed ? "text-emerald-600" : "text-red-600"}
|
|
35
|
+
/>
|
|
36
|
+
<StatCard label="Regressions" value={regressions} icon={AlertTriangle} accent={regressions > 0 ? "text-red-600" : undefined} />
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{trend.length > 1 && (
|
|
40
|
+
<Card className="mb-6 p-4">
|
|
41
|
+
<h3 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Score over time (%)</h3>
|
|
42
|
+
<div style={{ width: "100%", height: 220 }}>
|
|
43
|
+
<ResponsiveContainer>
|
|
44
|
+
<LineChart data={trend}>
|
|
45
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
46
|
+
<XAxis dataKey="t" tick={{ fontSize: 11 }} stroke="#9ca3af" />
|
|
47
|
+
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} stroke="#9ca3af" width={40} />
|
|
48
|
+
<Tooltip contentStyle={{ fontSize: 12, borderRadius: 8 }} />
|
|
49
|
+
<ReferenceLine y={latest ? +(latest.score * 100).toFixed(1) : 0} stroke="#adb5bd" strokeDasharray="4 4" />
|
|
50
|
+
<Line type="monotone" dataKey="score" stroke="#4c6ef5" strokeWidth={2} dot={{ r: 2 }} />
|
|
51
|
+
</LineChart>
|
|
52
|
+
</ResponsiveContainer>
|
|
53
|
+
</div>
|
|
54
|
+
</Card>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
<h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Datasets</h2>
|
|
58
|
+
<Card className="mb-6 overflow-hidden">
|
|
59
|
+
{dsLoading ? (
|
|
60
|
+
<div className="p-8 text-center text-gray-500">Loading…</div>
|
|
61
|
+
) : datasets.length === 0 ? (
|
|
62
|
+
<EmptyState icon={Database} title="No datasets yet">
|
|
63
|
+
Create one with the SDK: <code className="text-xs">splyntra eval push --name ...</code> or
|
|
64
|
+
<code className="text-xs"> POST /v1/datasets</code>.
|
|
65
|
+
</EmptyState>
|
|
66
|
+
) : (
|
|
67
|
+
<table className="w-full text-sm">
|
|
68
|
+
<thead className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-800/50">
|
|
69
|
+
<tr className="[&>th]:px-4 [&>th]:py-3 [&>th]:font-medium [&>th]:text-gray-500">
|
|
70
|
+
<th>Name</th>
|
|
71
|
+
<th>Slug</th>
|
|
72
|
+
<th className="text-right">Version</th>
|
|
73
|
+
<th className="text-right">Items</th>
|
|
74
|
+
<th className="text-right">Created</th>
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
78
|
+
{datasets.map((d) => (
|
|
79
|
+
<tr key={d.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
|
80
|
+
<td className="px-4 py-3 font-medium">{d.name}</td>
|
|
81
|
+
<td className="px-4 py-3 font-mono text-xs text-gray-500">{d.slug}</td>
|
|
82
|
+
<td className="px-4 py-3 text-right tabular-nums">v{d.latest_version}</td>
|
|
83
|
+
<td className="px-4 py-3 text-right tabular-nums">{d.item_count}</td>
|
|
84
|
+
<td className="px-4 py-3 text-right text-xs text-gray-500">{new Date(d.created_at).toLocaleDateString()}</td>
|
|
85
|
+
</tr>
|
|
86
|
+
))}
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
)}
|
|
90
|
+
</Card>
|
|
91
|
+
|
|
92
|
+
<h2 className="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Recent runs</h2>
|
|
93
|
+
<Card className="overflow-hidden">
|
|
94
|
+
{runs.length === 0 ? (
|
|
95
|
+
<EmptyState icon={ClipboardCheck} title="No runs yet">
|
|
96
|
+
Run <code className="text-xs">splyntra eval run --gate</code> in CI to score against a dataset.
|
|
97
|
+
</EmptyState>
|
|
98
|
+
) : (
|
|
99
|
+
<table className="w-full text-sm">
|
|
100
|
+
<thead className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-800/50">
|
|
101
|
+
<tr className="[&>th]:px-4 [&>th]:py-3 [&>th]:font-medium [&>th]:text-gray-500">
|
|
102
|
+
<th>Run</th>
|
|
103
|
+
<th className="text-right">Score</th>
|
|
104
|
+
<th className="text-right">Items</th>
|
|
105
|
+
<th>Gate</th>
|
|
106
|
+
<th className="text-right">When</th>
|
|
107
|
+
</tr>
|
|
108
|
+
</thead>
|
|
109
|
+
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
110
|
+
{runs.map((r) => (
|
|
111
|
+
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
|
112
|
+
<td className="px-4 py-3 font-mono text-xs text-gray-500">{r.id.slice(0, 8)}</td>
|
|
113
|
+
<td className="px-4 py-3 text-right font-medium tabular-nums">{(r.score * 100).toFixed(1)}%</td>
|
|
114
|
+
<td className="px-4 py-3 text-right tabular-nums text-gray-500">{r.item_count}</td>
|
|
115
|
+
<td className="px-4 py-3">
|
|
116
|
+
{r.regression ? (
|
|
117
|
+
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-red-50 text-red-700 ring-1 ring-inset ring-red-200">
|
|
118
|
+
<AlertTriangle className="h-3 w-3" /> regression
|
|
119
|
+
</span>
|
|
120
|
+
) : (
|
|
121
|
+
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-200">
|
|
122
|
+
<CheckCircle2 className="h-3 w-3" /> {r.passed ? "passed" : "ok"}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
</td>
|
|
126
|
+
<td className="px-4 py-3 text-right text-xs text-gray-500">{new Date(r.created_at).toLocaleString()}</td>
|
|
127
|
+
</tr>
|
|
128
|
+
))}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
)}
|
|
132
|
+
</Card>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
--background: #f8fafc;
|
|
7
|
+
--foreground: #1c2541;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@media (prefers-color-scheme: dark) {
|
|
11
|
+
:root {
|
|
12
|
+
--background: #0b0f14;
|
|
13
|
+
--foreground: #e1e8ed;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
body {
|
|
18
|
+
color: var(--foreground);
|
|
19
|
+
background: var(--background);
|
|
20
|
+
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
21
|
+
"Helvetica Neue", Arial, sans-serif;
|
|
22
|
+
-webkit-font-smoothing: antialiased;
|
|
23
|
+
-moz-osx-font-smoothing: grayscale;
|
|
24
|
+
text-rendering: optimizeLegibility;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Subtle, unobtrusive scrollbars */
|
|
28
|
+
* {
|
|
29
|
+
scrollbar-width: thin;
|
|
30
|
+
scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
|
|
31
|
+
}
|
|
32
|
+
*::-webkit-scrollbar {
|
|
33
|
+
width: 8px;
|
|
34
|
+
height: 8px;
|
|
35
|
+
}
|
|
36
|
+
*::-webkit-scrollbar-thumb {
|
|
37
|
+
background-color: rgba(148, 163, 184, 0.5);
|
|
38
|
+
border-radius: 9999px;
|
|
39
|
+
}
|
|
40
|
+
*::-webkit-scrollbar-track {
|
|
41
|
+
background: transparent;
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import "./globals.css";
|
|
4
|
+
import { Providers } from "./providers";
|
|
5
|
+
import { AppShell } from "@/components/layout/AppShell";
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: "Splyntra - Agent Observability & Security",
|
|
9
|
+
description: "Unified observability and security for AI agents",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function RootLayout({
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="en">
|
|
19
|
+
<body className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
|
20
|
+
<Providers>
|
|
21
|
+
<AppShell>{children}</AppShell>
|
|
22
|
+
</Providers>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useRouter } from "next/navigation";
|
|
7
|
+
import { signIn } from "next-auth/react";
|
|
8
|
+
import { AuthCard, Field } from "@/components/auth/AuthCard";
|
|
9
|
+
|
|
10
|
+
export default function LoginPage() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
const [busy, setBusy] = useState(false);
|
|
14
|
+
|
|
15
|
+
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
16
|
+
e.preventDefault();
|
|
17
|
+
setError("");
|
|
18
|
+
setBusy(true);
|
|
19
|
+
const form = new FormData(e.currentTarget);
|
|
20
|
+
const res = await signIn("credentials", {
|
|
21
|
+
email: form.get("email"),
|
|
22
|
+
password: form.get("password"),
|
|
23
|
+
redirect: false,
|
|
24
|
+
});
|
|
25
|
+
setBusy(false);
|
|
26
|
+
if (res?.error) setError("Invalid email or password.");
|
|
27
|
+
else router.push("/");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<AuthCard title="Sign in to Splyntra">
|
|
32
|
+
<form onSubmit={onSubmit} className="space-y-3">
|
|
33
|
+
<Field name="email" type="email" label="Email" />
|
|
34
|
+
<Field name="password" type="password" label="Password" />
|
|
35
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
36
|
+
<button
|
|
37
|
+
type="submit"
|
|
38
|
+
disabled={busy}
|
|
39
|
+
className="w-full rounded-lg bg-splyntra-600 py-2 text-sm font-medium text-white hover:bg-splyntra-700 disabled:opacity-50"
|
|
40
|
+
>
|
|
41
|
+
{busy ? "Signing in…" : "Sign in"}
|
|
42
|
+
</button>
|
|
43
|
+
</form>
|
|
44
|
+
<p className="mt-4 text-center text-sm text-gray-500">
|
|
45
|
+
No account?{" "}
|
|
46
|
+
<Link href="/signup" className="text-splyntra-600 hover:underline">
|
|
47
|
+
Create one
|
|
48
|
+
</Link>
|
|
49
|
+
</p>
|
|
50
|
+
</AuthCard>
|
|
51
|
+
);
|
|
52
|
+
}
|