Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/favicon.ico
Binary file not shown.
Binary file modified public/logo192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions src/components/inspect/tool-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { queryOptions } from '@tanstack/react-query'
import { createServerFn } from '@tanstack/react-start'
import { LRUCache } from 'lru-cache'
import { queryKeys, STALE_TELEMETRY_MS } from '#/lib/query-keys'
import {
getActiveProviderId,
getToolDetail,
listToolRecentCalls,
type ToolCallSample,
type ToolDetail,
} from '#/lib/telemetry'

const detailCache = new LRUCache<string, ToolDetail>({ max: 500, ttl: 5 * 60_000 })
const recentCache = new LRUCache<string, ToolCallSample[]>({ max: 500, ttl: 2 * 60_000 })

const toolNameValidator = (input: unknown): string => {
if (typeof input !== 'string' || !input) throw new Error('expected tool name')
return input
}

const fetchDetail = createServerFn({ method: 'GET' })
.inputValidator(toolNameValidator)
.handler(async ({ data }) => {
const key = `${getActiveProviderId()}:${data}`
const cached = detailCache.get(key)
if (cached) return cached
const result = await getToolDetail(data)
if (result) detailCache.set(key, result)
return result
})

const fetchRecent = createServerFn({ method: 'GET' })
.inputValidator(toolNameValidator)
.handler(async ({ data }) => {
const key = `${getActiveProviderId()}:${data}`
const cached = recentCache.get(key)
if (cached) return cached
const result = await listToolRecentCalls(data, { limit: 50 })
recentCache.set(key, result)
return result
})

export const toolDetailQuery = (name: string) =>
queryOptions({
queryKey: queryKeys.tools.detail(name),
queryFn: () => fetchDetail({ data: name }),
staleTime: STALE_TELEMETRY_MS,
})

export const toolRecentCallsQuery = (name: string) =>
queryOptions({
queryKey: queryKeys.tools.recent(name),
queryFn: () => fetchRecent({ data: name }),
staleTime: STALE_TELEMETRY_MS,
})
193 changes: 193 additions & 0 deletions src/components/inspect/tool-drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { IconInfoCircle, IconX } from '@tabler/icons-react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { RelativeTime } from '#/components/relative-time'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '#/components/ui/sheet'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '#/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '#/components/ui/tooltip'
import { formatDuration, formatPercent, formatTokens, truncateId } from '#/lib/format'
import type { ToolCallSample, ToolDetail } from '#/lib/telemetry'
import { toolDetailQuery, toolRecentCallsQuery } from './tool-data'

const CHARS_PER_TOKEN = 4

interface Props {
toolName: string | null
onClose: () => void
}

export function ToolInspectDrawer({ toolName, onClose }: Props) {
const open = toolName !== null
const name = toolName ?? ''
const { data: detail, isLoading: detailLoading } = useQuery({
...toolDetailQuery(name),
enabled: open,
})
const { data: recent, isLoading: recentLoading } = useQuery({
...toolRecentCallsQuery(name),
enabled: open,
})

return (
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
<SheetContent
side="right"
showCloseButton={false}
className="w-full gap-0 bg-background p-0 text-foreground data-[side=right]:sm:max-w-2xl"
>
<header className="flex items-center justify-between gap-3 border-b px-4 py-2">
<div className="flex min-w-0 items-center gap-2">
<SheetTitle className="truncate text-sm font-medium">{name}</SheetTitle>
<SheetDescription className="sr-only">Tool detail</SheetDescription>
</div>
<SheetClose asChild>
<Button variant="ghost" size="icon-sm" aria-label="Close">
<IconX />
</Button>
</SheetClose>
</header>

<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<StatsGrid detail={detail ?? null} loading={detailLoading} />
<RecentCallsSection rows={recent ?? []} loading={recentLoading} />
</div>
</SheetContent>
</Sheet>
)
}

function StatsGrid({ detail, loading }: { detail: ToolDetail | null; loading: boolean }) {
if (loading && !detail) {
return <div className="px-4 py-6 text-xs text-muted-foreground">Loading…</div>
}
if (!detail) {
return <div className="px-4 py-6 text-xs text-muted-foreground">No calls observed.</div>
}
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-3 border-b px-4 py-4 sm:grid-cols-4">
<Stat label="Calls" value={detail.calls.toLocaleString()} hint="Tool invocations in this window." />
<Stat
label="Errors"
hint="Failed invocations. Target: < 1% error rate."
value={
<span className="flex items-baseline gap-1.5">
<span className="tabular-nums">{detail.errors.toLocaleString()}</span>
{detail.errors > 0 && (
<Badge variant="destructive" className="px-1 text-[10px]">
{formatPercent(detail.errorRate, 1)}
</Badge>
)}
</span>
}
/>
<Stat label="p50 latency" hint="Median wall-clock duration. Target: < 1s." value={formatDuration(detail.p50Ms)} />
<Stat label="p95 latency" hint="95th percentile duration. Target: < 5s." value={formatDuration(detail.p95Ms)} />
<Stat
label="Avg tokens"
hint="Avg result size. Target: < 500 tokens."
value={<TokensFromChars chars={detail.avgChars} />}
/>
<Stat
label="p95 tokens"
hint="95th percentile result size. Target: < 2k tokens."
value={<TokensFromChars chars={detail.p95Chars} />}
/>
<Stat
label="Max tokens"
hint="Largest single result observed."
value={<TokensFromChars chars={detail.maxChars} />}
/>
<Stat
label="Last seen"
hint="Most recent invocation."
value={<RelativeTime ts={detail.lastSeenMs} className="tabular-nums" />}
/>
</div>
)
}

