feat: Decisions page — aggregated view of all decision points across traces
Adds /dashboard/decisions page with colored type badges, search, filters (by type), sort (newest/oldest/costliest), pagination, and links to parent traces. New /api/decisions endpoint with Prisma queries. Removes 'Soon' badge from sidebar nav.
This commit is contained in:
127
apps/web/src/app/api/decisions/route.ts
Normal file
127
apps/web/src/app/api/decisions/route.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma } from "@agentlens/database";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
|
||||
const type = searchParams.get("type");
|
||||
const search = searchParams.get("search");
|
||||
const sort = searchParams.get("sort") ?? "newest";
|
||||
|
||||
// Validate pagination
|
||||
if (isNaN(page) || page < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid page parameter. Must be a positive integer." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (isNaN(limit) || limit < 1 || limit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid limit parameter. Must be between 1 and 100." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate type
|
||||
const validTypes = [
|
||||
"TOOL_SELECTION",
|
||||
"ROUTING",
|
||||
"RETRY",
|
||||
"ESCALATION",
|
||||
"MEMORY_RETRIEVAL",
|
||||
"PLANNING",
|
||||
"CUSTOM",
|
||||
];
|
||||
if (type && !validTypes.includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid type. Must be one of: ${validTypes.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate sort
|
||||
const validSorts = ["newest", "oldest", "costliest"];
|
||||
if (!validSorts.includes(sort)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid sort. Must be one of: ${validSorts.join(", ")}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.DecisionPointWhereInput = {};
|
||||
if (type) {
|
||||
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
|
||||
}
|
||||
if (search) {
|
||||
where.reasoning = {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
};
|
||||
}
|
||||
|
||||
// Build order by
|
||||
let orderBy: Prisma.DecisionPointOrderByWithRelationInput;
|
||||
switch (sort) {
|
||||
case "oldest":
|
||||
orderBy = { timestamp: "asc" };
|
||||
break;
|
||||
case "costliest":
|
||||
orderBy = { costUsd: "desc" };
|
||||
break;
|
||||
case "newest":
|
||||
default:
|
||||
orderBy = { timestamp: "desc" };
|
||||
break;
|
||||
}
|
||||
|
||||
// Count total
|
||||
const total = await prisma.decisionPoint.count({ where });
|
||||
|
||||
// Pagination
|
||||
const skip = (page - 1) * limit;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// Fetch decisions with parent trace and span
|
||||
const decisions = await prisma.decisionPoint.findMany({
|
||||
where,
|
||||
include: {
|
||||
trace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
span: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
decisions,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error listing decisions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
402
apps/web/src/app/dashboard/decisions/page.tsx
Normal file
402
apps/web/src/app/dashboard/decisions/page.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
DollarSign,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
type DecisionType =
|
||||
| "TOOL_SELECTION"
|
||||
| "ROUTING"
|
||||
| "RETRY"
|
||||
| "ESCALATION"
|
||||
| "MEMORY_RETRIEVAL"
|
||||
| "PLANNING"
|
||||
| "CUSTOM";
|
||||
|
||||
type SortOption = "newest" | "oldest" | "costliest";
|
||||
|
||||
interface Decision {
|
||||
id: string;
|
||||
traceId: string;
|
||||
type: DecisionType;
|
||||
reasoning: string | null;
|
||||
chosen: Record<string, unknown>;
|
||||
alternatives: Record<string, unknown>[];
|
||||
contextSnapshot: Record<string, unknown> | null;
|
||||
durationMs: number | null;
|
||||
costUsd: number | null;
|
||||
parentSpanId: string | null;
|
||||
timestamp: string;
|
||||
trace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
span: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface DecisionsResponse {
|
||||
decisions: Decision[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const typeConfig: Record<
|
||||
DecisionType,
|
||||
{ label: string; bg: string; text: string }
|
||||
> = {
|
||||
TOOL_SELECTION: {
|
||||
label: "Tool Selection",
|
||||
bg: "bg-blue-500/10",
|
||||
text: "text-blue-400",
|
||||
},
|
||||
ROUTING: {
|
||||
label: "Routing",
|
||||
bg: "bg-purple-500/10",
|
||||
text: "text-purple-400",
|
||||
},
|
||||
RETRY: {
|
||||
label: "Retry",
|
||||
bg: "bg-amber-500/10",
|
||||
text: "text-amber-400",
|
||||
},
|
||||
ESCALATION: {
|
||||
label: "Escalation",
|
||||
bg: "bg-red-500/10",
|
||||
text: "text-red-400",
|
||||
},
|
||||
MEMORY_RETRIEVAL: {
|
||||
label: "Memory Retrieval",
|
||||
bg: "bg-cyan-500/10",
|
||||
text: "text-cyan-400",
|
||||
},
|
||||
PLANNING: {
|
||||
label: "Planning",
|
||||
bg: "bg-emerald-500/10",
|
||||
text: "text-emerald-400",
|
||||
},
|
||||
CUSTOM: {
|
||||
label: "Custom",
|
||||
bg: "bg-neutral-500/10",
|
||||
text: "text-neutral-400",
|
||||
},
|
||||
};
|
||||
|
||||
const sortOptions: { value: SortOption; label: string }[] = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "costliest", label: "Most Expensive" },
|
||||
];
|
||||
|
||||
const allTypes: DecisionType[] = [
|
||||
"TOOL_SELECTION",
|
||||
"ROUTING",
|
||||
"RETRY",
|
||||
"ESCALATION",
|
||||
"MEMORY_RETRIEVAL",
|
||||
"PLANNING",
|
||||
"CUSTOM",
|
||||
];
|
||||
|
||||
function extractChosenName(chosen: Record<string, unknown>): string {
|
||||
if (chosen && typeof chosen === "object") {
|
||||
if (typeof chosen.name === "string") return chosen.name;
|
||||
if (typeof chosen.action === "string") return chosen.action;
|
||||
if (typeof chosen.tool === "string") return chosen.tool;
|
||||
// Fallback: first string value
|
||||
for (const val of Object.values(chosen)) {
|
||||
if (typeof val === "string") return val;
|
||||
}
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + "...";
|
||||
}
|
||||
|
||||
export default function DecisionsPage() {
|
||||
const [decisions, setDecisions] = useState<Decision[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<DecisionType | "ALL">("ALL");
|
||||
const [sortFilter, setSortFilter] = useState<SortOption>("newest");
|
||||
|
||||
const fetchDecisions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", currentPage.toString());
|
||||
params.set("limit", "30");
|
||||
|
||||
if (typeFilter !== "ALL") {
|
||||
params.set("type", typeFilter);
|
||||
}
|
||||
if (searchQuery) {
|
||||
params.set("search", searchQuery);
|
||||
}
|
||||
if (sortFilter !== "newest") {
|
||||
params.set("sort", sortFilter);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/decisions?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch decisions: ${res.status}`);
|
||||
}
|
||||
|
||||
const data: DecisionsResponse = await res.json();
|
||||
setDecisions(data.decisions);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.totalPages);
|
||||
} catch (error) {
|
||||
console.error("Error fetching decisions:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, typeFilter, searchQuery, sortFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDecisions();
|
||||
}, [fetchDecisions]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, typeFilter, sortFilter]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-100">Decisions</h1>
|
||||
<p className="text-neutral-400 mt-1">
|
||||
{total} decision point{total !== 1 ? "s" : ""} across all traces
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by reasoning..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-neutral-900 border border-neutral-800 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) =>
|
||||
setTypeFilter(e.target.value as DecisionType | "ALL")
|
||||
}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-3 text-sm text-neutral-100 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
||||
>
|
||||
<option value="ALL">All Types</option>
|
||||
{allTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{typeConfig[t].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Sort */}
|
||||
<select
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value as SortOption)}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-3 text-sm text-neutral-100 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && decisions.length === 0 && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-6 w-24 bg-neutral-800 rounded-md" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-48 bg-neutral-800 rounded-md mb-2" />
|
||||
<div className="h-4 w-96 bg-neutral-800/60 rounded-md" />
|
||||
</div>
|
||||
<div className="h-4 w-16 bg-neutral-800 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && decisions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-20 h-20 rounded-2xl bg-neutral-900 border border-neutral-800 flex items-center justify-center mb-6">
|
||||
<GitBranch className="w-10 h-10 text-neutral-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-neutral-100 mb-2">
|
||||
No decision points yet
|
||||
</h2>
|
||||
<p className="text-neutral-400 max-w-md">
|
||||
Decision points will appear here once your agents start making
|
||||
decisions. Send traces with decision data to see them aggregated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision List */}
|
||||
{decisions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{decisions.map((decision) => (
|
||||
<DecisionCard key={decision.id} decision={decision} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-6 border-t border-neutral-800">
|
||||
<p className="text-sm text-neutral-500">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||
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" />
|
||||
</button>
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() =>
|
||||
handlePageChange(Math.min(totalPages, currentPage + 1))
|
||||
}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionCard({ decision }: { decision: Decision }) {
|
||||
const config = typeConfig[decision.type] || typeConfig.CUSTOM;
|
||||
const chosenName = extractChosenName(
|
||||
decision.chosen as Record<string, unknown>
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Left: Type badge + chosen + reasoning */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium uppercase tracking-wide",
|
||||
config.bg,
|
||||
config.text
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<h3 className="font-semibold text-neutral-100 truncate">
|
||||
{chosenName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{decision.reasoning && (
|
||||
<p className="text-sm text-neutral-400 leading-relaxed">
|
||||
{truncate(decision.reasoning, 120)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatRelativeTime(decision.timestamp)}
|
||||
</span>
|
||||
{decision.alternatives.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
{decision.alternatives.length} alternative
|
||||
{decision.alternatives.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
{decision.costUsd !== null && decision.costUsd > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5" />$
|
||||
{decision.costUsd.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
{decision.span && (
|
||||
<span className="flex items-center gap-1.5 text-neutral-500">
|
||||
<GitBranch className="w-3.5 h-3.5" />
|
||||
{decision.span.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Trace link */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Link
|
||||
href={`/dashboard/traces/${decision.trace.id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-neutral-800/50 border border-neutral-700/50 text-sm text-neutral-300 hover:text-emerald-400 hover:border-emerald-500/30 transition-all"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{decision.trace.name}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
GitBranch,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -22,7 +21,7 @@ interface NavItem {
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Traces", icon: Activity },
|
||||
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch, comingSoon: true },
|
||||
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings, comingSoon: true },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user