feat: add command palette, accessibility, scroll animations, demo workspace, and keyboard navigation
- COMP-139: Command palette for quick navigation - COMP-140: Accessibility improvements - COMP-141: Scroll animations with animate-on-scroll component - COMP-143: Demo workspace with seed data and demo banner - COMP-145: Keyboard navigation and shortcuts help Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.1.0",
|
||||
|
||||
33
apps/web/src/app/api/demo/seed/route.ts
Normal file
33
apps/web/src/app/api/demo/seed/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { seedDemoData } from "@/lib/demo-data";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { demoSeeded: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (user.demoSeeded) {
|
||||
return NextResponse.json({ error: "Demo data already seeded" }, { status: 409 });
|
||||
}
|
||||
|
||||
await seedDemoData(session.user.id);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error seeding demo data:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
@@ -38,6 +39,21 @@ export default function ApiKeysPage() {
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||
|
||||
const handleKeySelect = useCallback(
|
||||
(index: number) => {
|
||||
const key = keys[index];
|
||||
if (key) {
|
||||
setConfirmRevokeId(key.id);
|
||||
}
|
||||
},
|
||||
[keys]
|
||||
);
|
||||
|
||||
const { selectedIndex } = useKeyboardNav({
|
||||
itemCount: keys.length,
|
||||
onSelect: handleKeySelect,
|
||||
});
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -157,6 +173,7 @@ export default function ApiKeysPage() {
|
||||
onClick={() =>
|
||||
copyToClipboard(newlyCreatedKey.key, "new-key")
|
||||
}
|
||||
aria-label="Copy API key to clipboard"
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-all shrink-0",
|
||||
copiedField === "new-key"
|
||||
@@ -196,10 +213,11 @@ export default function ApiKeysPage() {
|
||||
<h2 className="text-sm font-semibold">Create New API Key</h2>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 font-medium block mb-1.5">
|
||||
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">
|
||||
Key Name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
@@ -271,10 +289,16 @@ export default function ApiKeysPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-800">
|
||||
{keys.map((apiKey) => (
|
||||
{keys.map((apiKey, index) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="flex items-center gap-4 px-6 py-4 group"
|
||||
data-keyboard-index={index}
|
||||
className={cn(
|
||||
"flex items-center gap-4 px-6 py-4 group transition-colors",
|
||||
index === selectedIndex
|
||||
? "bg-emerald-500/5 ring-1 ring-inset ring-emerald-500/20"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0">
|
||||
<Key className="w-4 h-4 text-neutral-500" />
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { KeyboardShortcutsHelp, ShortcutsHint } from "@/components/keyboard-shortcuts-help";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -151,6 +153,7 @@ function VerificationBanner() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Dismiss verification banner"
|
||||
className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -165,6 +168,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950 flex">
|
||||
<CommandPalette />
|
||||
<KeyboardShortcutsHelp />
|
||||
<ShortcutsHint />
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden lg:block w-64 h-screen sticky top-0">
|
||||
<Sidebar />
|
||||
@@ -189,13 +195,14 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<main id="main-content" className="flex-1 min-w-0">
|
||||
<VerificationBanner />
|
||||
{/* Mobile Header */}
|
||||
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open navigation menu"
|
||||
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { Suspense } from "react";
|
||||
import { TraceList } from "@/components/trace-list";
|
||||
import { DemoSeedTrigger } from "@/components/demo-seed-trigger";
|
||||
import { DemoBanner } from "@/components/demo-banner";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface TraceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
durationMs: number | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
isDemo?: boolean;
|
||||
_count: {
|
||||
decisionPoints: number;
|
||||
spans: number;
|
||||
events: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TracesResponse {
|
||||
traces: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
durationMs: number | null;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
_count: {
|
||||
decisionPoints: number;
|
||||
spans: number;
|
||||
events: number;
|
||||
};
|
||||
}>;
|
||||
traces: TraceItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
@@ -55,14 +60,21 @@ async function getTraces(
|
||||
export default async function DashboardPage() {
|
||||
const data = await getTraces(50, 1);
|
||||
|
||||
const hasTraces = data.traces.length > 0;
|
||||
const allTracesAreDemo =
|
||||
hasTraces && data.traces.every((t) => t.isDemo === true);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}>
|
||||
<TraceList
|
||||
initialTraces={data.traces}
|
||||
initialTotal={data.total}
|
||||
initialTotalPages={data.totalPages}
|
||||
initialPage={data.page}
|
||||
/>
|
||||
</Suspense>
|
||||
<DemoSeedTrigger hasTraces={hasTraces}>
|
||||
{allTracesAreDemo && <DemoBanner allTracesAreDemo={allTracesAreDemo} />}
|
||||
<Suspense fallback={<div className="p-8 text-zinc-400">Loading traces...</div>}>
|
||||
<TraceList
|
||||
initialTraces={data.traces}
|
||||
initialTotal={data.total}
|
||||
initialTotalPages={data.totalPages}
|
||||
initialPage={data.page}
|
||||
/>
|
||||
</Suspense>
|
||||
</DemoSeedTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,3 +36,39 @@
|
||||
--font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
[data-animate="hidden"] {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-animate="visible"] {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-animate="hidden"][style*="animation-delay"] {
|
||||
transition-delay: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-animate="hidden"] {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} bg-neutral-950 text-neutral-100 antialiased`}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[200] focus:px-4 focus:py-2 focus:rounded-lg focus:bg-emerald-500 focus:text-neutral-950 focus:font-semibold focus:text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-neutral-950"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
Clipboard,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { AnimateOnScroll } from "@/components/animate-on-scroll";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-950">
|
||||
<main id="main-content" className="min-h-screen bg-neutral-950">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -161,6 +162,7 @@ export default function HomePage() {
|
||||
{/* Features Section */}
|
||||
<section className="py-24 border-b border-neutral-800/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold mb-4">
|
||||
Everything you need to understand your agents
|
||||
@@ -169,9 +171,11 @@ export default function HomePage() {
|
||||
From decision trees to cost intelligence, get complete visibility into how your AI systems operate
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{/* Feature 1: Decision Trees */}
|
||||
<AnimateOnScroll delay={0}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<GitBranch className="w-7 h-7 text-emerald-400" />
|
||||
@@ -181,8 +185,10 @@ export default function HomePage() {
|
||||
Visualize the complete reasoning behind every agent choice. See the branching logic, alternatives considered, and the path chosen.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Feature 2: Context Awareness */}
|
||||
<AnimateOnScroll delay={100}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<Brain className="w-7 h-7 text-emerald-400" />
|
||||
@@ -192,8 +198,10 @@ export default function HomePage() {
|
||||
Monitor context window utilization in real-time. Track what's being fed into your agents and what's being left behind.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Feature 3: Cost Intelligence */}
|
||||
<AnimateOnScroll delay={200}>
|
||||
<div className="group p-8 rounded-2xl border border-neutral-800/50 bg-gradient-to-b from-neutral-900/50 to-transparent hover:border-emerald-500/30 transition-all duration-300">
|
||||
<div className="w-14 h-14 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300">
|
||||
<DollarSign className="w-7 h-7 text-emerald-400" />
|
||||
@@ -203,6 +211,7 @@ export default function HomePage() {
|
||||
Track spending per decision, per agent, per trace. Get granular insights into where every dollar goes in your AI operations.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -211,6 +220,7 @@ export default function HomePage() {
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_60%_40%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Zap className="w-4 h-4" />
|
||||
@@ -223,9 +233,11 @@ export default function HomePage() {
|
||||
Go from zero to full agent observability in under five minutes
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{/* Step 1: Install */}
|
||||
<AnimateOnScroll delay={0}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -243,8 +255,10 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">pip install vectry-agentlens</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Step 2: Instrument */}
|
||||
<AnimateOnScroll delay={100}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -264,8 +278,10 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">wrap_openai()</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Step 3: Observe */}
|
||||
<AnimateOnScroll delay={200}>
|
||||
<div className="relative p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30">
|
||||
<div className="absolute -top-4 left-8">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500 text-neutral-950 text-sm font-bold shadow-lg shadow-emerald-500/25">
|
||||
@@ -283,6 +299,7 @@ export default function HomePage() {
|
||||
<code className="text-sm font-mono text-emerald-400">agentlens.vectry.tech</code>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
|
||||
{/* Connecting arrows decoration */}
|
||||
@@ -302,6 +319,7 @@ export default function HomePage() {
|
||||
<section className="py-24 border-b border-neutral-800/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-start">
|
||||
<AnimateOnScroll>
|
||||
<div className="lg:sticky lg:top-8">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Cpu className="w-4 h-4" />
|
||||
@@ -331,8 +349,10 @@ export default function HomePage() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
{/* Code Blocks - Two patterns stacked */}
|
||||
<AnimateOnScroll delay={150}>
|
||||
<div className="space-y-6">
|
||||
{/* Decorator Pattern */}
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-800 bg-neutral-900/50 backdrop-blur-sm">
|
||||
@@ -480,6 +500,7 @@ export default function HomePage() {
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -488,6 +509,7 @@ export default function HomePage() {
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_50%_50%_at_50%_50%,rgba(16,185,129,0.03),transparent)]" />
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Link2 className="w-4 h-4" />
|
||||
@@ -500,7 +522,9 @@ export default function HomePage() {
|
||||
First-class support for the most popular AI frameworks. Drop in and start tracing.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<AnimateOnScroll>
|
||||
<div className="grid sm:grid-cols-3 gap-6 max-w-3xl mx-auto">
|
||||
{/* OpenAI */}
|
||||
<div className="group flex flex-col items-center p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 hover:border-emerald-500/20 transition-all duration-300">
|
||||
@@ -538,6 +562,7 @@ export default function HomePage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -545,6 +570,7 @@ export default function HomePage() {
|
||||
<section className="py-24 border-b border-neutral-800/50 relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(16,185,129,0.04),transparent)]" />
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<AnimateOnScroll>
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-neutral-700 bg-neutral-800/30 text-neutral-400 text-sm mb-6">
|
||||
<Shield className="w-4 h-4" />
|
||||
@@ -557,7 +583,9 @@ export default function HomePage() {
|
||||
No hidden fees. Start free, scale as you grow. Every plan includes the full dashboard experience.
|
||||
</p>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
|
||||
<AnimateOnScroll>
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{/* Free Tier */}
|
||||
<div className="relative flex flex-col p-8 rounded-2xl border border-neutral-800/50 bg-neutral-900/30 transition-all duration-300 hover:border-neutral-700">
|
||||
@@ -657,6 +685,7 @@ export default function HomePage() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</AnimateOnScroll>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -689,6 +718,6 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, ReactNode } from "react";
|
||||
|
||||
interface AnimateOnScrollProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function AnimateOnScroll({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
threshold = 0.15,
|
||||
}: AnimateOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
if (prefersReduced) {
|
||||
el.setAttribute("data-animate", "visible");
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.setAttribute("data-animate", "visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-animate="hidden"
|
||||
className={className}
|
||||
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
apps/web/src/components/command-palette.tsx
Normal file
258
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Command } from "cmdk";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Search,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RecentTrace {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recentTraces, setRecentTraces] = useState<RecentTrace[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRecentTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/traces?limit=5", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecentTraces(data.traces ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail -- palette still works for navigation
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRecentTraces();
|
||||
}
|
||||
}, [open, fetchRecentTraces]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function runCommand(command: () => void) {
|
||||
setOpen(false);
|
||||
command();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||
<Command
|
||||
className="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
loop
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4">
|
||||
<Search className="w-4 h-4 text-neutral-500 shrink-0" />
|
||||
<Command.Input
|
||||
placeholder="Search traces, navigate, or run actions..."
|
||||
className="w-full py-4 bg-transparent text-sm text-neutral-100 placeholder-neutral-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||
<Command.Empty className="py-8 text-center text-sm text-neutral-500">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{/* Recent Traces */}
|
||||
{recentTraces.length > 0 && (
|
||||
<Command.Group
|
||||
heading="Recent Traces"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="px-2 py-3 text-xs text-neutral-500">
|
||||
Loading traces...
|
||||
</div>
|
||||
) : (
|
||||
recentTraces.map((trace) => (
|
||||
<Command.Item
|
||||
key={trace.id}
|
||||
value={`trace ${trace.name} ${trace.id}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(`/dashboard/traces/${trace.id}`)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
"text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400"
|
||||
)}
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1 truncate">{trace.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
trace.status === "COMPLETED" &&
|
||||
"bg-emerald-500/10 text-emerald-400",
|
||||
trace.status === "ERROR" &&
|
||||
"bg-red-500/10 text-red-400",
|
||||
trace.status === "RUNNING" &&
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
)}
|
||||
>
|
||||
{trace.status.toLowerCase()}
|
||||
</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
|
||||
</Command.Item>
|
||||
))
|
||||
)}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Command.Group
|
||||
heading="Navigation"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Dashboard Traces"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Dashboard</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Decisions"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/decisions"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Decisions</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="API Keys"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">API Keys</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Settings"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/settings"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Settings</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* Actions */}
|
||||
<Command.Group
|
||||
heading="Actions"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Create New API Key"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">New API Key</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Sign Out Logout"
|
||||
onSelect={() =>
|
||||
runCommand(() => signOut({ callbackUrl: "/" }))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-red-500/10 data-[selected=true]:text-red-400 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Logout</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-[11px] text-neutral-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
esc
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/demo-banner.tsx
Normal file
65
apps/web/src/components/demo-banner.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Beaker, ArrowRight, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DISMISS_KEY = "agentlens-demo-banner-dismissed";
|
||||
|
||||
interface DemoBannerProps {
|
||||
allTracesAreDemo: boolean;
|
||||
}
|
||||
|
||||
export function DemoBanner({ allTracesAreDemo }: DemoBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY) === "true");
|
||||
}, []);
|
||||
|
||||
if (dismissed || !allTracesAreDemo) return null;
|
||||
|
||||
function handleDismiss() {
|
||||
setDismissed(true);
|
||||
localStorage.setItem(DISMISS_KEY, "true");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-6 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4",
|
||||
"flex items-center gap-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 shrink-0">
|
||||
<Beaker className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-emerald-200 font-medium">
|
||||
You are viewing sample data.
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400/60 mt-0.5">
|
||||
Connect your agent to start collecting real traces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/docs/getting-started"
|
||||
className="hidden sm:flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors shrink-0"
|
||||
>
|
||||
View Setup Guide
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss demo banner"
|
||||
className="p-1.5 rounded-lg text-emerald-400/40 hover:text-emerald-400/80 hover:bg-emerald-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DemoSeedTriggerProps {
|
||||
hasTraces: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DemoSeedTrigger({ hasTraces, children }: DemoSeedTriggerProps) {
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTraces || seeding) return;
|
||||
|
||||
async function seedIfNeeded() {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await fetch("/api/demo/seed", { method: "POST" });
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
// Seed failed, continue showing empty state
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
}
|
||||
|
||||
seedIfNeeded();
|
||||
}, [hasTraces, seeding]);
|
||||
|
||||
if (!hasTraces && seeding) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-10 h-10 rounded-xl border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mb-4" />
|
||||
<p className="text-sm text-neutral-400">Setting up your workspace with sample data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ["j"], description: "Move selection down" },
|
||||
{ keys: ["k"], description: "Move selection up" },
|
||||
{ keys: ["Enter"], description: "Open selected item" },
|
||||
{ keys: ["Escape"], description: "Clear selection / go back" },
|
||||
{ keys: ["g", "h"], description: "Go to Dashboard" },
|
||||
{ keys: ["g", "s"], description: "Go to Settings" },
|
||||
{ keys: ["g", "k"], description: "Go to API Keys" },
|
||||
{ keys: ["g", "d"], description: "Go to Decisions" },
|
||||
{ keys: ["Cmd", "K"], description: "Open command palette" },
|
||||
{ keys: ["?"], description: "Show this help" },
|
||||
];
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if ((el as HTMLElement).isContentEditable) return;
|
||||
}
|
||||
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
||||
<h2 className="text-sm font-semibold text-neutral-100">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close shortcuts help"
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[60vh] overflow-y-auto">
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-neutral-800/50"
|
||||
>
|
||||
<span className="text-sm text-neutral-400">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, j) => (
|
||||
<span key={j}>
|
||||
{j > 0 && (
|
||||
<span className="text-neutral-600 text-xs mx-0.5">
|
||||
then
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center justify-center min-w-[24px] px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs font-mono text-neutral-300">
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsHint() {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-30">
|
||||
<span className="text-xs text-neutral-600 flex items-center gap-1.5">
|
||||
Press
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
?
|
||||
</kbd>
|
||||
for shortcuts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
|
||||
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
|
||||
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
|
||||
|
||||
@@ -87,6 +88,7 @@ export function TraceList({
|
||||
initialTotalPages,
|
||||
initialPage,
|
||||
}: TraceListProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [traces, setTraces] = useState<Trace[]>(initialTraces);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
@@ -283,6 +285,19 @@ export function TraceList({
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const { selectedIndex } = useKeyboardNav({
|
||||
itemCount: filteredTraces.length,
|
||||
onSelect: useCallback(
|
||||
(index: number) => {
|
||||
const trace = filteredTraces[index];
|
||||
if (trace) {
|
||||
router.push(`/dashboard/traces/${trace.id}`);
|
||||
}
|
||||
},
|
||||
[filteredTraces, router]
|
||||
),
|
||||
});
|
||||
|
||||
const filterChips: { value: FilterStatus; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
{ value: "RUNNING", label: "Running" },
|
||||
@@ -376,7 +391,9 @@ export function TraceList({
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
|
||||
<label htmlFor="trace-search" className="sr-only">Search traces</label>
|
||||
<input
|
||||
id="trace-search"
|
||||
type="text"
|
||||
placeholder="Search traces..."
|
||||
value={searchQuery}
|
||||
@@ -422,8 +439,9 @@ export function TraceList({
|
||||
{showAdvancedFilters && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<label htmlFor="sort-filter" className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<select
|
||||
id="sort-filter"
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value as SortOption)}
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all"
|
||||
@@ -437,8 +455,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<label htmlFor="date-from" className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<input
|
||||
id="date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
@@ -447,8 +466,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<label htmlFor="date-to" className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<input
|
||||
id="date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
@@ -457,8 +477,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<label htmlFor="tags-filter" className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<input
|
||||
id="tags-filter"
|
||||
type="text"
|
||||
placeholder="e.g., production, critical, api"
|
||||
value={tagsFilter}
|
||||
@@ -473,8 +494,13 @@ export function TraceList({
|
||||
|
||||
{/* Trace List */}
|
||||
<div className="space-y-3">
|
||||
{filteredTraces.map((trace) => (
|
||||
<TraceCard key={trace.id} trace={trace} />
|
||||
{filteredTraces.map((trace, index) => (
|
||||
<TraceCard
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
index={index}
|
||||
isSelected={index === selectedIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -497,6 +523,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||
aria-label="Previous page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
@@ -504,6 +531,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||
aria-label="Next page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
@@ -515,13 +543,29 @@ export function TraceList({
|
||||
);
|
||||
}
|
||||
|
||||
function TraceCard({ trace }: { trace: Trace }) {
|
||||
function TraceCard({
|
||||
trace,
|
||||
index,
|
||||
isSelected,
|
||||
}: {
|
||||
trace: Trace;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const status = statusConfig[trace.status];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/traces/${trace.id}`}>
|
||||
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer">
|
||||
<div
|
||||
data-keyboard-index={index}
|
||||
className={cn(
|
||||
"group p-5 bg-neutral-900 border rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer",
|
||||
isSelected
|
||||
? "border-emerald-500/40 bg-emerald-500/5 ring-1 ring-emerald-500/20"
|
||||
: "border-neutral-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Left: Name and Status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
123
apps/web/src/hooks/use-keyboard-nav.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface UseKeyboardNavOptions {
|
||||
itemCount: number;
|
||||
onSelect: (index: number) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useKeyboardNav({
|
||||
itemCount,
|
||||
onSelect,
|
||||
enabled = true,
|
||||
}: UseKeyboardNavOptions) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const router = useRouter();
|
||||
const gPressedRef = useRef(false);
|
||||
const gTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const resetSelection = useCallback(() => {
|
||||
setSelectedIndex(-1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (isInputFocused()) return;
|
||||
|
||||
if (gPressedRef.current) {
|
||||
gPressedRef.current = false;
|
||||
clearTimeout(gTimerRef.current);
|
||||
|
||||
if (e.key === "h") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
if (e.key === "s") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/settings");
|
||||
return;
|
||||
}
|
||||
if (e.key === "k") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/keys");
|
||||
return;
|
||||
}
|
||||
if (e.key === "d") {
|
||||
e.preventDefault();
|
||||
router.push("/dashboard/decisions");
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
gPressedRef.current = true;
|
||||
gTimerRef.current = setTimeout(() => {
|
||||
gPressedRef.current = false;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "j" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev + 1;
|
||||
return next >= itemCount ? itemCount - 1 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const next = prev - 1;
|
||||
return next < 0 ? 0 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
onSelect(selectedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
clearTimeout(gTimerRef.current);
|
||||
};
|
||||
}, [enabled, itemCount, selectedIndex, onSelect, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0) return;
|
||||
|
||||
const row = document.querySelector(`[data-keyboard-index="${selectedIndex}"]`);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return { selectedIndex, setSelectedIndex, resetSelection };
|
||||
}
|
||||
554
apps/web/src/lib/demo-data.ts
Normal file
554
apps/web/src/lib/demo-data.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Prisma, SpanType } from "@agentlens/database";
|
||||
|
||||
type EventCreate = Prisma.EventCreateWithoutTraceInput;
|
||||
type DecisionCreate = Prisma.DecisionPointCreateWithoutTraceInput;
|
||||
|
||||
interface DemoSpan {
|
||||
id: string;
|
||||
name: string;
|
||||
type: SpanType;
|
||||
status: "RUNNING" | "COMPLETED" | "ERROR";
|
||||
parentSpanId?: string;
|
||||
input?: Prisma.InputJsonValue;
|
||||
output?: Prisma.InputJsonValue;
|
||||
tokenCount?: number;
|
||||
costUsd?: number;
|
||||
durationMs?: number;
|
||||
startedAt: Date;
|
||||
endedAt?: Date;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
function daysAgo(days: number, offsetMs = 0): Date {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
d.setMilliseconds(d.getMilliseconds() + offsetMs);
|
||||
return d;
|
||||
}
|
||||
|
||||
function endDate(start: Date, durationMs: number): Date {
|
||||
return new Date(start.getTime() + durationMs);
|
||||
}
|
||||
|
||||
export async function seedDemoData(userId: string) {
|
||||
const traces = [
|
||||
createSimpleChatTrace(userId),
|
||||
createMultiToolAgentTrace(userId),
|
||||
createRagPipelineTrace(userId),
|
||||
createErrorHandlingTrace(userId),
|
||||
createLongRunningWorkflowTrace(userId),
|
||||
createCodeAnalysisTrace(userId),
|
||||
createWebSearchTrace(userId),
|
||||
];
|
||||
|
||||
for (const traceFn of traces) {
|
||||
const { trace, spans, events, decisions } = traceFn;
|
||||
|
||||
await prisma.trace.create({
|
||||
data: {
|
||||
...trace,
|
||||
spans: { create: spans },
|
||||
events: { create: events },
|
||||
decisionPoints: { create: decisions },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { demoSeeded: true },
|
||||
});
|
||||
}
|
||||
|
||||
function createSimpleChatTrace(userId: string) {
|
||||
const start = daysAgo(1);
|
||||
const duration = 1240;
|
||||
const spanId = `demo-span-chat-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Simple Chat Completion",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["openai", "chat"],
|
||||
metadata: { model: "gpt-4o", temperature: 0.7 },
|
||||
totalCost: 0.0032,
|
||||
totalTokens: 245,
|
||||
totalDuration: duration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, duration),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: spanId,
|
||||
name: "chat.completions.create",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { messages: [{ role: "user", content: "Explain quantum computing in simple terms" }] },
|
||||
output: { content: "Quantum computing uses quantum bits (qubits) that can exist in multiple states simultaneously..." },
|
||||
tokenCount: 245,
|
||||
costUsd: 0.0032,
|
||||
durationMs: duration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, duration),
|
||||
metadata: { model: "gpt-4o", provider: "openai" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [] as DecisionCreate[],
|
||||
};
|
||||
}
|
||||
|
||||
function createMultiToolAgentTrace(userId: string) {
|
||||
const start = daysAgo(2);
|
||||
const parentId = `demo-span-agent-${userId.slice(0, 8)}`;
|
||||
const toolIds = [
|
||||
`demo-span-tool1-${userId.slice(0, 8)}`,
|
||||
`demo-span-tool2-${userId.slice(0, 8)}`,
|
||||
`demo-span-tool3-${userId.slice(0, 8)}`,
|
||||
];
|
||||
const llmId = `demo-span-llm-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Multi-Tool Agent Run",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["agent", "tools", "production"],
|
||||
metadata: { agent: "research-assistant", run_id: "demo-run-001" },
|
||||
totalCost: 0.0187,
|
||||
totalTokens: 1823,
|
||||
totalDuration: 8420,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 8420),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: parentId,
|
||||
name: "research-assistant",
|
||||
type: "AGENT" as const,
|
||||
status: "COMPLETED" as const,
|
||||
durationMs: 8420,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 8420),
|
||||
metadata: { max_iterations: 5 },
|
||||
},
|
||||
{
|
||||
id: toolIds[0],
|
||||
name: "web_search",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { query: "latest AI research papers 2026" },
|
||||
output: { results: [{ title: "Scaling Laws for Neural Language Models", url: "https://arxiv.org/..." }] },
|
||||
durationMs: 2100,
|
||||
startedAt: endDate(start, 200),
|
||||
endedAt: endDate(start, 2300),
|
||||
},
|
||||
{
|
||||
id: toolIds[1],
|
||||
name: "document_reader",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { url: "https://arxiv.org/..." },
|
||||
output: { content: "Abstract: We study empirical scaling laws for language model performance..." },
|
||||
durationMs: 1800,
|
||||
startedAt: endDate(start, 2400),
|
||||
endedAt: endDate(start, 4200),
|
||||
},
|
||||
{
|
||||
id: toolIds[2],
|
||||
name: "summarizer",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { text: "Abstract: We study empirical scaling laws..." },
|
||||
output: { summary: "The paper examines how language model performance scales with compute, data, and model size." },
|
||||
durationMs: 1500,
|
||||
startedAt: endDate(start, 4300),
|
||||
endedAt: endDate(start, 5800),
|
||||
},
|
||||
{
|
||||
id: llmId,
|
||||
name: "gpt-4o-synthesis",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: parentId,
|
||||
input: { messages: [{ role: "system", content: "Synthesize research findings" }] },
|
||||
output: { content: "Based on the latest research, AI scaling laws suggest..." },
|
||||
tokenCount: 1823,
|
||||
costUsd: 0.0187,
|
||||
durationMs: 2400,
|
||||
startedAt: endDate(start, 5900),
|
||||
endedAt: endDate(start, 8300),
|
||||
metadata: { model: "gpt-4o" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "TOOL_SELECTION" as const,
|
||||
reasoning: "User asked about latest AI research, need web search to get current information",
|
||||
chosen: { tool: "web_search", args: { query: "latest AI research papers 2026" } },
|
||||
alternatives: [{ tool: "memory_lookup" }, { tool: "knowledge_base" }],
|
||||
parentSpanId: parentId,
|
||||
durationMs: 150,
|
||||
costUsd: 0.001,
|
||||
timestamp: endDate(start, 100),
|
||||
},
|
||||
{
|
||||
type: "ROUTING" as const,
|
||||
reasoning: "Search results contain arxiv links, routing to document reader for full content",
|
||||
chosen: { next_step: "document_reader" },
|
||||
alternatives: [{ next_step: "direct_response" }, { next_step: "ask_clarification" }],
|
||||
parentSpanId: parentId,
|
||||
durationMs: 80,
|
||||
costUsd: 0.0005,
|
||||
timestamp: endDate(start, 2350),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createRagPipelineTrace(userId: string) {
|
||||
const start = daysAgo(3);
|
||||
const retrievalId = `demo-span-retrieval-${userId.slice(0, 8)}`;
|
||||
const embeddingId = `demo-span-embed-${userId.slice(0, 8)}`;
|
||||
const genId = `demo-span-gen-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "RAG Pipeline",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["rag", "retrieval", "embeddings"],
|
||||
metadata: { pipeline: "knowledge-qa", version: "2.1" },
|
||||
totalCost: 0.0091,
|
||||
totalTokens: 892,
|
||||
totalDuration: 4350,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 4350),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: embeddingId,
|
||||
name: "embed_query",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { text: "How does our refund policy work?" },
|
||||
output: { embedding: [0.023, -0.041, 0.089] },
|
||||
tokenCount: 12,
|
||||
costUsd: 0.00001,
|
||||
durationMs: 320,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 320),
|
||||
metadata: { model: "text-embedding-3-small" },
|
||||
},
|
||||
{
|
||||
id: retrievalId,
|
||||
name: "vector_search",
|
||||
type: "MEMORY_OP" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { embedding: [0.023, -0.041, 0.089], top_k: 5 },
|
||||
output: { documents: [{ id: "doc-1", score: 0.92, title: "Refund Policy v3" }] },
|
||||
durationMs: 180,
|
||||
startedAt: endDate(start, 400),
|
||||
endedAt: endDate(start, 580),
|
||||
metadata: { index: "company-docs", results_count: 5 },
|
||||
},
|
||||
{
|
||||
id: genId,
|
||||
name: "generate_answer",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { messages: [{ role: "system", content: "Answer using the provided context" }] },
|
||||
output: { content: "Our refund policy allows returns within 30 days of purchase..." },
|
||||
tokenCount: 880,
|
||||
costUsd: 0.009,
|
||||
durationMs: 3600,
|
||||
startedAt: endDate(start, 650),
|
||||
endedAt: endDate(start, 4250),
|
||||
metadata: { model: "gpt-4o-mini" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "MEMORY_RETRIEVAL" as const,
|
||||
reasoning: "Query about refund policy matched knowledge base with high confidence",
|
||||
chosen: { source: "vector_search", confidence: 0.92 },
|
||||
alternatives: [{ source: "web_search" }, { source: "ask_human" }],
|
||||
durationMs: 50,
|
||||
timestamp: endDate(start, 350),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createErrorHandlingTrace(userId: string) {
|
||||
const start = daysAgo(5);
|
||||
const spanId = `demo-span-err-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Error Handling Example",
|
||||
userId,
|
||||
status: "ERROR" as const,
|
||||
isDemo: true,
|
||||
tags: ["error", "rate-limit"],
|
||||
metadata: { error_type: "RateLimitError", retries: 3 },
|
||||
totalCost: 0.0,
|
||||
totalTokens: 0,
|
||||
totalDuration: 15200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 15200),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: spanId,
|
||||
name: "chat.completions.create",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "ERROR" as const,
|
||||
statusMessage: "RateLimitError: Rate limit exceeded. Retry after 30s.",
|
||||
input: { messages: [{ role: "user", content: "Analyze this dataset" }] },
|
||||
durationMs: 15200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 15200),
|
||||
metadata: { model: "gpt-4o", retry_count: 3 },
|
||||
},
|
||||
],
|
||||
events: [
|
||||
{
|
||||
type: "ERROR" as const,
|
||||
name: "RateLimitError",
|
||||
spanId,
|
||||
metadata: { message: "Rate limit exceeded", status_code: 429 },
|
||||
timestamp: endDate(start, 5000),
|
||||
},
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
name: "Retry attempt 1",
|
||||
spanId,
|
||||
metadata: { attempt: 1, backoff_ms: 2000 },
|
||||
timestamp: endDate(start, 7000),
|
||||
},
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
name: "Retry attempt 2",
|
||||
spanId,
|
||||
metadata: { attempt: 2, backoff_ms: 4000 },
|
||||
timestamp: endDate(start, 11000),
|
||||
},
|
||||
{
|
||||
type: "ERROR" as const,
|
||||
name: "Max retries exceeded",
|
||||
spanId,
|
||||
metadata: { message: "Giving up after 3 retries", final_status: 429 },
|
||||
timestamp: endDate(start, 15200),
|
||||
},
|
||||
],
|
||||
decisions: [
|
||||
{
|
||||
type: "RETRY" as const,
|
||||
reasoning: "Received 429 rate limit error, exponential backoff strategy selected",
|
||||
chosen: { action: "retry", strategy: "exponential_backoff", max_retries: 3 },
|
||||
alternatives: [{ action: "fail_immediately" }, { action: "switch_model" }],
|
||||
durationMs: 20,
|
||||
timestamp: endDate(start, 5100),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createLongRunningWorkflowTrace(userId: string) {
|
||||
const start = daysAgo(6);
|
||||
const totalDuration = 34500;
|
||||
const chainId = `demo-span-chain-${userId.slice(0, 8)}`;
|
||||
const spanPrefix = `demo-span-wf-${userId.slice(0, 8)}`;
|
||||
|
||||
const stepNames = [
|
||||
"data_ingestion",
|
||||
"preprocessing",
|
||||
"feature_extraction",
|
||||
"model_inference",
|
||||
"post_processing",
|
||||
"validation",
|
||||
"output_formatting",
|
||||
];
|
||||
|
||||
const spans: DemoSpan[] = [
|
||||
{
|
||||
id: chainId,
|
||||
name: "data-processing-pipeline",
|
||||
type: "CHAIN",
|
||||
status: "COMPLETED",
|
||||
durationMs: totalDuration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, totalDuration),
|
||||
metadata: { pipeline: "batch-analysis", version: "1.4" },
|
||||
},
|
||||
];
|
||||
|
||||
let elapsed = 200;
|
||||
for (let i = 0; i < stepNames.length; i++) {
|
||||
const stepDuration = 2000 + Math.floor(Math.random() * 5000);
|
||||
spans.push({
|
||||
id: `${spanPrefix}-${i}`,
|
||||
name: stepNames[i],
|
||||
type: i === 3 ? "LLM_CALL" : "CUSTOM",
|
||||
status: "COMPLETED",
|
||||
durationMs: stepDuration,
|
||||
startedAt: endDate(start, elapsed),
|
||||
endedAt: endDate(start, elapsed + stepDuration),
|
||||
metadata: { step: i + 1, total_steps: stepNames.length },
|
||||
});
|
||||
elapsed += stepDuration + 100;
|
||||
}
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Long-Running Workflow",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["pipeline", "batch", "production"],
|
||||
metadata: { pipeline: "batch-analysis", records_processed: 1250 },
|
||||
totalCost: 0.042,
|
||||
totalTokens: 4200,
|
||||
totalDuration: totalDuration,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, totalDuration),
|
||||
},
|
||||
spans: spans.map((s) => ({
|
||||
...s,
|
||||
parentSpanId: s.id === chainId ? undefined : chainId,
|
||||
})),
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "PLANNING" as const,
|
||||
reasoning: "Large dataset detected, selecting batch processing strategy with parallel feature extraction",
|
||||
chosen: { strategy: "batch_parallel", batch_size: 50 },
|
||||
alternatives: [{ strategy: "sequential" }, { strategy: "streaming" }],
|
||||
parentSpanId: chainId,
|
||||
durationMs: 100,
|
||||
timestamp: endDate(start, 100),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createCodeAnalysisTrace(userId: string) {
|
||||
const start = daysAgo(4);
|
||||
const agentId = `demo-span-codeagent-${userId.slice(0, 8)}`;
|
||||
const readId = `demo-span-read-${userId.slice(0, 8)}`;
|
||||
const analyzeId = `demo-span-analyze-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Code Review Agent",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["code-review", "agent"],
|
||||
metadata: { repo: "acme/backend", pr_number: 142 },
|
||||
totalCost: 0.015,
|
||||
totalTokens: 1450,
|
||||
totalDuration: 6200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 6200),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: agentId,
|
||||
name: "code-review-agent",
|
||||
type: "AGENT" as const,
|
||||
status: "COMPLETED" as const,
|
||||
durationMs: 6200,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 6200),
|
||||
},
|
||||
{
|
||||
id: readId,
|
||||
name: "read_diff",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: agentId,
|
||||
input: { pr_number: 142 },
|
||||
output: { files_changed: 5, additions: 120, deletions: 30 },
|
||||
durationMs: 800,
|
||||
startedAt: endDate(start, 100),
|
||||
endedAt: endDate(start, 900),
|
||||
},
|
||||
{
|
||||
id: analyzeId,
|
||||
name: "analyze_code",
|
||||
type: "LLM_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
parentSpanId: agentId,
|
||||
input: { diff: "...", instructions: "Review for bugs and style issues" },
|
||||
output: { review: "Found 2 potential issues: 1) Missing null check on line 45, 2) Unused import" },
|
||||
tokenCount: 1450,
|
||||
costUsd: 0.015,
|
||||
durationMs: 5100,
|
||||
startedAt: endDate(start, 1000),
|
||||
endedAt: endDate(start, 6100),
|
||||
metadata: { model: "gpt-4o" },
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [
|
||||
{
|
||||
type: "TOOL_SELECTION" as const,
|
||||
reasoning: "Need to read PR diff before analyzing code",
|
||||
chosen: { tool: "read_diff", args: { pr_number: 142 } },
|
||||
alternatives: [{ tool: "read_file" }, { tool: "list_files" }],
|
||||
parentSpanId: agentId,
|
||||
durationMs: 60,
|
||||
timestamp: endDate(start, 50),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createWebSearchTrace(userId: string) {
|
||||
const start = daysAgo(0, -3600000);
|
||||
const searchId = `demo-span-websearch-${userId.slice(0, 8)}`;
|
||||
|
||||
return {
|
||||
trace: {
|
||||
name: "Web Search Agent",
|
||||
userId,
|
||||
status: "COMPLETED" as const,
|
||||
isDemo: true,
|
||||
tags: ["search", "web"],
|
||||
metadata: { query: "AgentLens observability" },
|
||||
totalCost: 0.002,
|
||||
totalTokens: 180,
|
||||
totalDuration: 2800,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 2800),
|
||||
},
|
||||
spans: [
|
||||
{
|
||||
id: searchId,
|
||||
name: "web_search",
|
||||
type: "TOOL_CALL" as const,
|
||||
status: "COMPLETED" as const,
|
||||
input: { query: "AgentLens observability platform" },
|
||||
output: { results_count: 10, top_result: "https://agentlens.vectry.tech" },
|
||||
durationMs: 2800,
|
||||
startedAt: start,
|
||||
endedAt: endDate(start, 2800),
|
||||
},
|
||||
],
|
||||
events: [] as EventCreate[],
|
||||
decisions: [] as DecisionCreate[],
|
||||
};
|
||||
}
|
||||
599
package-lock.json
generated
599
package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"ioredis": "^5.9.2",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.1.0",
|
||||
@@ -1345,6 +1346,447 @@
|
||||
"@prisma/debug": "6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
|
||||
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
|
||||
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
@@ -2171,7 +2613,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -2245,6 +2687,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
@@ -2415,6 +2869,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/comma-separated-tokens": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
||||
@@ -2633,6 +3103,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -2811,6 +3287,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
@@ -3861,6 +4346,75 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -4479,6 +5033,49 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@ model User {
|
||||
passwordHash String
|
||||
name String?
|
||||
emailVerified Boolean @default(false)
|
||||
demoSeeded Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -124,7 +125,8 @@ model Trace {
|
||||
tags String[] @default([])
|
||||
metadata Json?
|
||||
|
||||
// Owner — nullable for backward compat with existing unowned traces
|
||||
isDemo Boolean @default(false)
|
||||
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user