diff --git a/public/favicon.ico b/public/favicon.ico index fb98ad3..b81cc1e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo192.png b/public/logo192.png index 2974a2c..a86752c 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png index c3dc2cf..022f037 100644 Binary files a/public/logo512.png and b/public/logo512.png differ diff --git a/src/components/inspect/tool-data.ts b/src/components/inspect/tool-data.ts new file mode 100644 index 0000000..22d069f --- /dev/null +++ b/src/components/inspect/tool-data.ts @@ -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({ max: 500, ttl: 5 * 60_000 }) +const recentCache = new LRUCache({ 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, + }) diff --git a/src/components/inspect/tool-drawer.tsx b/src/components/inspect/tool-drawer.tsx new file mode 100644 index 0000000..d19b6c3 --- /dev/null +++ b/src/components/inspect/tool-drawer.tsx @@ -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 ( + !next && onClose()}> + +
+
+ {name} + Tool detail +
+ + + +
+ +
+ + +
+
+
+ ) +} + +function StatsGrid({ detail, loading }: { detail: ToolDetail | null; loading: boolean }) { + if (loading && !detail) { + return
Loading…
+ } + if (!detail) { + return
No calls observed.
+ } + return ( +
+ + + {detail.errors.toLocaleString()} + {detail.errors > 0 && ( + + {formatPercent(detail.errorRate, 1)} + + )} + + } + /> + + + } + /> + } + /> + } + /> + } + /> +
+ ) +} + +function Stat({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) { + return ( +
+ + {label} + {hint && ( + + + + + {hint} + + )} + + {value} +
+ ) +} + +function TokensFromChars({ chars }: { chars: number }) { + if (!chars) return + const tokens = Math.ceil(chars / CHARS_PER_TOKEN) + return ( + + {formatTokens(tokens)} + tok + + ) +} + +function RecentCallsSection({ rows, loading }: { rows: ToolCallSample[]; loading: boolean }) { + return ( +
+

Recent calls

+ {loading && rows.length === 0 ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
No recent calls.
+ ) : ( + + + + Trace + When + Duration + Status + + + + {rows.map((r) => ( + + + ) => ({ ...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)} + + + + + + {formatDuration(r.durationMs)} + + {r.hasError ? ( + + Error + + ) : ( + + )} + + + ))} + +
+ )} +
+ ) +} diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 508632b..97f71c8 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -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 }[] = [ @@ -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() }), ]) }, }) diff --git a/src/components/tool-link.tsx b/src/components/tool-link.tsx new file mode 100644 index 0000000..180a03b --- /dev/null +++ b/src/components/tool-link.tsx @@ -0,0 +1,13 @@ +import { Link } from '@tanstack/react-router' + +export function ToolLink({ name, className }: { name: string; className?: string }) { + return ( + ) => ({ ...prev, tool: name })) as unknown as never} + className={className ?? 'text-sm font-medium underline-offset-4 decoration-muted-foreground/40 hover:underline'} + > + {name} + + ) +} diff --git a/src/hooks/use-app-theme.ts b/src/hooks/use-app-theme.ts index e8d94ce..e1fa1d4 100644 --- a/src/hooks/use-app-theme.ts +++ b/src/hooks/use-app-theme.ts @@ -1,6 +1,6 @@ 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] @@ -8,7 +8,7 @@ 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 { @@ -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) => { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 8670bf7..f59cf93 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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, }, diff --git a/src/lib/telemetry/analytics-app-insights.ts b/src/lib/telemetry/analytics-app-insights.ts index 448dae7..7417213 100644 --- a/src/lib/telemetry/analytics-app-insights.ts +++ b/src/lib/telemetry/analytics-app-insights.ts @@ -9,12 +9,17 @@ import type { InventoryObservation, LatencyPoint, RunsPoint, + ToolCallSample, + ToolCatalogRow, + ToolDetail, ToolErrorRow, ToolPayloadRow, TopOpts, WindowOpts, } from './types' +const TOOL_NAME_RE = /^[A-Za-z0-9_./:-]+$/ + export async function fetchToolErrorRates(p: AppInsightsProvider, opts?: TopOpts): Promise { const limit = opts?.limit ?? 5 const q = ` @@ -55,6 +60,119 @@ export async function fetchToolPayloadSizes(p: AppInsightsProvider, opts?: TopOp return rows.map(mapToolPayloadRow) } +export async function fetchAllTools(p: AppInsightsProvider, opts?: TopOpts): Promise { + const limit = opts?.limit ?? 1000 + const q = ` + union dependencies, requests + | where name startswith "execute_tool " + | extend result_len = strlen(tostring(customDimensions["gen_ai.tool.call.result"])) + | extend result_len_nz = iif(result_len > 0, todouble(result_len), real(null)) + | summarize + calls = count(), + errors = countif(success == false), + avg_chars = avg(result_len_nz), + p95_chars = percentile(result_len_nz, 95), + p50_ms = percentile(duration, 50), + p95_ms = percentile(duration, 95), + last_seen = max(timestamp) + by name + | top ${limit} by calls desc + ` + const rows = await p.query(q, opts ?? {}) + return rows.map((r) => { + const calls = Number(r.calls ?? 0) + const errors = Number(r.errors ?? 0) + const raw = String(r.name ?? '') + return { + name: raw.startsWith('execute_tool ') ? raw.slice('execute_tool '.length) : raw, + calls, + errors, + errorRate: calls > 0 ? errors / calls : 0, + avgChars: Math.round(num(r.avg_chars) ?? 0), + p95Chars: Math.round(num(r.p95_chars) ?? 0), + p50Ms: Math.round(num(r.p50_ms) ?? 0), + p95Ms: Math.round(num(r.p95_ms) ?? 0), + lastSeenMs: typeof r.last_seen === 'string' ? Date.parse(r.last_seen) : 0, + } + }) +} + +export async function fetchToolDetail( + p: AppInsightsProvider, + name: string, + opts?: WindowOpts, +): Promise { + if (!TOOL_NAME_RE.test(name)) return null + const q = ` + union dependencies, requests + | where name == "execute_tool ${name}" + | extend result_len = strlen(tostring(customDimensions["gen_ai.tool.call.result"])) + | extend result_len_nz = iif(result_len > 0, todouble(result_len), real(null)) + | summarize + calls = count(), + errors = countif(success == false), + avg_chars = avg(result_len_nz), + p95_chars = percentile(result_len_nz, 95), + max_chars = max(result_len_nz), + p50_ms = percentile(duration, 50), + p95_ms = percentile(duration, 95), + first_seen = min(timestamp), + last_seen = max(timestamp) + ` + const rows = await p.query(q, opts ?? {}) + const r = rows[0] + const calls = Number(r?.calls ?? 0) + if (!r || calls === 0) return null + const errors = Number(r.errors ?? 0) + return { + name, + calls, + errors, + errorRate: errors / calls, + avgChars: Math.round(num(r.avg_chars) ?? 0), + p95Chars: Math.round(num(r.p95_chars) ?? 0), + maxChars: Math.round(num(r.max_chars) ?? 0), + p50Ms: Math.round(num(r.p50_ms) ?? 0), + p95Ms: Math.round(num(r.p95_ms) ?? 0), + firstSeenMs: typeof r.first_seen === 'string' ? Date.parse(r.first_seen) : 0, + lastSeenMs: typeof r.last_seen === 'string' ? Date.parse(r.last_seen) : 0, + } +} + +export async function fetchToolRecentCalls( + p: AppInsightsProvider, + name: string, + opts?: WindowOpts & { limit?: number }, +): Promise { + if (!TOOL_NAME_RE.test(name)) return [] + const limit = opts?.limit ?? 50 + const q = ` + union dependencies, requests + | where name == "execute_tool ${name}" + | extend sess = ${aiCoalesce('sessionId')} + | order by timestamp desc + | take ${limit} + | project trace_id = operation_Id, session_id = sess, started_at = timestamp, duration_ms = duration, has_error = (success == false) + ` + const rows = await p.query(q, opts ?? {}) + return rows + .map((r) => { + const traceId = typeof r.trace_id === 'string' ? r.trace_id : '' + if (!traceId) return null + const sessionId = typeof r.session_id === 'string' && r.session_id ? r.session_id : undefined + const started = typeof r.started_at === 'string' ? Date.parse(r.started_at) : 0 + const sample: ToolCallSample = { + traceId, + startedAtMs: started, + durationMs: Math.round(num(r.duration_ms) ?? 0), + hasError: r.has_error === true || r.has_error === 'true', + } + if (sessionId) sample.sessionId = sessionId + return sample + }) + .filter((s): s is ToolCallSample => s !== null) +} + export async function fetchChatLatencyOverTime(p: AppInsightsProvider, opts?: WindowOpts): Promise { const fromUs = opts?.fromUs ?? 0 const toUs = opts?.toUs ?? 0 diff --git a/src/lib/telemetry/analytics-openobserve.ts b/src/lib/telemetry/analytics-openobserve.ts index 120f5dd..305e70c 100644 --- a/src/lib/telemetry/analytics-openobserve.ts +++ b/src/lib/telemetry/analytics-openobserve.ts @@ -9,12 +9,17 @@ import type { LatencyPoint, OpenObserveProvider, RunsPoint, + ToolCallSample, + ToolCatalogRow, + ToolDetail, ToolErrorRow, ToolPayloadRow, TopOpts, WindowOpts, } from './types' +const TOOL_NAME_RE = /^[A-Za-z0-9_./:-]+$/ + // 20004 = column not in stream yet (fresh ingest). Treat as empty. async function emptyOn20004(run: () => Promise): Promise { try { @@ -69,6 +74,127 @@ export async function fetchToolPayloadSizes(p: OpenObserveProvider, opts?: TopOp return hits.map(mapToolPayloadRow) } +export async function fetchAllTools(p: OpenObserveProvider, opts?: TopOpts): Promise { + const limit = opts?.limit ?? 1000 + const sql = ` + SELECT + operation_name AS name, + COUNT(*) AS calls, + SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS errors, + AVG(NULLIF(LENGTH(gen_ai_tool_call_result), 0)) AS avg_chars, + approx_percentile_cont(NULLIF(LENGTH(gen_ai_tool_call_result), 0), 0.95) AS p95_chars, + approx_percentile_cont(duration, 0.5) / 1000 AS p50_ms, + approx_percentile_cont(duration, 0.95) / 1000 AS p95_ms, + MAX(start_time) AS last_seen_ns + FROM "${p.stream}" + WHERE operation_name LIKE 'execute_tool %' + GROUP BY operation_name + ORDER BY calls DESC + LIMIT ${limit} + ` + const hits = await emptyOn20004(() => p.query(sql, { ...opts, size: limit })) + return hits.map((h) => { + const calls = Number(h.calls ?? 0) + const errors = Number(h.errors ?? 0) + const raw = String(h.name ?? '') + const lastNs = Number(h.last_seen_ns ?? 0) + return { + name: raw.startsWith('execute_tool ') ? raw.slice('execute_tool '.length) : raw, + calls, + errors, + errorRate: calls > 0 ? errors / calls : 0, + avgChars: Math.round(num(h.avg_chars) ?? 0), + p95Chars: Math.round(num(h.p95_chars) ?? 0), + p50Ms: Math.round(num(h.p50_ms) ?? 0), + p95Ms: Math.round(num(h.p95_ms) ?? 0), + lastSeenMs: lastNs > 0 ? Math.floor(lastNs / 1_000_000) : 0, + } + }) +} + +export async function fetchToolDetail( + p: OpenObserveProvider, + name: string, + opts?: WindowOpts, +): Promise { + if (!TOOL_NAME_RE.test(name)) return null + const sql = ` + SELECT + COUNT(*) AS calls, + SUM(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS errors, + AVG(NULLIF(LENGTH(gen_ai_tool_call_result), 0)) AS avg_chars, + approx_percentile_cont(NULLIF(LENGTH(gen_ai_tool_call_result), 0), 0.95) AS p95_chars, + MAX(LENGTH(gen_ai_tool_call_result)) AS max_chars, + approx_percentile_cont(duration, 0.5) / 1000 AS p50_ms, + approx_percentile_cont(duration, 0.95) / 1000 AS p95_ms, + MIN(start_time) AS first_seen_ns, + MAX(start_time) AS last_seen_ns + FROM "${p.stream}" + WHERE operation_name = 'execute_tool ${name}' + ` + const hits = await emptyOn20004(() => p.query(sql, { ...opts, size: 1 })) + const h = hits[0] + const calls = Number(h?.calls ?? 0) + if (!h || calls === 0) return null + const errors = Number(h.errors ?? 0) + const firstNs = Number(h.first_seen_ns ?? 0) + const lastNs = Number(h.last_seen_ns ?? 0) + return { + name, + calls, + errors, + errorRate: errors / calls, + avgChars: Math.round(num(h.avg_chars) ?? 0), + p95Chars: Math.round(num(h.p95_chars) ?? 0), + maxChars: Math.round(num(h.max_chars) ?? 0), + p50Ms: Math.round(num(h.p50_ms) ?? 0), + p95Ms: Math.round(num(h.p95_ms) ?? 0), + firstSeenMs: firstNs > 0 ? Math.floor(firstNs / 1_000_000) : 0, + lastSeenMs: lastNs > 0 ? Math.floor(lastNs / 1_000_000) : 0, + } +} + +export async function fetchToolRecentCalls( + p: OpenObserveProvider, + name: string, + opts?: WindowOpts & { limit?: number }, +): Promise { + if (!TOOL_NAME_RE.test(name)) return [] + const limit = opts?.limit ?? 50 + const known = await p.getKnownColumns() + const sessionCols = ooColumns('sessionId', { known }) + const sessionExpr = sessionCols.length === 0 ? 'NULL' : `COALESCE(${sessionCols.join(', ')})` + const sql = ` + SELECT + trace_id, + ${sessionExpr} AS session_id, + start_time, + duration, + span_status + FROM "${p.stream}" + WHERE operation_name = 'execute_tool ${name}' + ORDER BY start_time DESC + LIMIT ${limit} + ` + const hits = await emptyOn20004(() => p.query(sql, { ...opts, size: limit })) + return hits + .map((h) => { + const traceId = typeof h.trace_id === 'string' ? h.trace_id : '' + if (!traceId) return null + const sessionId = typeof h.session_id === 'string' && h.session_id ? h.session_id : undefined + const startNs = Number(h.start_time ?? 0) + const sample: ToolCallSample = { + traceId, + startedAtMs: startNs > 0 ? Math.floor(startNs / 1_000_000) : 0, + durationMs: Math.round((num(h.duration) ?? 0) / 1000), + hasError: h.span_status === 'ERROR', + } + if (sessionId) sample.sessionId = sessionId + return sample + }) + .filter((s): s is ToolCallSample => s !== null) +} + export async function fetchChatLatencyOverTime(p: OpenObserveProvider, opts?: WindowOpts): Promise { const fromUs = opts?.fromUs ?? 0 const toUs = opts?.toUs ?? 0 diff --git a/src/lib/telemetry/analytics.ts b/src/lib/telemetry/analytics.ts index f37e215..383585b 100644 --- a/src/lib/telemetry/analytics.ts +++ b/src/lib/telemetry/analytics.ts @@ -12,6 +12,9 @@ import type { LatencyPoint, RunsPoint, TelemetryProvider, + ToolCallSample, + ToolCatalogRow, + ToolDetail, ToolErrorRow, ToolPayloadRow, TopOpts, @@ -63,6 +66,41 @@ export async function fetchRunsPerHour(p: TelemetryProvider, opts?: WindowOpts): } } +export async function fetchAllTools(p: TelemetryProvider, opts?: TopOpts): Promise { + switch (p.name) { + case 'openobserve': + return oo.fetchAllTools(p, opts) + case 'app-insights': + return ai.fetchAllTools(p, opts) + } +} + +export async function fetchToolDetail( + p: TelemetryProvider, + name: string, + opts?: WindowOpts, +): Promise { + switch (p.name) { + case 'openobserve': + return oo.fetchToolDetail(p, name, opts) + case 'app-insights': + return ai.fetchToolDetail(p, name, opts) + } +} + +export async function fetchToolRecentCalls( + p: TelemetryProvider, + name: string, + opts?: WindowOpts & { limit?: number }, +): Promise { + switch (p.name) { + case 'openobserve': + return oo.fetchToolRecentCalls(p, name, opts) + case 'app-insights': + return ai.fetchToolRecentCalls(p, name, opts) + } +} + export async function fetchInventory( p: TelemetryProvider, kind: InventoryDiscoveryKind, diff --git a/src/lib/telemetry/index.ts b/src/lib/telemetry/index.ts index 20a9999..084cf9e 100644 --- a/src/lib/telemetry/index.ts +++ b/src/lib/telemetry/index.ts @@ -18,6 +18,9 @@ import type { SessionSummary, SpanSummary, TelemetryProvider, + ToolCallSample, + ToolCatalogRow, + ToolDetail, ToolErrorRow, ToolPayloadRow, TopOpts, @@ -217,6 +220,21 @@ export async function listToolPayloadSizes(opts?: TopOpts): Promise { + return analytics.fetchAllTools(getActiveProvider(), opts) +} + +export async function getToolDetail(name: string, opts?: WindowOpts): Promise { + return analytics.fetchToolDetail(getActiveProvider(), name, opts) +} + +export async function listToolRecentCalls( + name: string, + opts?: WindowOpts & { limit?: number }, +): Promise { + return analytics.fetchToolRecentCalls(getActiveProvider(), name, opts) +} + export async function listChatLatencyOverTime(opts?: WindowOpts): Promise { return analytics.fetchChatLatencyOverTime(getActiveProvider(), opts) } diff --git a/src/lib/telemetry/types.ts b/src/lib/telemetry/types.ts index 5b24f5e..4b631b9 100644 --- a/src/lib/telemetry/types.ts +++ b/src/lib/telemetry/types.ts @@ -133,6 +133,40 @@ export interface ToolPayloadRow { sampleSessionId?: string } +export interface ToolCatalogRow { + name: string + calls: number + errors: number + errorRate: number + avgChars: number + p95Chars: number + p50Ms: number + p95Ms: number + lastSeenMs: number +} + +export interface ToolDetail { + name: string + calls: number + errors: number + errorRate: number + avgChars: number + p95Chars: number + maxChars: number + p50Ms: number + p95Ms: number + firstSeenMs: number + lastSeenMs: number +} + +export interface ToolCallSample { + traceId: string + sessionId?: string + startedAtMs: number + durationMs: number + hasError: boolean +} + export type TopOpts = ListOpts export interface LatencyPoint { diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 66a615e..d7e25aa 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as TracesIndexRouteImport } from './routes/traces/index' +import { Route as ToolsIndexRouteImport } from './routes/tools/index' import { Route as TasksIndexRouteImport } from './routes/tasks/index' import { Route as SessionsIndexRouteImport } from './routes/sessions/index' import { Route as PromptsIndexRouteImport } from './routes/prompts/index' @@ -34,6 +35,11 @@ const TracesIndexRoute = TracesIndexRouteImport.update({ path: '/traces/', getParentRoute: () => rootRouteImport, } as any) +const ToolsIndexRoute = ToolsIndexRouteImport.update({ + id: '/tools/', + path: '/tools/', + getParentRoute: () => rootRouteImport, +} as any) const TasksIndexRoute = TasksIndexRouteImport.update({ id: '/tasks/', path: '/tasks/', @@ -109,6 +115,7 @@ export interface FileRoutesByFullPath { '/prompts/': typeof PromptsIndexRoute '/sessions/': typeof SessionsIndexRoute '/tasks/': typeof TasksIndexRoute + '/tools/': typeof ToolsIndexRoute '/traces/': typeof TracesIndexRoute } export interface FileRoutesByTo { @@ -125,6 +132,7 @@ export interface FileRoutesByTo { '/prompts': typeof PromptsIndexRoute '/sessions': typeof SessionsIndexRoute '/tasks': typeof TasksIndexRoute + '/tools': typeof ToolsIndexRoute '/traces': typeof TracesIndexRoute } export interface FileRoutesById { @@ -142,6 +150,7 @@ export interface FileRoutesById { '/prompts/': typeof PromptsIndexRoute '/sessions/': typeof SessionsIndexRoute '/tasks/': typeof TasksIndexRoute + '/tools/': typeof ToolsIndexRoute '/traces/': typeof TracesIndexRoute } export interface FileRouteTypes { @@ -160,6 +169,7 @@ export interface FileRouteTypes { | '/prompts/' | '/sessions/' | '/tasks/' + | '/tools/' | '/traces/' fileRoutesByTo: FileRoutesByTo to: @@ -176,6 +186,7 @@ export interface FileRouteTypes { | '/prompts' | '/sessions' | '/tasks' + | '/tools' | '/traces' id: | '__root__' @@ -192,6 +203,7 @@ export interface FileRouteTypes { | '/prompts/' | '/sessions/' | '/tasks/' + | '/tools/' | '/traces/' fileRoutesById: FileRoutesById } @@ -209,6 +221,7 @@ export interface RootRouteChildren { PromptsIndexRoute: typeof PromptsIndexRoute SessionsIndexRoute: typeof SessionsIndexRoute TasksIndexRoute: typeof TasksIndexRoute + ToolsIndexRoute: typeof ToolsIndexRoute TracesIndexRoute: typeof TracesIndexRoute } @@ -228,6 +241,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TracesIndexRouteImport parentRoute: typeof rootRouteImport } + '/tools/': { + id: '/tools/' + path: '/tools' + fullPath: '/tools/' + preLoaderRoute: typeof ToolsIndexRouteImport + parentRoute: typeof rootRouteImport + } '/tasks/': { id: '/tasks/' path: '/tasks' @@ -329,6 +349,7 @@ const rootRouteChildren: RootRouteChildren = { PromptsIndexRoute: PromptsIndexRoute, SessionsIndexRoute: SessionsIndexRoute, TasksIndexRoute: TasksIndexRoute, + ToolsIndexRoute: ToolsIndexRoute, TracesIndexRoute: TracesIndexRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/-home-components.tsx b/src/routes/-home-components.tsx index 152fa70..69f830c 100644 --- a/src/routes/-home-components.tsx +++ b/src/routes/-home-components.tsx @@ -1,24 +1,56 @@ import { ArrowTopRightOnSquareIcon, ChevronDownIcon } from '@heroicons/react/20/solid' +import { IconInfoCircle } from '@tabler/icons-react' import { Link } from '@tanstack/react-router' import { useState } from 'react' import { RelativeTime } from '#/components/relative-time' +import { ToolLink } from '#/components/tool-link' import { Badge } from '#/components/ui/badge' -import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card' +import { Card, CardAction, CardContent, CardHeader, CardTitle } from '#/components/ui/card' import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '#/components/ui/empty' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '#/components/ui/table' +import { Tooltip, TooltipContent, TooltipTrigger } from '#/components/ui/tooltip' import { formatPercent, formatTokens } from '#/lib/format' import type { ToolErrorRow, ToolPayloadRow } from '#/lib/telemetry' import type { InventoryRow } from '#/server/inbox' const PREVIEW_ROWS = 5 -export function Section({ title, children, wide }: { title: string; children: React.ReactNode; wide?: boolean }) { +export function Section({ + title, + description, + action, + children, + wide, +}: { + title: string + description?: string + action?: React.ReactNode + children: React.ReactNode + wide?: boolean +}) { return ( - - - {title} + + + + {title} + {description && ( + + + + + {description} + + )} + + {action && {action}} - {children} + {children} ) } @@ -78,12 +110,15 @@ export function OpenLink({ traceId, sessionId }: { traceId?: string | null; sess ) } +const CHARS_PER_TOKEN = 4 + function Chars({ chars }: { chars: number }) { if (!chars) return + const tokens = Math.ceil(chars / CHARS_PER_TOKEN) return ( - - {formatTokens(chars)} - ch + + {formatTokens(tokens)} + tok ) } @@ -112,8 +147,8 @@ export function ToolErrorTable({ rows }: { rows: ToolErrorRow[] }) { {visible.map((row) => ( - - {stripPrefix(row.name, 'execute_tool')} + + {row.errors} {row.total} @@ -152,8 +187,8 @@ export function ToolPayloadTable({ rows }: { rows: ToolPayloadRow[] }) { {visible.map((row) => ( - - {stripPrefix(row.name, 'execute_tool')} + + @@ -180,35 +215,34 @@ export function NewToolsTable({ rows }: { rows: InventoryRow[] }) { if (rows.length === 0) { return } + const visible = rows.slice(0, PREVIEW_ROWS) return ( - - {(visible) => ( - - - - Tool - Server - First seen - - - - - {visible.map((row) => ( - - {row.name} - {row.namespace || 'unknown'} - - - - - - - - ))} - -
- )} -
+ + + + Tool + Server + First seen + + + + + {visible.map((row) => ( + + + + + {row.namespace || 'unknown'} + + + + + + + + ))} + +
) } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index eb42bdb..6d31a17 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { createRootRouteWithContext, HeadContent, Link, Scripts, useNavigate, us import { ThemeProvider } from 'next-themes' import { AppSidebar } from '#/components/app-sidebar' import { CommandPaletteProvider } from '#/components/command-palette' +import { ToolInspectDrawer } from '#/components/inspect/tool-drawer' import { ShortcutsDialogProvider } from '#/components/shortcuts-dialog' import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar' import { Toaster } from '#/components/ui/sonner' @@ -44,6 +45,21 @@ export const Route = createRootRouteWithContext()({ type: 'image/svg+xml', href: '/favicon.svg', }, + { + rel: 'icon', + type: 'image/x-icon', + sizes: '48x48 32x32 16x16', + href: '/favicon.ico', + }, + { + rel: 'apple-touch-icon', + sizes: '192x192', + href: '/logo192.png', + }, + { + rel: 'manifest', + href: '/manifest.json', + }, { rel: 'stylesheet', href: appCss, @@ -60,7 +76,7 @@ export const Route = createRootRouteWithContext()({ // Runs before React hydrates so the chosen color theme / font are applied // without a flash. Reads localStorage and sets data-theme / data-font on // ; CSS variants key off those attributes (see styles.css). -const APPLY_THEME_SCRIPT = `try{var t=localStorage.getItem('color-theme');if(t)document.documentElement.dataset.theme=t;var f=localStorage.getItem('app-font');if(f)document.documentElement.dataset.font=f;}catch(e){}` +const APPLY_THEME_SCRIPT = `try{var t=localStorage.getItem('color-theme')||'loupe';document.documentElement.dataset.theme=t;var f=localStorage.getItem('app-font');if(f)document.documentElement.dataset.font=f;}catch(e){}` function RootDocument({ children }: { children: React.ReactNode }) { return ( @@ -80,6 +96,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { {children} + @@ -128,3 +145,17 @@ function SessionDrawerMount() { /> ) } + +function ToolDrawerMount() { + const search = useSearch({ strict: false }) as { tool?: string; trace?: string; session?: string } + const navigate = useNavigate() + const tool = !search.trace && !search.session && typeof search.tool === 'string' && search.tool ? search.tool : null + return ( + { + void navigate({ search: clearKey('tool') as never, replace: true }) + }} + /> + ) +} diff --git a/src/routes/inbox/-components/data-table.tsx b/src/routes/inbox/-components/data-table.tsx index 2fd5b74..3f99e42 100644 --- a/src/routes/inbox/-components/data-table.tsx +++ b/src/routes/inbox/-components/data-table.tsx @@ -93,7 +93,7 @@ export function InboxDataTable({ data, isLoading, ...actions }: InboxDataTablePr table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ec0d438..fd2675e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link } from '@tanstack/react-router' import { Page } from '#/components/page' import { CacheAreaChart } from './-home-charts/cache-area' import { LatencyAreaChart } from './-home-charts/latency-area' @@ -7,6 +7,18 @@ import { ThroughputAreaChart } from './-home-charts/throughput-area' import { NewAgentsTable, NewToolsTable, Section, ToolErrorTable, ToolPayloadTable } from './-home-components' import { homeInboxQuery } from './-home-data' +function ViewAllToolsLink({ sort }: { sort?: 'p95Chars' | 'errorRate' | 'lastSeenMs' | 'calls' }) { + return ( + + View all → + + ) +} + export const Route = createFileRoute('/')({ loader: ({ context }) => context.queryClient.ensureQueryData(homeInboxQuery()), component: Home, @@ -22,19 +34,31 @@ function Home() { return (
-
+
} + >
-
+
} + >
-
+
} + >
-
+
diff --git a/src/routes/sessions/-components/data-table.tsx b/src/routes/sessions/-components/data-table.tsx index 791087a..c87c05d 100644 --- a/src/routes/sessions/-components/data-table.tsx +++ b/src/routes/sessions/-components/data-table.tsx @@ -140,7 +140,7 @@ export function DataTable({ :first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', + 'h-12 [&>:first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', onRowClick && 'cursor-pointer', rowClassName?.(row.original), )} diff --git a/src/routes/tasks/-components/tasks-table.tsx b/src/routes/tasks/-components/tasks-table.tsx index f6e05de..d89ee70 100644 --- a/src/routes/tasks/-components/tasks-table.tsx +++ b/src/routes/tasks/-components/tasks-table.tsx @@ -129,7 +129,7 @@ export function TasksDataTable({ :first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', + 'h-12 [&>:first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', onRowClick && 'cursor-pointer', )} onClick={onRowClick ? () => onRowClick(row.original) : undefined} diff --git a/src/routes/tools/-columns.tsx b/src/routes/tools/-columns.tsx new file mode 100644 index 0000000..d79cec1 --- /dev/null +++ b/src/routes/tools/-columns.tsx @@ -0,0 +1,93 @@ +import type { ColumnDef } from '@tanstack/react-table' +import { DataTableColumnHeader } from '#/components/data-table-column-header' +import { RelativeTime } from '#/components/relative-time' +import { ToolLink } from '#/components/tool-link' +import { Badge } from '#/components/ui/badge' +import { formatDuration, formatPercent, formatTokens } from '#/lib/format' +import type { ToolCatalogRow } from '#/lib/telemetry' + +const CHARS_PER_TOKEN = 4 + +function Tokens({ chars }: { chars: number }) { + if (!chars) return + const tokens = Math.ceil(chars / CHARS_PER_TOKEN) + return ( + + {formatTokens(tokens)} + tok + + ) +} + +export const toolColumns: ColumnDef[] = [ + { + accessorKey: 'name', + header: ({ column }) => , + cell: ({ row }) => , + filterFn: (row, _id, value) => { + const q = String(value ?? '') + .trim() + .toLowerCase() + if (!q) return true + return row.original.name.toLowerCase().includes(q) + }, + }, + { + accessorKey: 'calls', + header: ({ column }) => , + cell: ({ row }) =>
{row.original.calls.toLocaleString()}
, + }, + { + accessorKey: 'errorRate', + header: ({ column }) => , + cell: ({ row }) => { + const r = row.original + return ( +
+ {r.errors > 0 ? ( + + {formatPercent(r.errorRate, 1)} + + ) : ( + + )} +
+ ) + }, + }, + { + accessorKey: 'p50Ms', + header: ({ column }) => , + cell: ({ row }) =>
{formatDuration(row.original.p50Ms)}
, + }, + { + accessorKey: 'p95Ms', + header: ({ column }) => , + cell: ({ row }) =>
{formatDuration(row.original.p95Ms)}
, + }, + { + accessorKey: 'avgChars', + header: ({ column }) => , + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: 'p95Chars', + header: ({ column }) => , + cell: ({ row }) => ( +
+ +
+ ), + }, + { + accessorKey: 'lastSeenMs', + header: ({ column }) => , + cell: ({ row }) => ( + + ), + }, +] diff --git a/src/routes/tools/-data.ts b/src/routes/tools/-data.ts new file mode 100644 index 0000000..ac89f70 --- /dev/null +++ b/src/routes/tools/-data.ts @@ -0,0 +1,35 @@ +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, listAllTools, type ToolCatalogRow } from '#/lib/telemetry' +import { DEFAULT, parse, serialize, type TimeRange, windowUs } from '#/lib/time-range' + +const cache = new LRUCache({ max: 100 }) + +const ttlMs = (range: TimeRange) => { + const us = windowUs(range) + const days = (us.toUs - us.fromUs) / 1_000_000 / 86_400 + if (days <= 1) return 5 * 60_000 + if (days <= 7) return 15 * 60_000 + return 30 * 60_000 +} + +const fetchTools = createServerFn({ method: 'GET' }) + .inputValidator((input: unknown) => parse(input)) + .handler(async ({ data }): Promise => { + const key = `${getActiveProviderId()}:${serialize(data)}` + const cached = cache.get(key) + if (cached) return cached + const { fromUs, toUs } = windowUs(data) + const result = await listAllTools({ fromUs, toUs, limit: 1000 }) + cache.set(key, result, { ttl: ttlMs(data) }) + return result + }) + +export const toolsCatalogQuery = (range: TimeRange = DEFAULT) => + queryOptions({ + queryKey: queryKeys.tools.catalog(serialize(range)), + queryFn: () => fetchTools({ data: range }), + staleTime: STALE_TELEMETRY_MS, + }) diff --git a/src/routes/tools/-tools-data-table.tsx b/src/routes/tools/-tools-data-table.tsx new file mode 100644 index 0000000..1cf6c0c --- /dev/null +++ b/src/routes/tools/-tools-data-table.tsx @@ -0,0 +1,199 @@ +import { IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react' +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type SortingState, + useReactTable, + type VisibilityState, +} from '@tanstack/react-table' +import * as React from 'react' +import type { AutoRefreshInterval } from '#/components/auto-refresh-select' +import { DataTableToolbar } from '#/components/data-table-toolbar' +import { Spinner } from '#/components/spinner' +import { Button } from '#/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '#/components/ui/table' +import type { ToolCatalogRow } from '#/lib/telemetry' +import type { TimeRange } from '#/lib/time-range' +import { toolColumns } from './-columns' + +interface ToolsDataTableProps { + data: ToolCatalogRow[] + isLoading?: boolean + sorting: SortingState + onSortingChange: (next: SortingState) => void + range: TimeRange + onRangeChange: (range: TimeRange) => void + autoRefresh: AutoRefreshInterval + onAutoRefreshChange: (interval: AutoRefreshInterval) => void + onRefresh: () => void + refreshing?: boolean +} + +export function ToolsDataTable({ + data, + isLoading, + sorting, + onSortingChange, + range, + onRangeChange, + autoRefresh, + onAutoRefreshChange, + onRefresh, + refreshing, +}: ToolsDataTableProps) { + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [columnFilters, setColumnFilters] = React.useState([]) + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 50 }) + + const table = useReactTable({ + data, + columns: toolColumns, + state: { sorting, columnVisibility, columnFilters, pagination }, + getRowId: (row) => row.name, + onSortingChange: (updater) => { + const next = typeof updater === 'function' ? updater(sorting) : updater + onSortingChange(next) + }, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + return ( +
+ +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + +
+ {isLoading ? ( + + ) : ( +
No tools in this window.
+ )} +
+
+
+ )} +
+
+
+
+
+
+
+
+

Rows per page

+ +
+
+ Page {table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)} +
+
+ + + + +
+
+
+
+
+ ) +} diff --git a/src/routes/tools/index.tsx b/src/routes/tools/index.tsx new file mode 100644 index 0000000..d337373 --- /dev/null +++ b/src/routes/tools/index.tsx @@ -0,0 +1,74 @@ +import { useQuery } from '@tanstack/react-query' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import type { SortingState } from '@tanstack/react-table' +import { useMemo } from 'react' +import { AUTO_REFRESH_MS } from '#/components/auto-refresh-select' +import { Page } from '#/components/page' +import { useAutoRefresh } from '#/hooks/use-auto-refresh' +import { useTimeRange } from '#/hooks/use-time-range' +import { toolsCatalogQuery } from './-data' +import { ToolsDataTable } from './-tools-data-table' + +const SORTABLE_COLUMNS = new Set(['name', 'calls', 'errorRate', 'p50Ms', 'p95Ms', 'avgChars', 'p95Chars', 'lastSeenMs']) + +export const Route = createFileRoute('/tools/')({ + validateSearch: (search: Record): { sort?: string; desc?: boolean; tool?: string } => { + const sort = typeof search.sort === 'string' && SORTABLE_COLUMNS.has(search.sort) ? search.sort : undefined + const desc = typeof search.desc === 'boolean' ? search.desc : undefined + const tool = typeof search.tool === 'string' ? search.tool.trim() : '' + return { + ...(sort ? { sort } : {}), + ...(desc !== undefined ? { desc } : {}), + ...(tool ? { tool } : {}), + } + }, + loader: ({ context }) => context.queryClient.ensureQueryData(toolsCatalogQuery()), + component: ToolsPage, +}) + +function ToolsPage() { + const { sort, desc } = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const [range, setRange] = useTimeRange() + const [autoRefresh, setAutoRefresh] = useAutoRefresh() + const { data, isLoading, isFetching, refetch } = useQuery({ + ...toolsCatalogQuery(range), + refetchInterval: AUTO_REFRESH_MS[autoRefresh], + }) + + const sorting: SortingState = useMemo( + () => (sort ? [{ id: sort, desc: desc ?? true }] : [{ id: 'calls', desc: true }]), + [sort, desc], + ) + + const setSorting = (next: SortingState) => { + const first = next[0] + void navigate({ + search: (prev) => ({ + ...prev, + sort: first?.id, + desc: first ? first.desc : undefined, + }), + replace: true, + }) + } + + return ( + + { + void refetch() + }} + refreshing={isFetching} + /> + + ) +} diff --git a/src/routes/traces/-spans-data-table.tsx b/src/routes/traces/-spans-data-table.tsx index ff5bb96..d6cd95b 100644 --- a/src/routes/traces/-spans-data-table.tsx +++ b/src/routes/traces/-spans-data-table.tsx @@ -123,7 +123,7 @@ export function SpansDataTable({ :first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', + 'h-12 [&>:first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', onRowClick && 'cursor-pointer', )} onClick={onRowClick ? () => onRowClick(row.original) : undefined} diff --git a/src/routes/traces/-traces-data-table.tsx b/src/routes/traces/-traces-data-table.tsx index 41559b5..ee98267 100644 --- a/src/routes/traces/-traces-data-table.tsx +++ b/src/routes/traces/-traces-data-table.tsx @@ -145,7 +145,7 @@ export function TracesDataTable({ :first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', + 'h-12 [&>:first-child]:pl-4 [&>:last-child]:pr-4 lg:[&>:first-child]:pl-6 lg:[&>:last-child]:pr-6', onRowClick && 'cursor-pointer', )} onClick={onRowClick ? () => onRowClick(row.original) : undefined} diff --git a/src/styles.css b/src/styles.css index c27cfc2..3853577 100644 --- a/src/styles.css +++ b/src/styles.css @@ -245,72 +245,143 @@ --sidebar-ring: oklch(0.67 0.17 153.85); } -:root[data-theme='vscode']:not(.dark) { - --background: oklch(0.97 0.02 225.66); - --foreground: oklch(0.15 0.02 269.18); - --card: oklch(0.98 0.01 228.79); - --card-foreground: oklch(0.15 0.02 269.18); - --popover: oklch(0.98 0.01 238.45); - --popover-foreground: oklch(0.15 0.02 269.18); - --primary: oklch(0.71 0.15 239.07); - --primary-foreground: oklch(0.94 0.03 232.39); - --secondary: oklch(0.91 0.03 229.2); - --secondary-foreground: oklch(0.15 0.02 269.18); - --muted: oklch(0.89 0.02 225.69); - --muted-foreground: oklch(0.36 0.03 230.3); - --accent: oklch(0.88 0.02 235.72); - --accent-foreground: oklch(0.34 0.05 229.72); - --destructive: oklch(0.61 0.24 20.96); - --border: oklch(0.82 0.02 240.77); - --input: oklch(0.82 0.02 240.77); - --ring: oklch(0.55 0.1 235.72); - --chart-1: oklch(0.57 0.11 228.97); - --chart-2: oklch(0.45 0.1 270.08); - --chart-3: oklch(0.65 0.15 159.03); - --chart-4: oklch(0.75 0.1 100.01); - --chart-5: oklch(0.55 0.15 299.88); - --sidebar: oklch(0.93 0.01 238.46); - --sidebar-foreground: oklch(0.15 0.02 269.18); - --sidebar-primary: oklch(0.57 0.11 228.97); - --sidebar-primary-foreground: oklch(0.99 0.01 203.97); - --sidebar-accent: oklch(0.88 0.02 235.72); - --sidebar-accent-foreground: oklch(0.15 0.02 269.18); - --sidebar-border: oklch(0.82 0.02 240.77); - --sidebar-ring: oklch(0.57 0.11 228.97); +/* Loupe brand — flat near-black surfaces, sidebar matches bg, electric violet accent. */ +:root[data-theme='loupe']:not(.dark) { + --background: oklch(1 0 0); + --foreground: oklch(0.15 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0 0); + --primary: oklch(0.55 0.27 280); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.15 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.5 0 0); + --accent: oklch(0.94 0.04 280); + --accent-foreground: oklch(0.45 0.25 280); + --destructive: oklch(0.55 0.245 27); + --border: oklch(0.92 0 0); + --input: oklch(0.92 0 0); + --ring: oklch(0.55 0.27 280); + --chart-1: oklch(0.55 0.27 280); + --chart-2: oklch(0.6 0 0); + --chart-3: oklch(0.65 0.17 162); + --chart-4: oklch(0.75 0.18 70); + --chart-5: oklch(0.6 0.23 25); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.15 0 0); + --sidebar-primary: oklch(0.55 0.27 280); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.94 0 0); + --sidebar-accent-foreground: oklch(0.15 0 0); + --sidebar-border: oklch(0.92 0 0); + --sidebar-ring: oklch(0.55 0.27 280); } -.dark[data-theme='vscode'] { - --background: oklch(0.18 0.02 271.27); - --foreground: oklch(0.9 0.01 238.47); - --card: oklch(0.22 0.02 271.67); - --card-foreground: oklch(0.9 0.01 238.47); - --popover: oklch(0.22 0.02 271.67); - --popover-foreground: oklch(0.9 0.01 238.47); - --primary: oklch(0.71 0.15 239.07); - --primary-foreground: oklch(0.94 0.03 232.39); - --secondary: oklch(0.28 0.03 270.91); - --secondary-foreground: oklch(0.9 0.01 238.47); - --muted: oklch(0.28 0.03 270.91); - --muted-foreground: oklch(0.6 0.03 269.46); - --accent: oklch(0.28 0.03 270.91); - --accent-foreground: oklch(0.9 0.01 238.47); - --destructive: oklch(0.64 0.25 19.69); - --border: oklch(0.9 0.01 238.47 / 15%); - --input: oklch(0.9 0.01 238.47 / 20%); - --ring: oklch(0.66 0.13 227.15); - --chart-1: oklch(0.66 0.13 227.15); - --chart-2: oklch(0.6 0.1 269.83); - --chart-3: oklch(0.7 0.15 159.83); - --chart-4: oklch(0.8 0.1 100.65); - --chart-5: oklch(0.6 0.15 300.14); - --sidebar: oklch(0.22 0.02 271.67); - --sidebar-foreground: oklch(0.9 0.01 238.47); - --sidebar-primary: oklch(0.66 0.13 227.15); - --sidebar-primary-foreground: oklch(0.18 0.02 271.27); - --sidebar-accent: oklch(0.28 0.03 270.91); - --sidebar-accent-foreground: oklch(0.9 0.01 238.47); - --sidebar-border: oklch(0.9 0.01 238.47 / 15%); - --sidebar-ring: oklch(0.66 0.13 227.15); +.dark[data-theme='loupe'] { + --background: oklch(0.205 0.012 280); + --foreground: oklch(0.97 0 0); + --card: oklch(0.235 0.012 280); + --card-foreground: oklch(0.97 0 0); + --popover: oklch(0.255 0.012 280); + --popover-foreground: oklch(0.97 0 0); + --primary: oklch(0.65 0.27 280); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.275 0.012 280); + --secondary-foreground: oklch(0.97 0 0); + --muted: oklch(0.275 0.012 280); + --muted-foreground: oklch(0.7 0.01 280); + --accent: oklch(0.29 0.025 280); + --accent-foreground: oklch(0.97 0 0); + --destructive: oklch(0.65 0.25 25); + --border: oklch(1 0 0 / 6%); + --input: oklch(1 0 0 / 9%); + --ring: oklch(0.65 0.27 280); + --chart-1: oklch(0.65 0.27 280); + --chart-2: oklch(0.7 0 0); + --chart-3: oklch(0.7 0.17 162); + --chart-4: oklch(0.78 0.18 70); + --chart-5: oklch(0.65 0.23 25); + --sidebar: oklch(0.175 0.012 280); + --sidebar-foreground: oklch(0.97 0 0); + --sidebar-primary: oklch(0.65 0.27 280); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.255 0.012 280); + --sidebar-accent-foreground: oklch(0.97 0 0); + --sidebar-border: oklch(1 0 0 / 6%); + --sidebar-ring: oklch(0.65 0.27 280); +} + +/* Tremor planner palette — cool Tailwind grays + blue accent. + Source: github.com/tremorlabs/template-planner */ +:root[data-theme='tremor']:not(.dark) { + --background: oklch(1 0 0); + --foreground: oklch(0.21 0.034 264.665); + --card: oklch(1 0 0); + --card-foreground: oklch(0.21 0.034 264.665); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.21 0.034 264.665); + --primary: oklch(0.546 0.245 262.881); + --primary-foreground: oklch(0.985 0.002 247.839); + --secondary: oklch(0.967 0.003 264.542); + --secondary-foreground: oklch(0.21 0.034 264.665); + --muted: oklch(0.967 0.003 264.542); + --muted-foreground: oklch(0.551 0.027 264.364); + --accent: oklch(0.932 0.032 255.585); + --accent-foreground: oklch(0.546 0.245 262.881); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.928 0.006 264.531); + --input: oklch(0.928 0.006 264.531); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.623 0.214 259.815); + --chart-2: oklch(0.707 0.022 261.325); + --chart-3: oklch(0.696 0.17 162.48); + --chart-4: oklch(0.769 0.188 70.08); + --chart-5: oklch(0.606 0.25 292.717); + --sidebar: oklch(0.985 0.002 247.839); + --sidebar-foreground: oklch(0.21 0.034 264.665); + --sidebar-primary: oklch(0.546 0.245 262.881); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.928 0.006 264.531); + --sidebar-accent-foreground: oklch(0.21 0.034 264.665); + --sidebar-border: oklch(0.928 0.006 264.531); + --sidebar-ring: oklch(0.623 0.214 259.815); +} + +.dark[data-theme='tremor'] { + --background: oklch(0.13 0.028 261.692); + --foreground: oklch(0.985 0.002 247.839); + --card: oklch(0.105 0.04 263); + --card-foreground: oklch(0.985 0.002 247.839); + --popover: oklch(0.105 0.04 263); + --popover-foreground: oklch(0.985 0.002 247.839); + --primary: oklch(0.623 0.214 259.815); + --primary-foreground: oklch(0.985 0.002 247.839); + --secondary: oklch(0.21 0.034 264.665); + --secondary-foreground: oklch(0.985 0.002 247.839); + --muted: oklch(0.21 0.034 264.665); + --muted-foreground: oklch(0.707 0.022 261.325); + --accent: oklch(0.21 0.034 264.665); + --accent-foreground: oklch(0.623 0.214 259.815); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(0.278 0.033 256.848); + --input: oklch(0.278 0.033 256.848); + --ring: oklch(0.623 0.214 259.815); + --chart-1: oklch(0.623 0.214 259.815); + --chart-2: oklch(0.551 0.027 264.364); + --chart-3: oklch(0.696 0.17 162.48); + --chart-4: oklch(0.769 0.188 70.08); + --chart-5: oklch(0.606 0.25 292.717); + --sidebar: oklch(0.13 0.028 261.692); + --sidebar-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary: oklch(0.623 0.214 259.815); + --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent: oklch(0.21 0.034 264.665); + --sidebar-accent-foreground: oklch(0.985 0.002 247.839); + --sidebar-border: oklch(0.278 0.033 256.848); + --sidebar-ring: oklch(0.623 0.214 259.815); } /* Alternate fonts — opt in by setting data-font on . */