feat: add tool analytics functionality and UI components
- Extend ClaudeSession type to include optional billing source. - Introduce ToolAnalyticsResponse type for API response structure. - Implement getToolAnalytics function to fetch tool analytics data. - Create RankedAnalyticsList component for displaying ranked items. - Develop ToolAnalyticsPanel component to manage and display tool analytics. - Add ToolFrequencyChart component to visualize tool call frequency. - Implement loading and empty states for better user experience.
This commit is contained in:
parent
a8e560a586
commit
b782511ee9
|
|
@ -4,7 +4,7 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
|
|
@ -12,18 +12,32 @@ import {
|
|||
Clock3,
|
||||
Coins,
|
||||
Filter,
|
||||
LineChart,
|
||||
MessagesSquare,
|
||||
Search,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/clerk";
|
||||
import {
|
||||
type AnalyticsRangeDays,
|
||||
ToolAnalyticsPanel,
|
||||
} from "@/components/claude/ToolAnalyticsPanel";
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { listClaudeSessions, type ClaudeSession } from "@/lib/api/claude-code";
|
||||
import { formatRelativeTimestamp, formatTimestamp, truncateText } from "@/lib/formatters";
|
||||
import {
|
||||
formatRelativeTimestamp,
|
||||
formatTimestamp,
|
||||
truncateText,
|
||||
} from "@/lib/formatters";
|
||||
|
||||
type ClaudeCodeTab = "sessions" | "analytics";
|
||||
|
||||
const ANALYTICS_DAYS: AnalyticsRangeDays[] = [7, 30, 90];
|
||||
|
||||
function formatCost(value: number) {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
|
|
@ -40,13 +54,22 @@ function sessionHref(session: ClaudeSession) {
|
|||
export default function ClaudeCodePage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeOnly, setActiveOnly] = useState(false);
|
||||
const selectedTab: ClaudeCodeTab =
|
||||
searchParams.get("tab") === "analytics" ? "analytics" : "sessions";
|
||||
const rawDays = Number(searchParams.get("days"));
|
||||
const selectedDays: AnalyticsRangeDays = ANALYTICS_DAYS.includes(
|
||||
rawDays as AnalyticsRangeDays,
|
||||
)
|
||||
? (rawDays as AnalyticsRangeDays)
|
||||
: 30;
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ["claude-code", "sessions", activeOnly],
|
||||
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
|
||||
enabled: Boolean(isSignedIn),
|
||||
enabled: Boolean(isSignedIn && selectedTab === "sessions"),
|
||||
refetchInterval: 30_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
|
@ -79,6 +102,23 @@ export default function ClaudeCodePage() {
|
|||
router.push(sessionHref(session));
|
||||
};
|
||||
|
||||
const updateCommandCenterUrl = (
|
||||
tab: ClaudeCodeTab,
|
||||
days: AnalyticsRangeDays = selectedDays,
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", tab);
|
||||
if (tab === "analytics") {
|
||||
params.set("days", String(days));
|
||||
} else {
|
||||
params.delete("days");
|
||||
}
|
||||
const query = params.toString();
|
||||
router.replace(`/claude-code${query ? `?${query}` : ""}`, {
|
||||
scroll: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardPageLayout
|
||||
signedOut={{
|
||||
|
|
@ -89,232 +129,269 @@ export default function ClaudeCodePage() {
|
|||
title="Claude Code"
|
||||
description="Inspect local agent sessions, costs, tools, and conversation history."
|
||||
headerActions={
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeOnly ? "primary" : "outline"}
|
||||
onClick={() => setActiveOnly((value) => !value)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Active only
|
||||
</Button>
|
||||
selectedTab === "sessions" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeOnly ? "primary" : "outline"}
|
||||
onClick={() => setActiveOnly((value) => !value)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Active only
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
contentClassName="space-y-6"
|
||||
>
|
||||
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
|
||||
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
|
||||
<TerminalSquare className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
|
||||
Session command center
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
|
||||
Open a session to read the exact conversation, tool calls, and
|
||||
thinking trail.
|
||||
</p>
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(value) => {
|
||||
if (value === "sessions" || value === "analytics") {
|
||||
updateCommandCenterUrl(value);
|
||||
}
|
||||
}}
|
||||
className="space-y-5"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<TabsList aria-label="Claude Code views">
|
||||
<TabsTrigger value="sessions">
|
||||
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics">
|
||||
<LineChart className="mr-2 h-3.5 w-3.5" />
|
||||
Tool Analytics
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="sessions" className="mt-0">
|
||||
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
|
||||
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
|
||||
<TerminalSquare className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
|
||||
Session command center
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
|
||||
Open a session to read the exact conversation, tool
|
||||
calls, and thinking trail.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Sessions
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.session_count ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Active
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.active_sessions ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Tokens
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.total_tokens ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Spend
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{formatCost(stats?.total_cost_usd ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Sessions
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.session_count ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Active
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.active_sessions ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Tokens
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{(stats?.total_tokens ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Spend
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
||||
{formatCost(stats?.total_cost_usd ?? 0)}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search sessions, projects, models, branches..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--text-muted)]">
|
||||
Showing {filteredSessions.length.toLocaleString()} of{" "}
|
||||
{sessions.length.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-b border-[color:var(--border)] px-5 py-4 md:flex-row md:items-center md:px-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search sessions, projects, models, branches..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--text-muted)]">
|
||||
Showing {filteredSessions.length.toLocaleString()} of{" "}
|
||||
{sessions.length.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[color:var(--border)]">
|
||||
<thead className="bg-[color:var(--surface-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Session
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Usage
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Last active
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Open
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[color:var(--border)]">
|
||||
{sessionsQuery.isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<tr key={index}>
|
||||
<td colSpan={5} className="px-5 py-4">
|
||||
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
|
||||
</td>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[color:var(--border)]">
|
||||
<thead className="bg-[color:var(--surface-muted)]">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Session
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Model
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Usage
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Last active
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||
Open
|
||||
</th>
|
||||
</tr>
|
||||
))
|
||||
) : sessionsQuery.isError ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-16 text-center">
|
||||
<Bot className="mx-auto h-10 w-10 text-rose-300" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
Claude Code sessions unavailable
|
||||
</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||
The backend could not read local session data. Check the API
|
||||
server and try again.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-16 text-center">
|
||||
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
No Claude Code sessions found
|
||||
</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||
Sessions appear here after Claude Code writes local JSONL history
|
||||
under your configured projects directory.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSessions.map((session) => (
|
||||
<tr
|
||||
key={session.session_id}
|
||||
tabIndex={0}
|
||||
role="link"
|
||||
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
|
||||
onClick={() => openSession(session)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") openSession(session);
|
||||
}}
|
||||
>
|
||||
<td className="max-w-[420px] px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
|
||||
{session.title || truncateText(session.session_id, 20)}
|
||||
</p>
|
||||
{session.is_active ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
|
||||
{session.cwd ?? session.project_dir}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="max-w-[220px] space-y-1">
|
||||
{(session.models.length > 0
|
||||
? session.models
|
||||
: ["Unknown model"]
|
||||
).map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
|
||||
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
|
||||
{formatCost(session.cost_usd)}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[color:var(--border)]">
|
||||
{sessionsQuery.isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<tr key={index}>
|
||||
<td colSpan={5} className="px-5 py-4">
|
||||
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : sessionsQuery.isError ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-16 text-center">
|
||||
<Bot className="mx-auto h-10 w-10 text-rose-300" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
Claude Code sessions unavailable
|
||||
</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||
The backend could not read local session data. Check
|
||||
the API server and try again.
|
||||
</p>
|
||||
<p className="text-xs text-[color:var(--text-muted)]">
|
||||
{session.tokens.total.toLocaleString()} tokens ·{" "}
|
||||
{session.message_count.toLocaleString()} turns
|
||||
</td>
|
||||
</tr>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-16 text-center">
|
||||
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
No Claude Code sessions found
|
||||
</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||
Sessions appear here after Claude Code writes local
|
||||
JSONL history under your configured projects
|
||||
directory.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
|
||||
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
|
||||
{formatRelativeTimestamp(session.last_message_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
|
||||
{formatTimestamp(session.last_message_at)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link
|
||||
href={sessionHref(session)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
aria-label={`Open ${session.title ?? session.session_id}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredSessions.map((session) => (
|
||||
<tr
|
||||
key={session.session_id}
|
||||
tabIndex={0}
|
||||
role="link"
|
||||
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)] focus-visible:bg-[color:var(--surface-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[color:var(--accent)]"
|
||||
onClick={() => openSession(session)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") openSession(session);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<td className="max-w-[420px] px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
|
||||
<TerminalSquare className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
|
||||
{session.title ||
|
||||
truncateText(session.session_id, 20)}
|
||||
</p>
|
||||
{session.is_active ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 break-words text-xs text-[color:var(--text-muted)]">
|
||||
{session.cwd ?? session.project_dir}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="max-w-[220px] space-y-1">
|
||||
{(session.models.length > 0
|
||||
? session.models
|
||||
: ["Unknown model"]
|
||||
).map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="block truncate rounded-full bg-[color:var(--surface-muted)] px-2.5 py-1 font-mono text-xs text-[color:var(--text-muted)]"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex items-center gap-2 font-semibold text-[color:var(--text)]">
|
||||
<Coins className="h-4 w-4 text-[color:var(--text-muted)]" />
|
||||
{formatCost(session.cost_usd)}
|
||||
</p>
|
||||
<p className="text-xs text-[color:var(--text-muted)]">
|
||||
{session.tokens.total.toLocaleString()} tokens ·{" "}
|
||||
{session.message_count.toLocaleString()} turns
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
|
||||
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
|
||||
{formatRelativeTimestamp(session.last_message_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
|
||||
{formatTimestamp(session.last_message_at)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link
|
||||
href={sessionHref(session)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
|
||||
aria-label={`Open ${session.title ?? session.session_id}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="mt-0">
|
||||
<ToolAnalyticsPanel
|
||||
days={selectedDays}
|
||||
enabled={Boolean(isSignedIn && selectedTab === "analytics")}
|
||||
onDaysChange={(days) => updateCommandCenterUrl("analytics", days)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DashboardPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Clipboard, FileCode2, Terminal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type RankedAnalyticsItem = {
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type RankedAnalyticsListProps = {
|
||||
title: string;
|
||||
items: RankedAnalyticsItem[];
|
||||
kind: "file" | "command";
|
||||
};
|
||||
|
||||
async function copyValue(value: string, onCopied: () => void) {
|
||||
if (!value || typeof navigator === "undefined" || !navigator.clipboard)
|
||||
return;
|
||||
await navigator.clipboard.writeText(value);
|
||||
onCopied();
|
||||
}
|
||||
|
||||
export function RankedAnalyticsList({
|
||||
title,
|
||||
items,
|
||||
kind,
|
||||
}: RankedAnalyticsListProps) {
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const Icon = kind === "command" ? Terminal : FileCode2;
|
||||
|
||||
const markCopied = (value: string) => {
|
||||
setCopied(value);
|
||||
window.setTimeout(() => setCopied(null), 1400);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="min-w-0 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm"
|
||||
aria-labelledby={`${title.toLowerCase().replace(/\s+/g, "-")}-heading`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
id={`${title.toLowerCase().replace(/\s+/g, "-")}-heading`}
|
||||
className="truncate text-sm font-semibold text-[color:var(--text)]"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
|
||||
Top {items.length.toLocaleString()} by count
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1",
|
||||
kind === "command"
|
||||
? "bg-amber-500/15 text-amber-300 ring-amber-400/20"
|
||||
: "bg-violet-500/15 text-violet-300 ring-violet-400/20",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-[color:var(--border-strong)] p-5 text-sm text-[color:var(--text-muted)]">
|
||||
No entries in this range.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className="group flex min-w-0 items-center gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] px-3 py-2.5 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-muted)]"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[color:var(--surface-muted)] text-xs font-bold text-[color:var(--text-muted)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-xs text-[color:var(--text)]"
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-[color:var(--accent-soft)] px-2 py-1 text-xs font-semibold text-[color:var(--accent-strong)]">
|
||||
{item.count.toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[color:var(--border)] text-[color:var(--text-muted)] opacity-100 transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
|
||||
aria-label={`Copy ${item.label}`}
|
||||
onClick={() =>
|
||||
copyValue(item.label, () => markCopied(item.label))
|
||||
}
|
||||
>
|
||||
{copied === item.label ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Clipboard className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
RankedAnalyticsList,
|
||||
type RankedAnalyticsItem,
|
||||
} from "@/components/claude/RankedAnalyticsList";
|
||||
import { ToolFrequencyChart } from "@/components/claude/ToolFrequencyChart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getToolAnalytics } from "@/lib/api/claude-code";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AnalyticsRangeDays = 7 | 30 | 90;
|
||||
|
||||
type ToolAnalyticsPanelProps = {
|
||||
days: AnalyticsRangeDays;
|
||||
onDaysChange: (days: AnalyticsRangeDays) => void;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const RANGE_OPTIONS: AnalyticsRangeDays[] = [7, 30, 90];
|
||||
|
||||
function totalToolCalls(toolCounts: Record<string, number>) {
|
||||
return Object.values(toolCounts).reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
function mostUsedTool(toolCounts: Record<string, number>) {
|
||||
const [tool, count] =
|
||||
Object.entries(toolCounts).sort((a, b) => b[1] - a[1])[0] ?? [];
|
||||
return tool && typeof count === "number" ? { tool, count } : null;
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]" />
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-72 animate-pulse rounded-2xl bg-[color:var(--surface-muted)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-10 text-center">
|
||||
<Wrench className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
|
||||
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
No tool analytics yet
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[color:var(--text-muted)]">
|
||||
Tool analytics appear after local Claude Code sessions include assistant
|
||||
tool calls in the selected date range.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolAnalyticsPanel({
|
||||
days,
|
||||
onDaysChange,
|
||||
enabled = true,
|
||||
}: ToolAnalyticsPanelProps) {
|
||||
const analyticsQuery = useQuery({
|
||||
queryKey: ["claude-code", "tool-analytics", days],
|
||||
queryFn: () => getToolAnalytics({ days }),
|
||||
enabled,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const analytics = analyticsQuery.data;
|
||||
const totalCalls = analytics ? totalToolCalls(analytics.tool_counts) : 0;
|
||||
const topTool = useMemo(
|
||||
() => (analytics ? mostUsedTool(analytics.tool_counts) : null),
|
||||
[analytics],
|
||||
);
|
||||
const topFilesRead: RankedAnalyticsItem[] =
|
||||
analytics?.top_files_read.map((item) => ({
|
||||
label: item.path,
|
||||
count: item.count,
|
||||
})) ?? [];
|
||||
const topFilesWritten: RankedAnalyticsItem[] =
|
||||
analytics?.top_files_written.map((item) => ({
|
||||
label: item.path,
|
||||
count: item.count,
|
||||
})) ?? [];
|
||||
const topCommands: RankedAnalyticsItem[] =
|
||||
analytics?.top_commands.map((item) => ({
|
||||
label: item.command,
|
||||
count: item.count,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
|
||||
Tool Analytics
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
|
||||
{analyticsQuery.isFetching && !analyticsQuery.isLoading
|
||||
? "Refreshing selected range..."
|
||||
: `${days.toLocaleString()} day operating window`}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="inline-flex w-full rounded-full border border-[color:var(--border)] bg-[color:var(--bg)] p-1 md:w-auto"
|
||||
aria-label="Select analytics date range"
|
||||
role="group"
|
||||
>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-h-9 flex-1 rounded-full px-4 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] md:flex-none",
|
||||
days === option
|
||||
? "bg-[color:var(--accent)] text-[color:var(--primary-foreground)] shadow-sm"
|
||||
: "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--text)]",
|
||||
)}
|
||||
aria-pressed={days === option}
|
||||
onClick={() => onDaysChange(option)}
|
||||
>
|
||||
{option}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analyticsQuery.isLoading ? <LoadingSkeleton /> : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<div className="rounded-2xl border border-rose-400/30 bg-rose-950/20 p-8 text-center">
|
||||
<AlertTriangle className="mx-auto h-10 w-10 text-rose-300" />
|
||||
<h2 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||
Analytics unavailable
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-[color:var(--text-muted)]">
|
||||
The backend could not aggregate tool analytics for this range.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-6"
|
||||
onClick={() => analyticsQuery.refetch()}
|
||||
disabled={analyticsQuery.isFetching}
|
||||
>
|
||||
{analyticsQuery.isFetching ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="h-4 w-4" />
|
||||
)}
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{analytics && !analyticsQuery.isError && totalCalls === 0 ? (
|
||||
<EmptyState />
|
||||
) : null}
|
||||
|
||||
{analytics && totalCalls > 0 ? (
|
||||
<>
|
||||
<section
|
||||
className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"
|
||||
aria-label="Analytics summary"
|
||||
>
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Tool calls
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[color:var(--text)]">
|
||||
{totalCalls.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Sessions scanned
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[color:var(--text)]">
|
||||
{analytics.session_count.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Most-used tool
|
||||
</p>
|
||||
<p className="mt-2 truncate text-2xl font-semibold text-[color:var(--text)]">
|
||||
{topTool ? topTool.tool : "None"}
|
||||
</p>
|
||||
{topTool ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
|
||||
{topTool.count.toLocaleString()} calls
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
||||
Date range
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-2 text-2xl font-semibold text-[color:var(--text)]">
|
||||
<BarChart3 className="h-5 w-5 text-[color:var(--text-muted)]" />
|
||||
{analytics.date_range_days}d
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ToolFrequencyChart toolCounts={analytics.tool_counts} />
|
||||
|
||||
<div className="grid min-w-0 gap-4 xl:grid-cols-3">
|
||||
<RankedAnalyticsList
|
||||
title="Top Files Read"
|
||||
kind="file"
|
||||
items={topFilesRead}
|
||||
/>
|
||||
<RankedAnalyticsList
|
||||
title="Top Files Written"
|
||||
kind="file"
|
||||
items={topFilesWritten}
|
||||
/>
|
||||
<RankedAnalyticsList
|
||||
title="Top Commands"
|
||||
kind="command"
|
||||
items={topCommands}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { Activity, Terminal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToolFrequencyChartProps = {
|
||||
toolCounts: Record<string, number>;
|
||||
};
|
||||
|
||||
const TOOL_ACCENTS = [
|
||||
"from-cyan-400 to-blue-500",
|
||||
"from-violet-400 to-fuchsia-500",
|
||||
"from-emerald-400 to-teal-500",
|
||||
"from-amber-300 to-orange-500",
|
||||
"from-rose-300 to-pink-500",
|
||||
"from-sky-300 to-indigo-500",
|
||||
];
|
||||
|
||||
export function ToolFrequencyChart({ toolCounts }: ToolFrequencyChartProps) {
|
||||
const entries = Object.entries(toolCounts)
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-[color:var(--border-strong)] bg-[color:var(--surface)] p-8 text-center">
|
||||
<Activity className="mx-auto h-9 w-9 text-[color:var(--text-muted)]" />
|
||||
<h3 className="mt-4 text-base font-semibold text-[color:var(--text)]">
|
||||
No tool calls in this range
|
||||
</h3>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||
Tool activity appears after Claude Code sessions include assistant
|
||||
tool calls.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5 shadow-sm md:p-6"
|
||||
aria-labelledby="tool-frequency-heading"
|
||||
>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2
|
||||
id="tool-frequency-heading"
|
||||
className="font-heading text-lg font-semibold text-[color:var(--text)]"
|
||||
>
|
||||
Tool frequency
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
|
||||
Ranked by call count across the selected window.
|
||||
</p>
|
||||
</div>
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-400/20">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3" role="list" aria-label="Tool call counts">
|
||||
{entries.map(([toolName, count], index) => {
|
||||
const width = `${Math.max((count / maxCount) * 100, 4)}%`;
|
||||
return (
|
||||
<div
|
||||
key={toolName}
|
||||
role="listitem"
|
||||
className="grid gap-2 md:grid-cols-[160px_1fr_80px] md:items-center"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-[color:var(--surface-muted)] text-xs font-bold text-[color:var(--text-muted)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-[color:var(--text)]">
|
||||
{toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-9 rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-1">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-lg bg-gradient-to-r transition-[width] duration-500 motion-reduce:transition-none",
|
||||
TOOL_ACCENTS[index % TOOL_ACCENTS.length],
|
||||
)}
|
||||
style={{ width }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left text-sm font-semibold text-[color:var(--text)] md:text-right">
|
||||
{count.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export type ClaudeSession = {
|
|||
models: string[];
|
||||
tokens: ClaudeSessionTokens;
|
||||
cost_usd: number;
|
||||
billing_source?: string;
|
||||
message_count: number;
|
||||
first_message_at: string | null;
|
||||
last_message_at: string | null;
|
||||
|
|
@ -47,6 +48,25 @@ export type ClaudeSessionListResponse = {
|
|||
stats: ClaudeSessionStats;
|
||||
};
|
||||
|
||||
export type ToolAnalyticsFileEntry = {
|
||||
path: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ToolAnalyticsCommandEntry = {
|
||||
command: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ToolAnalyticsResponse = {
|
||||
tool_counts: Record<string, number>;
|
||||
top_files_read: ToolAnalyticsFileEntry[];
|
||||
top_files_written: ToolAnalyticsFileEntry[];
|
||||
top_commands: ToolAnalyticsCommandEntry[];
|
||||
session_count: number;
|
||||
date_range_days: number;
|
||||
};
|
||||
|
||||
export type SessionTextBlock = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
|
|
@ -116,6 +136,25 @@ export async function listClaudeSessions({
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export type GetToolAnalyticsParams = {
|
||||
days?: 7 | 30 | 90 | number;
|
||||
project?: string;
|
||||
};
|
||||
|
||||
export async function getToolAnalytics({
|
||||
days = 30,
|
||||
project,
|
||||
}: GetToolAnalyticsParams = {}): Promise<ToolAnalyticsResponse> {
|
||||
const params = new URLSearchParams({ days: String(days) });
|
||||
if (project?.trim()) params.set("project", project.trim());
|
||||
|
||||
const response = await customFetch<ApiResponse<ToolAnalyticsResponse>>(
|
||||
`/api/v1/claude-code/analytics/tools?${params.toString()}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getClaudeSession(
|
||||
sessionId: string,
|
||||
): Promise<ClaudeSession> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue