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 { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
|
@ -12,18 +12,32 @@ import {
|
||||||
Clock3,
|
Clock3,
|
||||||
Coins,
|
Coins,
|
||||||
Filter,
|
Filter,
|
||||||
|
LineChart,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Search,
|
Search,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
|
import {
|
||||||
|
type AnalyticsRangeDays,
|
||||||
|
ToolAnalyticsPanel,
|
||||||
|
} from "@/components/claude/ToolAnalyticsPanel";
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { 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) {
|
function formatCost(value: number) {
|
||||||
return new Intl.NumberFormat(undefined, {
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
|
@ -40,13 +54,22 @@ function sessionHref(session: ClaudeSession) {
|
||||||
export default function ClaudeCodePage() {
|
export default function ClaudeCodePage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeOnly, setActiveOnly] = useState(false);
|
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({
|
const sessionsQuery = useQuery({
|
||||||
queryKey: ["claude-code", "sessions", activeOnly],
|
queryKey: ["claude-code", "sessions", activeOnly],
|
||||||
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
|
queryFn: () => listClaudeSessions({ activeOnly, limit: 300 }),
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn && selectedTab === "sessions"),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
refetchOnMount: "always",
|
refetchOnMount: "always",
|
||||||
});
|
});
|
||||||
|
|
@ -79,6 +102,23 @@ export default function ClaudeCodePage() {
|
||||||
router.push(sessionHref(session));
|
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 (
|
return (
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
|
|
@ -89,232 +129,269 @@ export default function ClaudeCodePage() {
|
||||||
title="Claude Code"
|
title="Claude Code"
|
||||||
description="Inspect local agent sessions, costs, tools, and conversation history."
|
description="Inspect local agent sessions, costs, tools, and conversation history."
|
||||||
headerActions={
|
headerActions={
|
||||||
<Button
|
selectedTab === "sessions" ? (
|
||||||
type="button"
|
<Button
|
||||||
variant={activeOnly ? "primary" : "outline"}
|
type="button"
|
||||||
onClick={() => setActiveOnly((value) => !value)}
|
variant={activeOnly ? "primary" : "outline"}
|
||||||
>
|
onClick={() => setActiveOnly((value) => !value)}
|
||||||
<Filter className="h-4 w-4" />
|
>
|
||||||
Active only
|
<Filter className="h-4 w-4" />
|
||||||
</Button>
|
Active only
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
contentClassName="space-y-6"
|
contentClassName="space-y-6"
|
||||||
>
|
>
|
||||||
<section className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-sm">
|
<Tabs
|
||||||
<div className="relative border-b border-[color:var(--border)] px-5 py-6 md:px-6">
|
value={selectedTab}
|
||||||
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#06b6d4,#8b5cf6,#22c55e)]" />
|
onValueChange={(value) => {
|
||||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
if (value === "sessions" || value === "analytics") {
|
||||||
<div className="min-w-0">
|
updateCommandCenterUrl(value);
|
||||||
<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" />
|
className="space-y-5"
|
||||||
</span>
|
>
|
||||||
<div>
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<h2 className="font-heading text-xl font-semibold text-[color:var(--text)]">
|
<TabsList aria-label="Claude Code views">
|
||||||
Session command center
|
<TabsTrigger value="sessions">
|
||||||
</h2>
|
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||||
<p className="mt-1 text-sm text-[color:var(--text-muted)]">
|
Sessions
|
||||||
Open a session to read the exact conversation, tool calls, and
|
</TabsTrigger>
|
||||||
thinking trail.
|
<TabsTrigger value="analytics">
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-w-0 grid-cols-2 gap-3 md:grid-cols-4 xl:min-w-[640px]">
|
<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="rounded-xl border border-[color:var(--border)] bg-[color:var(--bg)] p-3">
|
<div className="relative flex-1">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--text-muted)]">
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
|
||||||
Sessions
|
<Input
|
||||||
</p>
|
value={search}
|
||||||
<p className="mt-2 truncate text-xl font-semibold text-[color:var(--text)]">
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
{(stats?.session_count ?? 0).toLocaleString()}
|
placeholder="Search sessions, projects, models, branches..."
|
||||||
</p>
|
className="pl-10"
|
||||||
</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>
|
||||||
|
<p className="text-sm text-[color:var(--text-muted)]">
|
||||||
|
Showing {filteredSessions.length.toLocaleString()} of{" "}
|
||||||
|
{sessions.length.toLocaleString()}
|
||||||
|
</p>
|
||||||
</div>
|
</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="overflow-x-auto">
|
||||||
<div className="relative flex-1">
|
<table className="min-w-full divide-y divide-[color:var(--border)]">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[color:var(--text-muted)]" />
|
<thead className="bg-[color:var(--surface-muted)]">
|
||||||
<Input
|
<tr>
|
||||||
value={search}
|
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
Session
|
||||||
placeholder="Search sessions, projects, models, branches..."
|
</th>
|
||||||
className="pl-10"
|
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||||
/>
|
Model
|
||||||
</div>
|
</th>
|
||||||
<p className="text-sm text-[color:var(--text-muted)]">
|
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||||
Showing {filteredSessions.length.toLocaleString()} of{" "}
|
Usage
|
||||||
{sessions.length.toLocaleString()}
|
</th>
|
||||||
</p>
|
<th className="px-5 py-3 text-left text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||||
</div>
|
Last active
|
||||||
|
</th>
|
||||||
<div className="overflow-x-auto">
|
<th className="px-5 py-3 text-right text-xs font-bold uppercase tracking-[0.16em] text-[color:var(--text-muted)]">
|
||||||
<table className="min-w-full divide-y divide-[color:var(--border)]">
|
Open
|
||||||
<thead className="bg-[color:var(--surface-muted)]">
|
</th>
|
||||||
<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>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
) : sessionsQuery.isError ? (
|
<tbody className="divide-y divide-[color:var(--border)]">
|
||||||
<tr>
|
{sessionsQuery.isLoading ? (
|
||||||
<td colSpan={5} className="px-5 py-16 text-center">
|
Array.from({ length: 5 }).map((_, index) => (
|
||||||
<Bot className="mx-auto h-10 w-10 text-rose-300" />
|
<tr key={index}>
|
||||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
<td colSpan={5} className="px-5 py-4">
|
||||||
Claude Code sessions unavailable
|
<div className="h-16 animate-pulse rounded-xl bg-[color:var(--surface-muted)]" />
|
||||||
</h3>
|
</td>
|
||||||
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
</tr>
|
||||||
The backend could not read local session data. Check the API
|
))
|
||||||
server and try again.
|
) : sessionsQuery.isError ? (
|
||||||
</p>
|
<tr>
|
||||||
</td>
|
<td colSpan={5} className="px-5 py-16 text-center">
|
||||||
</tr>
|
<Bot className="mx-auto h-10 w-10 text-rose-300" />
|
||||||
) : filteredSessions.length === 0 ? (
|
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
||||||
<tr>
|
Claude Code sessions unavailable
|
||||||
<td colSpan={5} className="px-5 py-16 text-center">
|
</h3>
|
||||||
<MessagesSquare className="mx-auto h-10 w-10 text-[color:var(--text-muted)]" />
|
<p className="mx-auto mt-2 max-w-md text-sm text-[color:var(--text-muted)]">
|
||||||
<h3 className="mt-4 text-lg font-semibold text-[color:var(--text)]">
|
The backend could not read local session data. Check
|
||||||
No Claude Code sessions found
|
the API server and try again.
|
||||||
</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)}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[color:var(--text-muted)]">
|
</td>
|
||||||
{session.tokens.total.toLocaleString()} tokens ·{" "}
|
</tr>
|
||||||
{session.message_count.toLocaleString()} turns
|
) : 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>
|
</p>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
<td className="px-5 py-4">
|
) : (
|
||||||
<p className="flex items-center gap-2 text-sm font-semibold text-[color:var(--text)]">
|
filteredSessions.map((session) => (
|
||||||
<Clock3 className="h-4 w-4 text-[color:var(--text-muted)]" />
|
<tr
|
||||||
{formatRelativeTimestamp(session.last_message_at)}
|
key={session.session_id}
|
||||||
</p>
|
tabIndex={0}
|
||||||
<p className="mt-1 text-xs text-[color:var(--text-muted)]">
|
role="link"
|
||||||
{formatTimestamp(session.last_message_at)}
|
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)]"
|
||||||
</p>
|
onClick={() => openSession(session)}
|
||||||
</td>
|
onKeyDown={(event) => {
|
||||||
<td className="px-5 py-4 text-right">
|
if (event.key === "Enter") openSession(session);
|
||||||
<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" />
|
<td className="max-w-[420px] px-5 py-4">
|
||||||
</Link>
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
</td>
|
<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">
|
||||||
</tr>
|
<TerminalSquare className="h-4 w-4" />
|
||||||
))
|
</span>
|
||||||
)}
|
<div className="min-w-0">
|
||||||
</tbody>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</table>
|
<p className="break-words text-sm font-semibold text-[color:var(--text)]">
|
||||||
</div>
|
{session.title ||
|
||||||
</section>
|
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>
|
</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[];
|
models: string[];
|
||||||
tokens: ClaudeSessionTokens;
|
tokens: ClaudeSessionTokens;
|
||||||
cost_usd: number;
|
cost_usd: number;
|
||||||
|
billing_source?: string;
|
||||||
message_count: number;
|
message_count: number;
|
||||||
first_message_at: string | null;
|
first_message_at: string | null;
|
||||||
last_message_at: string | null;
|
last_message_at: string | null;
|
||||||
|
|
@ -47,6 +48,25 @@ export type ClaudeSessionListResponse = {
|
||||||
stats: ClaudeSessionStats;
|
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 = {
|
export type SessionTextBlock = {
|
||||||
text: string;
|
text: string;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
|
|
@ -116,6 +136,25 @@ export async function listClaudeSessions({
|
||||||
return response.data;
|
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(
|
export async function getClaudeSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): Promise<ClaudeSession> {
|
): Promise<ClaudeSession> {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue