@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,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { TraceListItem } from "@/lib/api";
|
|
5
|
+
import { useRouter } from "next/navigation";
|
|
6
|
+
import { Inbox, ChevronRight } from "lucide-react";
|
|
7
|
+
import { Card, EmptyState, StatusPill, RiskBadge, severityFromScore } from "@/components/ui/primitives";
|
|
8
|
+
|
|
9
|
+
interface TraceListProps {
|
|
10
|
+
traces: TraceListItem[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function TraceList({ traces }: TraceListProps) {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
|
|
16
|
+
if (traces.length === 0) {
|
|
17
|
+
return (
|
|
18
|
+
<Card>
|
|
19
|
+
<EmptyState icon={Inbox} title="No traces yet">
|
|
20
|
+
Run an instrumented agent to see traces appear here.
|
|
21
|
+
<pre className="mt-3 inline-block rounded-lg bg-gray-100 p-3 text-left text-xs dark:bg-gray-800">
|
|
22
|
+
{`pip install splyntra\npython examples/quickstart.py`}
|
|
23
|
+
</pre>
|
|
24
|
+
</EmptyState>
|
|
25
|
+
</Card>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Card className="overflow-hidden">
|
|
31
|
+
<table className="w-full text-sm">
|
|
32
|
+
<thead className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-800 dark:bg-gray-800/50">
|
|
33
|
+
<tr className="[&>th]:px-4 [&>th]:py-3 [&>th]:font-medium [&>th]:text-gray-500">
|
|
34
|
+
<th>Trace</th>
|
|
35
|
+
<th>Agent</th>
|
|
36
|
+
<th>Status</th>
|
|
37
|
+
<th className="text-right">Latency</th>
|
|
38
|
+
<th className="text-right">Tokens</th>
|
|
39
|
+
<th className="text-right">Cost</th>
|
|
40
|
+
<th className="text-right">Risk</th>
|
|
41
|
+
<th className="text-right">Time</th>
|
|
42
|
+
<th className="w-8" />
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
46
|
+
{traces.map((trace) => (
|
|
47
|
+
<tr
|
|
48
|
+
key={trace.trace_id}
|
|
49
|
+
onClick={() => router.push(`/traces/${trace.trace_id}`)}
|
|
50
|
+
className="group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
|
|
51
|
+
>
|
|
52
|
+
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-400">
|
|
53
|
+
{trace.trace_id.slice(0, 12)}…
|
|
54
|
+
</td>
|
|
55
|
+
<td className="px-4 py-3 font-medium">{trace.agent_id}</td>
|
|
56
|
+
<td className="px-4 py-3">
|
|
57
|
+
<StatusPill status={trace.status} />
|
|
58
|
+
</td>
|
|
59
|
+
<td className="px-4 py-3 text-right tabular-nums text-gray-600 dark:text-gray-400">{trace.latency_ms}ms</td>
|
|
60
|
+
<td className="px-4 py-3 text-right tabular-nums text-gray-600 dark:text-gray-400">
|
|
61
|
+
{trace.total_tokens.toLocaleString()}
|
|
62
|
+
</td>
|
|
63
|
+
<td className="px-4 py-3 text-right tabular-nums text-gray-600 dark:text-gray-400">
|
|
64
|
+
${trace.cost_usd.toFixed(4)}
|
|
65
|
+
</td>
|
|
66
|
+
<td className="px-4 py-3 text-right">
|
|
67
|
+
<RiskBadge score={trace.risk_score} severity={trace.risk_severity || severityFromScore(trace.risk_score)} />
|
|
68
|
+
</td>
|
|
69
|
+
<td className="px-4 py-3 text-right text-xs text-gray-500">
|
|
70
|
+
{new Date(trace.started_at).toLocaleTimeString()}
|
|
71
|
+
</td>
|
|
72
|
+
<td className="px-2 text-gray-300 dark:text-gray-600">
|
|
73
|
+
<ChevronRight className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100" />
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
))}
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</Card>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { TraceViewer } from "./TraceViewer";
|
|
5
|
+
import { Trace } from "@/types/trace";
|
|
6
|
+
|
|
7
|
+
const trace: Trace = {
|
|
8
|
+
traceId: "tr_8f3abc",
|
|
9
|
+
agentId: "support_agent",
|
|
10
|
+
workflowId: "wf_refund",
|
|
11
|
+
status: "ok",
|
|
12
|
+
latencyMs: 1200,
|
|
13
|
+
totalTokens: 340,
|
|
14
|
+
costUsd: 0.004,
|
|
15
|
+
riskScore: 72,
|
|
16
|
+
riskSeverity: "HIGH",
|
|
17
|
+
detections: [
|
|
18
|
+
{
|
|
19
|
+
detector: "secrets",
|
|
20
|
+
category: "aws_key",
|
|
21
|
+
severity: "CRITICAL",
|
|
22
|
+
confidence: 0.95,
|
|
23
|
+
description: "AWS key in tool input",
|
|
24
|
+
beta: false,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
detector: "injection",
|
|
28
|
+
category: "instruction_override",
|
|
29
|
+
severity: "MEDIUM",
|
|
30
|
+
confidence: 0.7,
|
|
31
|
+
description: "instruction override pattern",
|
|
32
|
+
beta: true,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
spans: [
|
|
36
|
+
{
|
|
37
|
+
spanId: "sp_1",
|
|
38
|
+
type: "llm_call",
|
|
39
|
+
name: "plan refund",
|
|
40
|
+
status: "ok",
|
|
41
|
+
latencyMs: 220,
|
|
42
|
+
detections: [],
|
|
43
|
+
startedAt: new Date(0).toISOString(),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
spanId: "sp_2",
|
|
47
|
+
type: "tool_call",
|
|
48
|
+
name: "payments.refund",
|
|
49
|
+
status: "ok",
|
|
50
|
+
latencyMs: 680,
|
|
51
|
+
detections: [],
|
|
52
|
+
startedAt: new Date(0).toISOString(),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
startedAt: new Date(0).toISOString(),
|
|
56
|
+
completedAt: new Date(0).toISOString(),
|
|
57
|
+
orgId: "org_1",
|
|
58
|
+
projectId: "proj_1",
|
|
59
|
+
environment: "development",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("TraceViewer (unified trace + risk view)", () => {
|
|
63
|
+
it("renders the trace id, agent, and risk score together", () => {
|
|
64
|
+
render(<TraceViewer trace={trace} />);
|
|
65
|
+
expect(screen.getByText("tr_8f3abc")).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText("support_agent")).toBeInTheDocument();
|
|
67
|
+
// Risk score is surfaced as part of the unified view.
|
|
68
|
+
expect(screen.getByText(/72/)).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders security detections including the beta injection finding", () => {
|
|
72
|
+
render(<TraceViewer trace={trace} />);
|
|
73
|
+
expect(screen.getByText(/AWS key in tool input/)).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText(/instruction override pattern/)).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("renders execution steps for replay", () => {
|
|
78
|
+
render(<TraceViewer trace={trace} />);
|
|
79
|
+
expect(screen.getByText("plan refund")).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText("payments.refund")).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
/**
|
|
3
|
+
* TraceViewer - The crown jewel of the dashboard.
|
|
4
|
+
* Shows execution trace + risk score in one unified view.
|
|
5
|
+
* Supports agent replay: step-by-step reconstruction of any run.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
"use client";
|
|
9
|
+
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import {
|
|
12
|
+
Bot,
|
|
13
|
+
BrainCircuit,
|
|
14
|
+
Wrench,
|
|
15
|
+
ArrowRight,
|
|
16
|
+
CornerDownRight,
|
|
17
|
+
ChevronDown,
|
|
18
|
+
ChevronRight,
|
|
19
|
+
ShieldAlert,
|
|
20
|
+
Clock,
|
|
21
|
+
DollarSign,
|
|
22
|
+
Coins,
|
|
23
|
+
type LucideIcon,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { Trace, Span, Detection } from "@/types/trace";
|
|
26
|
+
import { Card, RiskBadge, SeverityBadge, StatusPill } from "@/components/ui/primitives";
|
|
27
|
+
|
|
28
|
+
interface TraceViewerProps {
|
|
29
|
+
trace: Trace;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function TraceViewer({ trace }: TraceViewerProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="space-y-4">
|
|
35
|
+
{/* Header */}
|
|
36
|
+
<Card className="flex flex-wrap items-center justify-between gap-4 p-4">
|
|
37
|
+
<div>
|
|
38
|
+
<div className="flex items-center gap-2">
|
|
39
|
+
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-sm text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
|
40
|
+
{trace.traceId}
|
|
41
|
+
</code>
|
|
42
|
+
<StatusPill status={trace.status} />
|
|
43
|
+
</div>
|
|
44
|
+
<div className="mt-1.5 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
45
|
+
<Bot className="h-4 w-4 text-gray-400" />
|
|
46
|
+
<span className="font-medium">{trace.agentId}</span>
|
|
47
|
+
{trace.workflowId && (
|
|
48
|
+
<span className="text-gray-400">· workflow: {trace.workflowId}</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="flex items-center gap-5">
|
|
54
|
+
<Metric icon={Clock} label="Latency" value={`${trace.latencyMs}ms`} />
|
|
55
|
+
<Metric icon={DollarSign} label="Cost" value={`$${trace.costUsd.toFixed(4)}`} />
|
|
56
|
+
<Metric icon={Coins} label="Tokens" value={trace.totalTokens.toLocaleString()} />
|
|
57
|
+
<RiskBadge score={trace.riskScore} severity={trace.riskSeverity} />
|
|
58
|
+
</div>
|
|
59
|
+
</Card>
|
|
60
|
+
|
|
61
|
+
{/* Detections Panel */}
|
|
62
|
+
{trace.detections.length > 0 && (
|
|
63
|
+
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/20">
|
|
64
|
+
<h3 className="mb-2 flex items-center gap-2 text-sm font-semibold text-red-800 dark:text-red-200">
|
|
65
|
+
<ShieldAlert className="h-4 w-4" />
|
|
66
|
+
Security Detections ({trace.detections.length})
|
|
67
|
+
</h3>
|
|
68
|
+
<div className="space-y-1.5">
|
|
69
|
+
{trace.detections.map((d, i) => (
|
|
70
|
+
<DetectionRow key={i} detection={d} />
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Span Waterfall (Replay View) */}
|
|
77
|
+
<Card>
|
|
78
|
+
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-800">
|
|
79
|
+
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Execution Steps (Replay)</h3>
|
|
80
|
+
<span className="text-xs text-gray-500">{trace.spans.length} steps · click to expand</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
83
|
+
{trace.spans.map((span, idx) => (
|
|
84
|
+
<SpanRow key={span.spanId} span={span} traceLatency={trace.latencyMs} stepNumber={idx + 1} />
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
</Card>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function SpanRow({ span, traceLatency, stepNumber }: { span: Span; traceLatency: number; stepNumber: number }) {
|
|
93
|
+
const [expanded, setExpanded] = useState(false);
|
|
94
|
+
const widthPct = Math.max((span.latencyMs / Math.max(traceLatency, 1)) * 100, 2);
|
|
95
|
+
const flagged = span.detections.length > 0;
|
|
96
|
+
const Chevron = expanded ? ChevronDown : ChevronRight;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<div
|
|
101
|
+
className="flex items-center px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/60 cursor-pointer"
|
|
102
|
+
onClick={() => setExpanded(!expanded)}
|
|
103
|
+
>
|
|
104
|
+
<div className="flex w-1/3 items-center gap-2">
|
|
105
|
+
<span className="w-5 text-xs tabular-nums text-gray-400">{stepNumber}.</span>
|
|
106
|
+
<SpanTypeIcon type={span.type} />
|
|
107
|
+
<span className="truncate text-sm font-medium">{span.name}</span>
|
|
108
|
+
{span.parentSpanId && <CornerDownRight className="h-3 w-3 shrink-0 text-gray-400" />}
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex-1 px-4">
|
|
111
|
+
<div className="relative h-2 rounded-full bg-gray-100 dark:bg-gray-800">
|
|
112
|
+
<div
|
|
113
|
+
className={`h-full rounded-full ${
|
|
114
|
+
flagged ? "bg-red-400" : span.status === "error" ? "bg-orange-400" : "bg-splyntra-500"
|
|
115
|
+
}`}
|
|
116
|
+
style={{ width: `${widthPct}%` }}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="w-20 text-right text-xs tabular-nums text-gray-500">{span.latencyMs}ms</div>
|
|
121
|
+
{flagged && (
|
|
122
|
+
<span className="ml-2 inline-flex items-center gap-1 text-xs font-medium text-red-600">
|
|
123
|
+
<ShieldAlert className="h-3.5 w-3.5" /> flagged
|
|
124
|
+
</span>
|
|
125
|
+
)}
|
|
126
|
+
<Chevron className="ml-2 h-4 w-4 text-gray-400" />
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Expanded detail panel for replay */}
|
|
130
|
+
{expanded && (
|
|
131
|
+
<div className="border-t border-dashed border-gray-200 bg-gray-50 px-4 pb-3 dark:border-gray-700 dark:bg-gray-800/40">
|
|
132
|
+
<div className="grid grid-cols-2 gap-4 py-3 text-xs md:grid-cols-4">
|
|
133
|
+
<Detail label="Type" value={<span className="font-medium">{span.type}</span>} />
|
|
134
|
+
<Detail label="Status" value={<StatusPill status={span.status} />} />
|
|
135
|
+
<Detail label="Duration" value={<span className="font-medium">{span.latencyMs}ms</span>} />
|
|
136
|
+
<Detail label="Span ID" value={<code className="text-gray-600 dark:text-gray-300">{span.spanId.slice(0, 12)}</code>} />
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{span.tokens && (
|
|
140
|
+
<div className="mt-1 rounded-lg border border-gray-200 bg-white p-2.5 dark:border-gray-700 dark:bg-gray-900">
|
|
141
|
+
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-gray-700 dark:text-gray-300">
|
|
142
|
+
<BrainCircuit className="h-3.5 w-3.5 text-splyntra-500" /> LLM Usage
|
|
143
|
+
</div>
|
|
144
|
+
<div className="grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
|
|
145
|
+
<div><span className="text-gray-500">Model:</span> <span className="font-mono">{span.tokens.model}</span></div>
|
|
146
|
+
<div><span className="text-gray-500">Prompt:</span> {span.tokens.promptTokens.toLocaleString()}</div>
|
|
147
|
+
<div><span className="text-gray-500">Completion:</span> {span.tokens.completionTokens.toLocaleString()}</div>
|
|
148
|
+
<div><span className="text-gray-500">Cost:</span> ${span.tokens.costUsd.toFixed(5)}</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{span.detections.length > 0 && (
|
|
154
|
+
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 p-2.5 dark:border-red-800 dark:bg-red-900/20">
|
|
155
|
+
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-red-700 dark:text-red-300">
|
|
156
|
+
<ShieldAlert className="h-3.5 w-3.5" /> Security Findings
|
|
157
|
+
</div>
|
|
158
|
+
<div className="space-y-1.5">
|
|
159
|
+
{span.detections.map((d, i) => (
|
|
160
|
+
<DetectionRow key={i} detection={d} />
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{span.input && Object.keys(span.input).length > 0 && <CodeBlock label="Input" value={span.input} />}
|
|
167
|
+
{span.output && Object.keys(span.output).length > 0 && <CodeBlock label="Output" value={span.output} />}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function Detail({ label, value }: { label: string; value: React.ReactNode }) {
|
|
175
|
+
return (
|
|
176
|
+
<div>
|
|
177
|
+
<span className="block text-gray-500">{label}</span>
|
|
178
|
+
{value}
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function CodeBlock({ label, value }: { label: string; value: Record<string, unknown> }) {
|
|
184
|
+
return (
|
|
185
|
+
<div className="mt-2">
|
|
186
|
+
<div className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-300">{label}</div>
|
|
187
|
+
<pre className="max-h-32 overflow-auto rounded-lg border border-gray-200 bg-white p-2 text-xs dark:border-gray-700 dark:bg-gray-900">
|
|
188
|
+
{JSON.stringify(value, null, 2)}
|
|
189
|
+
</pre>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function DetectionRow({ detection }: { detection: Detection }) {
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex items-center gap-2 text-sm">
|
|
197
|
+
<SeverityBadge severity={detection.severity} />
|
|
198
|
+
<span className="text-gray-700 dark:text-gray-300">{detection.description}</span>
|
|
199
|
+
<span
|
|
200
|
+
className={`rounded px-1 text-[10px] font-medium uppercase ${
|
|
201
|
+
detection.beta
|
|
202
|
+
? "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300"
|
|
203
|
+
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300"
|
|
204
|
+
}`}
|
|
205
|
+
>
|
|
206
|
+
{detection.beta ? "beta" : "reliable"}
|
|
207
|
+
</span>
|
|
208
|
+
<span className="ml-auto text-xs tabular-nums text-gray-400">
|
|
209
|
+
{Math.round(detection.confidence * 100)}% confidence
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function Metric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
|
|
216
|
+
return (
|
|
217
|
+
<div className="text-center">
|
|
218
|
+
<div className="flex items-center justify-center gap-1 text-xs text-gray-500">
|
|
219
|
+
<Icon className="h-3 w-3" />
|
|
220
|
+
{label}
|
|
221
|
+
</div>
|
|
222
|
+
<div className="mt-0.5 text-sm font-semibold tabular-nums">{value}</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const SPAN_ICON: Record<string, LucideIcon> = {
|
|
228
|
+
agent: Bot,
|
|
229
|
+
llm_call: BrainCircuit,
|
|
230
|
+
tool_call: Wrench,
|
|
231
|
+
step: ArrowRight,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function SpanTypeIcon({ type }: { type: string }) {
|
|
235
|
+
const Icon = SPAN_ICON[type] || ArrowRight;
|
|
236
|
+
return <Icon className="h-4 w-4 shrink-0 text-splyntra-500" />;
|
|
237
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { Component, ErrorInfo, ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
fallback?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface State {
|
|
12
|
+
hasError: boolean;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
17
|
+
constructor(props: Props) {
|
|
18
|
+
super(props);
|
|
19
|
+
this.state = { hasError: false, error: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static getDerivedStateFromError(error: Error): State {
|
|
23
|
+
return { hasError: true, error };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
27
|
+
console.error("[Splyntra] Uncaught error:", error, errorInfo);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render() {
|
|
31
|
+
if (this.state.hasError) {
|
|
32
|
+
if (this.props.fallback) return this.props.fallback;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col items-center justify-center p-8 text-center">
|
|
36
|
+
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
|
37
|
+
<span className="text-red-600 text-xl">!</span>
|
|
38
|
+
</div>
|
|
39
|
+
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
40
|
+
Something went wrong
|
|
41
|
+
</h2>
|
|
42
|
+
<p className="text-sm text-gray-500 mb-4 max-w-md">
|
|
43
|
+
An unexpected error occurred. Please try again or contact support.
|
|
44
|
+
</p>
|
|
45
|
+
<button
|
|
46
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
47
|
+
className="px-4 py-2 bg-splyntra-500 text-white rounded-md text-sm hover:bg-splyntra-600 transition-colors"
|
|
48
|
+
>
|
|
49
|
+
Try again
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.props.children;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
export function Skeleton({ className = "" }: { className?: string }) {
|
|
3
|
+
return (
|
|
4
|
+
<div
|
|
5
|
+
className={`animate-pulse bg-gray-200 dark:bg-gray-800 rounded ${className}`}
|
|
6
|
+
/>
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TableSkeleton({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="bg-white dark:bg-gray-900 rounded-lg border overflow-hidden">
|
|
13
|
+
<div className="border-b bg-gray-50 dark:bg-gray-800 px-4 py-3">
|
|
14
|
+
<div className="flex gap-4">
|
|
15
|
+
{Array.from({ length: cols }).map((_, i) => (
|
|
16
|
+
<Skeleton key={i} className="h-4 w-20" />
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<div className="divide-y">
|
|
21
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
22
|
+
<div key={i} className="px-4 py-3 flex gap-4">
|
|
23
|
+
{Array.from({ length: cols }).map((_, j) => (
|
|
24
|
+
<Skeleton key={j} className="h-4 w-16" />
|
|
25
|
+
))}
|
|
26
|
+
</div>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CardSkeleton() {
|
|
34
|
+
return (
|
|
35
|
+
<div className="bg-white dark:bg-gray-900 rounded-lg border p-4">
|
|
36
|
+
<Skeleton className="h-3 w-20 mb-2" />
|
|
37
|
+
<Skeleton className="h-7 w-16" />
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared UI primitives — one source of truth for cards, page headers, badges,
|
|
6
|
+
* stat tiles, and empty states so every page looks and behaves consistently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ReactNode } from "react";
|
|
10
|
+
import {
|
|
11
|
+
Shield,
|
|
12
|
+
ShieldAlert,
|
|
13
|
+
ShieldCheck,
|
|
14
|
+
ShieldQuestion,
|
|
15
|
+
CheckCircle2,
|
|
16
|
+
XCircle,
|
|
17
|
+
type LucideIcon,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
|
|
20
|
+
// ─── Severity ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type Severity = "NONE" | "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
|
|
23
|
+
|
|
24
|
+
const SEVERITY_STYLES: Record<Severity, string> = {
|
|
25
|
+
NONE: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300 ring-gray-200 dark:ring-gray-700",
|
|
26
|
+
LOW: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 ring-emerald-200 dark:ring-emerald-900",
|
|
27
|
+
MEDIUM: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300 ring-amber-200 dark:ring-amber-900",
|
|
28
|
+
HIGH: "bg-orange-50 text-orange-700 dark:bg-orange-950/40 dark:text-orange-300 ring-orange-200 dark:ring-orange-900",
|
|
29
|
+
CRITICAL: "bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300 ring-red-200 dark:ring-red-900",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const SEVERITY_ICON: Record<Severity, LucideIcon> = {
|
|
33
|
+
NONE: ShieldCheck,
|
|
34
|
+
LOW: Shield,
|
|
35
|
+
MEDIUM: ShieldQuestion,
|
|
36
|
+
HIGH: ShieldAlert,
|
|
37
|
+
CRITICAL: ShieldAlert,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function severityFromScore(score: number): Severity {
|
|
41
|
+
if (score >= 75) return "CRITICAL";
|
|
42
|
+
if (score >= 50) return "HIGH";
|
|
43
|
+
if (score >= 25) return "MEDIUM";
|
|
44
|
+
if (score > 0) return "LOW";
|
|
45
|
+
return "NONE";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pill showing a risk score + severity, with a shield icon. */
|
|
49
|
+
export function RiskBadge({ score, severity }: { score: number; severity: string }) {
|
|
50
|
+
const sev = (severity as Severity) in SEVERITY_STYLES ? (severity as Severity) : "NONE";
|
|
51
|
+
const Icon = SEVERITY_ICON[sev];
|
|
52
|
+
return (
|
|
53
|
+
<span
|
|
54
|
+
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${SEVERITY_STYLES[sev]}`}
|
|
55
|
+
title={`${sev} risk`}
|
|
56
|
+
>
|
|
57
|
+
<Icon className="h-3.5 w-3.5" />
|
|
58
|
+
Risk {score}
|
|
59
|
+
</span>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Small severity tag (CRITICAL/HIGH/…). */
|
|
64
|
+
export function SeverityBadge({ severity }: { severity: string }) {
|
|
65
|
+
const sev = (severity as Severity) in SEVERITY_STYLES ? (severity as Severity) : "NONE";
|
|
66
|
+
return (
|
|
67
|
+
<span className={`inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wide ring-1 ring-inset ${SEVERITY_STYLES[sev]}`}>
|
|
68
|
+
{sev}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Status ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export function StatusPill({ status }: { status: string }) {
|
|
76
|
+
const ok = status === "ok" || status === "active";
|
|
77
|
+
const Icon = ok ? CheckCircle2 : XCircle;
|
|
78
|
+
return (
|
|
79
|
+
<span
|
|
80
|
+
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${
|
|
81
|
+
ok
|
|
82
|
+
? "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900"
|
|
83
|
+
: "bg-red-50 text-red-700 ring-red-200 dark:bg-red-950/40 dark:text-red-300 dark:ring-red-900"
|
|
84
|
+
}`}
|
|
85
|
+
>
|
|
86
|
+
<Icon className="h-3 w-3" />
|
|
87
|
+
{status}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Layout ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export function PageHeader({
|
|
95
|
+
title,
|
|
96
|
+
subtitle,
|
|
97
|
+
icon: Icon,
|
|
98
|
+
action,
|
|
99
|
+
}: {
|
|
100
|
+
title: string;
|
|
101
|
+
subtitle?: string;
|
|
102
|
+
icon?: LucideIcon;
|
|
103
|
+
action?: ReactNode;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<div className="mb-6 flex items-start justify-between gap-4">
|
|
107
|
+
<div className="flex items-start gap-3">
|
|
108
|
+
{Icon && (
|
|
109
|
+
<div className="mt-0.5 flex h-9 w-9 items-center justify-center rounded-lg bg-splyntra-50 text-splyntra-600 dark:bg-splyntra-900/30 dark:text-splyntra-100">
|
|
110
|
+
<Icon className="h-5 w-5" />
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
<div>
|
|
114
|
+
<h1 className="text-xl font-semibold tracking-tight text-gray-900 dark:text-white">{title}</h1>
|
|
115
|
+
{subtitle && <p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{subtitle}</p>}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
{action}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
124
|
+
return (
|
|
125
|
+
<div className={`rounded-xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900 ${className}`}>
|
|
126
|
+
{children}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function StatCard({
|
|
132
|
+
label,
|
|
133
|
+
value,
|
|
134
|
+
icon: Icon,
|
|
135
|
+
accent = "text-gray-900 dark:text-white",
|
|
136
|
+
}: {
|
|
137
|
+
label: string;
|
|
138
|
+
value: ReactNode;
|
|
139
|
+
icon?: LucideIcon;
|
|
140
|
+
accent?: string;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<Card className="p-4">
|
|
144
|
+
<div className="flex items-center justify-between">
|
|
145
|
+
<span className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</span>
|
|
146
|
+
{Icon && <Icon className="h-4 w-4 text-gray-400" />}
|
|
147
|
+
</div>
|
|
148
|
+
<div className={`mt-2 text-2xl font-semibold tabular-nums ${accent}`}>{value}</div>
|
|
149
|
+
</Card>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function EmptyState({
|
|
154
|
+
icon: Icon,
|
|
155
|
+
title,
|
|
156
|
+
children,
|
|
157
|
+
}: {
|
|
158
|
+
icon: LucideIcon;
|
|
159
|
+
title: string;
|
|
160
|
+
children?: ReactNode;
|
|
161
|
+
}) {
|
|
162
|
+
return (
|
|
163
|
+
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
|
164
|
+
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 text-gray-400 dark:bg-gray-800">
|
|
165
|
+
<Icon className="h-6 w-6" />
|
|
166
|
+
</div>
|
|
167
|
+
<p className="text-base font-medium text-gray-700 dark:text-gray-200">{title}</p>
|
|
168
|
+
{children && <div className="mt-1 max-w-md text-sm text-gray-500">{children}</div>}
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.css" {}
|