function Stat({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) {
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{label}
{hint && (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label={`About ${label}`} className="cursor-help">
<IconInfoCircle className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>{hint}</TooltipContent>
</Tooltip>
)}
</span>
<span className="text-sm tabular-nums">{value}</span>
</div>
)
}

function TokensFromChars({ chars }: { chars: number }) {
if (!chars) return <span className="text-muted-foreground">—</span>
const tokens = Math.ceil(chars / CHARS_PER_TOKEN)
return (
<span title={`${chars.toLocaleString()} chars · ≈${tokens.toLocaleString()} tokens`}>
{formatTokens(tokens)}
<span className="text-muted-foreground"> tok</span>
</span>
)
}

function RecentCallsSection({ rows, loading }: { rows: ToolCallSample[]; loading: boolean }) {
return (
<section className="flex min-h-0 flex-col px-4 py-4">
<h3 className="mb-2 text-xs font-medium text-muted-foreground">Recent calls</h3>
{loading && rows.length === 0 ? (
<div className="py-4 text-xs text-muted-foreground">Loading…</div>
) : rows.length === 0 ? (
<div className="py-4 text-xs text-muted-foreground">No recent calls.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Trace</TableHead>
<TableHead>When</TableHead>
<TableHead className="text-right tabular-nums">Duration</TableHead>
<TableHead className="w-12 text-right">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.traceId}>
<TableCell>
<Link
to="."
search={((prev: Record<string, unknown>) => ({ ...prev, trace: r.traceId })) as unknown as never}
className="font-mono text-xs text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{truncateId(r.traceId)}
</Link>
</TableCell>
<TableCell>
<RelativeTime ts={r.startedAtMs} className="tabular-nums text-muted-foreground" />
</TableCell>
<TableCell className="text-right tabular-nums">{formatDuration(r.durationMs)}</TableCell>
<TableCell className="text-right">
{r.hasError ? (
<Badge variant="destructive" className="px-1 text-[10px]">
Error
</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
)
}
4 changes: 3 additions & 1 deletion src/components/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ const MODES = [
] as const

const COLORS: { value: ColorTheme; label: string; dot: string }[] = [
{ value: 'loupe', label: 'Loupe', dot: 'oklch(0.65 0.27 295)' },
{ value: 'tremor', label: 'Tremor', dot: 'oklch(0.623 0.214 259.815)' },
{ value: 'neutral', label: 'Neutral', dot: 'oklch(0.205 0 0)' },
{ value: 'spotify', label: 'Spotify', dot: 'oklch(0.67 0.17 153.85)' },
{ value: 'vscode', label: 'VS Code', dot: 'oklch(0.71 0.15 239.07)' },
]

const FONTS: { value: AppFont; label: string; family: string }[] = [
Expand Down Expand Up @@ -239,6 +240,7 @@ function ProviderRow() {
qc.invalidateQueries({ queryKey: queryKeys.traces.all() }),
qc.invalidateQueries({ queryKey: queryKeys.home.all() }),
qc.invalidateQueries({ queryKey: queryKeys.inbox.all() }),
qc.invalidateQueries({ queryKey: queryKeys.tools.all() }),
])
},
})
Expand Down
13 changes: 13 additions & 0 deletions src/components/tool-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Link } from '@tanstack/react-router'

export function ToolLink({ name, className }: { name: string; className?: string }) {
return (
<Link
to="."
search={((prev: Record<string, unknown>) => ({ ...prev, tool: name })) as unknown as never}
className={className ?? 'text-sm font-medium underline-offset-4 decoration-muted-foreground/40 hover:underline'}
>
{name}
</Link>
)
}
13 changes: 4 additions & 9 deletions src/hooks/use-app-theme.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useEffect, useState } from 'react'

const COLOR_THEMES = ['neutral', 'spotify', 'vscode'] as const
const COLOR_THEMES = ['loupe', 'tremor', 'neutral', 'spotify'] as const
const APP_FONTS = ['pretendard', 'inter'] as const

export type ColorTheme = (typeof COLOR_THEMES)[number]
export type AppFont = (typeof APP_FONTS)[number]

const COLOR_STORAGE_KEY = 'color-theme'
const FONT_STORAGE_KEY = 'app-font'
const DEFAULT_COLOR: ColorTheme = 'neutral'
const DEFAULT_COLOR: ColorTheme = 'loupe'
const DEFAULT_FONT: AppFont = 'pretendard'

function isColorTheme(value: string | undefined): value is ColorTheme {
Expand All @@ -32,13 +32,8 @@ export function useAppTheme() {
const setColorTheme = (next: ColorTheme) => {
setColorThemeState(next)
const root = document.documentElement
if (next === DEFAULT_COLOR) {
delete root.dataset.theme
localStorage.removeItem(COLOR_STORAGE_KEY)
} else {
root.dataset.theme = next
localStorage.setItem(COLOR_STORAGE_KEY, next)
}
root.dataset.theme = next
localStorage.setItem(COLOR_STORAGE_KEY, next)
}

const setFont = (next: AppFont) => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const queryKeys = {
mcp: {
all: () => ['mcp'] as const,
},
tools: {
all: () => ['tools'] as const,
catalog: (range: string) => ['tools', 'catalog', { range }] as const,
detail: (name: string) => ['tools', 'detail', name] as const,
recent: (name: string) => ['tools', 'recent', name] as const,
},
providers: {
all: () => ['providers'] as const,
},
Expand Down
Loading