feat: Settings page, DELETE traces endpoint, Anthropic SDK, dashboard bug fixes

- Add /dashboard/settings with SDK connection details, data stats, purge
- Add DELETE /api/traces/[id] with cascade deletion
- Add Anthropic integration (wrap_anthropic) for Python SDK
- Fix missing root duration (totalDuration -> durationMs mapping)
- Fix truncated JSON in decision tree nodes (extract readable labels)
- Fix hardcoded 128K maxTokens in token gauge (model-aware context windows)
- Enable Settings nav item in sidebar
This commit is contained in:
Vectry
2026-02-10 02:35:50 +00:00
parent 4f7719eace
commit 0149e0a6f4
8 changed files with 1125 additions and 9 deletions

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST() {
try {
await prisma.$transaction([
prisma.event.deleteMany(),
prisma.decisionPoint.deleteMany(),
prisma.span.deleteMany(),
prisma.trace.deleteMany(),
]);
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error purging data:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
await Promise.all([
prisma.trace.count(),
prisma.span.count(),
prisma.decisionPoint.count(),
prisma.event.count(),
]);
return NextResponse.json(
{ totalTraces, totalSpans, totalDecisions, totalEvents },
{ status: 200 }
);
} catch (error) {
console.error("Error fetching stats:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,10 +1,26 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
type RouteParams = { params: Promise<{ id: string }> };
function extractActionLabel(value: unknown): string {
if (typeof value === "string") return value;
if (value && typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
if (typeof obj.name === "string") return obj.name;
if (typeof obj.action === "string") return obj.action;
if (typeof obj.tool === "string") return obj.tool;
for (const v of Object.values(obj)) {
if (typeof v === "string") return v;
}
}
return JSON.stringify(value);
}
// GET /api/traces/[id] — Get single trace with all relations
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
_request: NextRequest,
{ params }: RouteParams
) {
try {
const { id } = await params;
@@ -41,11 +57,13 @@ export async function GET(
// Transform data to match frontend expectations
const transformedTrace = {
...trace,
durationMs: trace.totalDuration,
costUsd: trace.totalCost,
decisionPoints: trace.decisionPoints.map((dp) => ({
id: dp.id,
type: dp.type,
chosenAction: typeof dp.chosen === "string" ? dp.chosen : JSON.stringify(dp.chosen),
alternatives: dp.alternatives.map((alt) => (typeof alt === "string" ? alt : JSON.stringify(alt))),
chosenAction: extractActionLabel(dp.chosen),
alternatives: dp.alternatives.map((alt) => extractActionLabel(alt)),
reasoning: dp.reasoning,
contextSnapshot: dp.contextSnapshot as Record<string, unknown> | null,
confidence: null, // Not in schema, default to null
@@ -81,3 +99,35 @@ export async function GET(
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// DELETE /api/traces/[id] — Delete a trace and all related data (cascade)
export async function DELETE(
_request: NextRequest,
{ params }: RouteParams
) {
try {
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
select: { id: true },
});
if (!trace) {
return NextResponse.json({ error: "Trace not found" }, { status: 404 });
}
await prisma.trace.delete({
where: { id },
});
return NextResponse.json({ success: true, deleted: id }, { status: 200 });
} catch (error) {
console.error("Error deleting trace:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -22,7 +22,7 @@ interface NavItem {
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Traces", icon: Activity },
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
{ href: "/dashboard/settings", label: "Settings", icon: Settings, comingSoon: true },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
];
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {

View File

@@ -0,0 +1,294 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Settings,
Key,
Globe,
Copy,
Check,
RefreshCw,
Database,
Trash2,
AlertTriangle,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Stats {
totalTraces: number;
totalSpans: number;
totalDecisions: number;
totalEvents: number;
}
export default function SettingsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [isPurging, setIsPurging] = useState(false);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
const fetchStats = useCallback(async () => {
setIsLoadingStats(true);
try {
const res = await fetch("/api/settings/stats", { cache: "no-store" });
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch (error) {
console.error("Failed to fetch stats:", error);
} finally {
setIsLoadingStats(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const copyToClipboard = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
} catch {
console.error("Failed to copy");
}
};
const handlePurgeAll = async () => {
setIsPurging(true);
try {
const res = await fetch("/api/settings/purge", { method: "POST" });
if (res.ok) {
setShowPurgeConfirm(false);
fetchStats();
}
} catch (error) {
console.error("Failed to purge:", error);
} finally {
setIsPurging(false);
}
};
const endpointUrl =
typeof window !== "undefined"
? `${window.location.origin}/api/traces`
: "https://agentlens.vectry.tech/api/traces";
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
<p className="text-neutral-400 mt-1">
Configuration and SDK connection details
</p>
</div>
{/* SDK Connection */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Globe className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">SDK Connection</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
<SettingField
label="Ingest Endpoint"
value={endpointUrl}
copiedField={copiedField}
fieldKey="endpoint"
onCopy={copyToClipboard}
/>
<SettingField
label="API Key"
value="any-value-accepted"
hint="Authentication is not enforced yet. Use any non-empty string as your Bearer token."
copiedField={copiedField}
fieldKey="apikey"
onCopy={copyToClipboard}
/>
<div className="pt-4 border-t border-neutral-800">
<p className="text-xs text-neutral-500 mb-3">Quick start</p>
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
<pre>{`from agentlens import init
init(
api_key="your-api-key",
endpoint="${endpointUrl.replace("/api/traces", "")}",
)`}</pre>
</div>
</div>
</div>
</section>
{/* Data & Storage */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Database className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">Data & Storage</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
{isLoadingStats ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 w-16 bg-neutral-800 rounded mb-2" />
<div className="h-8 w-12 bg-neutral-800 rounded" />
</div>
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Traces" value={stats.totalTraces} />
<StatCard label="Spans" value={stats.totalSpans} />
<StatCard label="Decisions" value={stats.totalDecisions} />
<StatCard label="Events" value={stats.totalEvents} />
</div>
) : (
<p className="text-sm text-neutral-500">
Unable to load statistics
</p>
)}
<div className="pt-4 border-t border-neutral-800 flex items-center justify-between">
<div>
<p className="text-sm text-neutral-300 font-medium">
Purge All Data
</p>
<p className="text-xs text-neutral-500 mt-0.5">
Permanently delete all traces, spans, decisions, and events
</p>
</div>
{showPurgeConfirm ? (
<div className="flex items-center gap-2">
<button
onClick={() => setShowPurgeConfirm(false)}
className="px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
<button
onClick={handlePurgeAll}
disabled={isPurging}
className="flex items-center gap-2 px-4 py-2 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-sm font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors"
>
{isPurging ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<AlertTriangle className="w-4 h-4" />
)}
Confirm Purge
</button>
</div>
) : (
<button
onClick={() => setShowPurgeConfirm(true)}
className="flex items-center gap-2 px-4 py-2 bg-neutral-800 border border-neutral-700 text-neutral-400 rounded-lg text-sm font-medium hover:text-red-400 hover:border-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
Purge
</button>
)}
</div>
</div>
</section>
{/* About */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Settings className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-semibold">About</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-neutral-500">Version</p>
<p className="text-neutral-200 font-medium">0.1.0</p>
</div>
<div>
<p className="text-neutral-500">SDK Package</p>
<p className="text-neutral-200 font-medium">vectry-agentlens</p>
</div>
<div>
<p className="text-neutral-500">Database</p>
<p className="text-neutral-200 font-medium">PostgreSQL</p>
</div>
<div>
<p className="text-neutral-500">License</p>
<p className="text-neutral-200 font-medium">MIT</p>
</div>
</div>
</div>
</section>
</div>
);
}
function SettingField({
label,
value,
hint,
copiedField,
fieldKey,
onCopy,
}: {
label: string;
value: string;
hint?: string;
copiedField: string | null;
fieldKey: string;
onCopy: (text: string, field: string) => void;
}) {
const isCopied = copiedField === fieldKey;
return (
<div>
<label className="text-xs text-neutral-500 font-medium block mb-1.5">
{label}
</label>
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg">
<Key className="w-4 h-4 text-neutral-600 shrink-0" />
<code className="text-sm text-neutral-300 font-mono truncate">
{value}
</code>
</div>
<button
onClick={() => onCopy(value, fieldKey)}
className={cn(
"p-2.5 rounded-lg border transition-all",
isCopied
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200"
)}
>
{isCopied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
{hint && (
<p className="text-xs text-neutral-600 mt-1.5">{hint}</p>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="p-3 bg-neutral-800/50 rounded-lg">
<p className="text-xs text-neutral-500">{label}</p>
<p className="text-xl font-bold text-neutral-100 mt-1">
{value.toLocaleString()}
</p>
</div>
);
}

View File

@@ -461,13 +461,35 @@ function CostBreakdown({
// Section C: Token Usage Gauge
function TokenUsageGauge({ trace }: { trace: Trace }) {
const tokenData = useMemo(() => {
// Try to get total tokens from various sources
const totalTokens =
(trace.metadata?.totalTokens as number | null | undefined) ??
(trace.metadata?.tokenCount as number | null | undefined) ??
null;
const maxTokens = 128000; // Default context window
const modelContextWindows: Record<string, number> = {
"gpt-4": 8192,
"gpt-4-32k": 32768,
"gpt-4-turbo": 128000,
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"gpt-3.5-turbo": 16385,
"claude-3-opus": 200000,
"claude-3-sonnet": 200000,
"claude-3-haiku": 200000,
"claude-3.5-sonnet": 200000,
"claude-4-opus": 200000,
"claude-4-sonnet": 200000,
};
const model = (trace.metadata?.model as string | undefined) ?? "";
const modelLower = model.toLowerCase();
let maxTokens = 128000;
for (const [prefix, ctx] of Object.entries(modelContextWindows)) {
if (modelLower.startsWith(prefix)) {
maxTokens = ctx;
break;
}
}
return {
totalTokens,