ui: convert ActivityTable to shadcn-svelte data-table

Replace the hand-rolled table with a TanStack Table-backed shadcn
data-table using the FlexRender/createSvelteTable helpers, with
DropdownMenu column visibility, Select page-size, and icon-button
pagination. Column visibility and page size persist to localStorage.

Move tooltip usage to the canonical shadcn-svelte pattern by adding a
single root Tooltip.Provider in App.svelte and using Tooltip.Root/
Trigger/Content directly in the activity-table sub-components
(HeaderLabel, MetaCell), dropping the custom Tooltip/MetadataTooltip
wrappers.

- add @tanstack/table-core and shadcn data-table helpers
- split cell/header renderers into activity-table/* sub-components
- switch pagination/visibility to TanStack Table state driven by
  table.nextPage/previousPage/setPageIndex/setPageSize and
  column.toggleVisibility
This commit is contained in:
Benson Wong
2026-06-28 03:26:24 +00:00
parent 82cad1b84e
commit 040ee1e284
11 changed files with 717 additions and 354 deletions
+14
View File
@@ -8,6 +8,7 @@
"name": "ui-svelte", "name": "ui-svelte",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
@@ -817,6 +818,19 @@
"vite": "^5.2.0 || ^6 || ^7" "vite": "^5.2.0 || ^6 || ^7"
} }
}, },
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tsconfig/svelte": { "node_modules/@tsconfig/svelte": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
+1
View File
@@ -34,6 +34,7 @@
"vitest": "^4.1.0" "vitest": "^4.1.0"
}, },
"dependencies": { "dependencies": {
"@tanstack/table-core": "^8.21.3",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.28", "katex": "^0.16.28",
+23 -20
View File
@@ -10,6 +10,7 @@
import Playground from "./routes/Playground.svelte"; import Playground from "./routes/Playground.svelte";
import PlaygroundStub from "./routes/PlaygroundStub.svelte"; import PlaygroundStub from "./routes/PlaygroundStub.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { Separator } from "$lib/components/ui/separator/index.js"; import { Separator } from "$lib/components/ui/separator/index.js";
import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api"; import { enableAPIEvents, checkPerformanceEnabled } from "./stores/api";
import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme"; import { initScreenWidth, initSystemThemeListener, isDarkMode, appTitle, connectionState } from "./stores/theme";
@@ -80,24 +81,26 @@
}); });
</script> </script>
<Sidebar.Provider> <Tooltip.Provider>
<AppSidebar /> <Sidebar.Provider>
<Sidebar.Inset class="h-screen min-w-0 overflow-hidden"> <AppSidebar />
<header <Sidebar.Inset class="h-screen min-w-0 overflow-hidden">
class="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b px-4" <header
> class="bg-background sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b px-4"
<Sidebar.Trigger class="-ml-1" /> >
<Separator orientation="vertical" class="mr-2 !h-4" /> <Sidebar.Trigger class="-ml-1" />
<h2 class="truncate pb-0 text-sm font-semibold">{sectionTitle}</h2> <Separator orientation="vertical" class="mr-2 !h-4" />
</header> <h2 class="truncate pb-0 text-sm font-semibold">{sectionTitle}</h2>
</header>
<main class="min-h-0 flex-1 overflow-auto p-4"> <main class="min-h-0 flex-1 overflow-auto p-4">
<div class="h-full" class:hidden={$currentRoute !== "/"}> <div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground /> <Playground />
</div> </div>
<div class="h-full" class:hidden={$currentRoute === "/"}> <div class="h-full" class:hidden={$currentRoute === "/"}>
<Router {routes} on:routeLoaded={handleRouteLoaded} /> <Router {routes} on:routeLoaded={handleRouteLoaded} />
</div> </div>
</main> </main>
</Sidebar.Inset> </Sidebar.Inset>
</Sidebar.Provider> </Sidebar.Provider>
</Tooltip.Provider>
+314 -334
View File
@@ -2,22 +2,34 @@
import type { ActivityLogEntry, ReqRespCapture } from "../lib/types"; import type { ActivityLogEntry, ReqRespCapture } from "../lib/types";
import { getCapture } from "../stores/api"; import { getCapture } from "../stores/api";
import { persistentStore } from "../stores/persistent"; import { persistentStore } from "../stores/persistent";
import { onMount } from "svelte";
import Tooltip from "./Tooltip.svelte";
import MetadataTooltip from "./MetadataTooltip.svelte";
import CaptureDialog from "./CaptureDialog.svelte"; import CaptureDialog from "./CaptureDialog.svelte";
import { Columns3, GripVertical } from "@lucide/svelte"; import {
type ColumnDef,
type PaginationState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
} from "@tanstack/table-core";
import {
FlexRender,
createSvelteTable,
renderComponent,
} from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import * as Card from "$lib/components/ui/card/index.js"; import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js"; import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { Button } from "$lib/components/ui/button/index.js"; import { Button } from "$lib/components/ui/button/index.js";
import {
type ColumnKey = string; Columns3,
ChevronLeft,
interface ColumnDef { ChevronRight,
key: ColumnKey; ChevronsLeft,
label: string; ChevronsRight,
defaultVisible: boolean; } from "@lucide/svelte";
} import HeaderLabel from "./activity-table/HeaderLabel.svelte";
import ViewCaptureButton from "./activity-table/ViewCaptureButton.svelte";
import MetaCell from "./activity-table/MetaCell.svelte";
interface Props { interface Props {
metrics: ActivityLogEntry[]; metrics: ActivityLogEntry[];
@@ -41,150 +53,6 @@
cardClass = "", cardClass = "",
}: Props = $props(); }: Props = $props();
function buildColumns(withModel: boolean): ColumnDef[] {
const cols: ColumnDef[] = [
{ key: "id", label: "ID", defaultVisible: true },
{ key: "time", label: "Time", defaultVisible: true },
];
if (withModel) cols.push({ key: "model", label: "Model", defaultVisible: true });
cols.push(
{ key: "req_path", label: "Path", defaultVisible: false },
{ key: "resp_status_code", label: "Status", defaultVisible: true },
{ key: "resp_content_type", label: "Content-Type", defaultVisible: false },
{ key: "cached", label: "Cached", defaultVisible: true },
{ key: "prompt", label: "Prompt", defaultVisible: true },
{ key: "generated", label: "Generated", defaultVisible: true },
{ key: "drafted", label: "Drafted", defaultVisible: false },
{ key: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
{ key: "gen_speed", label: "Gen Speed", defaultVisible: true },
{ key: "duration", label: "Duration", defaultVisible: true },
{ key: "capture", label: "Capture", defaultVisible: true },
{ key: "meta", label: "Meta", defaultVisible: false }
);
return cols;
}
// svelte-ignore state_referenced_locally
const columns: ColumnDef[] = buildColumns(showModelColumn);
const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key);
// svelte-ignore state_referenced_locally
const visibleColumns = persistentStore<ColumnKey[]>(`${storagePrefix}-columns`, defaultVisibleKeys);
// svelte-ignore state_referenced_locally
const columnOrder = persistentStore<ColumnKey[]>(
`${storagePrefix}-column-order`,
columns.map((c) => c.key)
);
// svelte-ignore state_referenced_locally
const pageSizeStore = persistentStore<number>(`${storagePrefix}-page-size`, 10);
let page = $state(0);
let totalPages = $derived(Math.max(1, Math.ceil(metrics.length / $pageSizeStore)));
let pageMetrics = $derived(metrics.slice(page * $pageSizeStore, (page + 1) * $pageSizeStore));
let displayMetrics = $derived(showPagination ? pageMetrics : metrics);
// Reset page when data source or page size changes
$effect(() => {
metrics;
$pageSizeStore;
page = 0;
});
let columnsMenuOpen = $state(false);
let dropdownContainer: HTMLDivElement | null = $state(null);
let dragKey: ColumnKey | null = $state(null);
let dragOverKey: ColumnKey | null = $state(null);
onMount(() => {
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && columnsMenuOpen) columnsMenuOpen = false;
}
function handleClick(e: MouseEvent) {
if (columnsMenuOpen && dropdownContainer && !dropdownContainer.contains(e.target as Node)) {
columnsMenuOpen = false;
}
}
document.addEventListener("keydown", handleKeydown);
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("keydown", handleKeydown);
document.removeEventListener("click", handleClick);
};
});
function toggleColumn(key: ColumnKey) {
const current = $visibleColumns;
if (current.includes(key)) {
if (current.length > 1) visibleColumns.set(current.filter((k) => k !== key));
} else {
visibleColumns.set([...current, key]);
}
}
function isColumnVisible(key: ColumnKey): boolean {
return $visibleColumns.includes(key);
}
function handleDragStart(e: DragEvent, key: ColumnKey) {
dragKey = key;
e.dataTransfer?.setData("text/plain", key);
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
}
function handleDragOver(e: DragEvent, key: ColumnKey) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
dragOverKey = key;
}
function handleDrop(e: DragEvent, targetKey: ColumnKey) {
e.preventDefault();
if (!dragKey || dragKey === targetKey) return;
const order = [...$columnOrder];
const fromIndex = order.indexOf(dragKey);
let toIndex = order.indexOf(targetKey);
if (fromIndex === -1 || toIndex === -1) return;
order.splice(fromIndex, 1);
if (fromIndex < toIndex) toIndex -= 1;
order.splice(toIndex, 0, dragKey);
columnOrder.set(order);
}
function handleDragEnd() {
dragKey = null;
dragOverKey = null;
}
function orderByColumnOrder<T extends { key: ColumnKey }>(cols: T[]): T[] {
return cols.slice().sort((a, b) => {
const aIndex = $columnOrder.indexOf(a.key);
const bIndex = $columnOrder.indexOf(b.key);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
}
let orderedColumns = $derived(orderByColumnOrder(columns));
let activeVisibleColumns = $derived(
orderByColumnOrder(columns.filter((c) => isColumnVisible(c.key))).map((c) => c.key)
);
let columnLabelMap = $derived(Object.fromEntries(columns.map((c) => [c.key, c.label])));
$effect(() => {
const staticKeys = new Set(columns.map((c) => c.key));
const order = $columnOrder;
const hasStale = order.some((k) => !staticKeys.has(k));
const missing = columns.filter((c) => !order.includes(c.key)).map((c) => c.key);
if (hasStale || missing.length > 0) {
const cleaned = order.filter((k) => staticKeys.has(k));
columnOrder.set([...cleaned, ...missing]);
}
});
function formatSpeed(speed: number): string { function formatSpeed(speed: number): string {
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s"; return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
} }
@@ -212,6 +80,80 @@
: "-"; : "-";
} }
interface ColMeta {
id: string;
label: string;
defaultVisible: boolean;
}
function buildColumnMeta(withModel: boolean): ColMeta[] {
const cols: ColMeta[] = [
{ id: "id", label: "ID", defaultVisible: true },
{ id: "time", label: "Time", defaultVisible: true },
];
if (withModel) cols.push({ id: "model", label: "Model", defaultVisible: true });
cols.push(
{ id: "req_path", label: "Path", defaultVisible: false },
{ id: "resp_status_code", label: "Status", defaultVisible: true },
{ id: "resp_content_type", label: "Content-Type", defaultVisible: false },
{ id: "cached", label: "Cached", defaultVisible: true },
{ id: "prompt", label: "Prompt", defaultVisible: true },
{ id: "generated", label: "Generated", defaultVisible: true },
{ id: "drafted", label: "Drafted", defaultVisible: false },
{ id: "prompt_speed", label: "Prompt Speed", defaultVisible: true },
{ id: "gen_speed", label: "Gen Speed", defaultVisible: true },
{ id: "duration", label: "Duration", defaultVisible: true },
{ id: "capture", label: "Capture", defaultVisible: true },
{ id: "meta", label: "Meta", defaultVisible: false }
);
return cols;
}
let columnMeta = $derived(buildColumnMeta(showModelColumn));
let columnLabelMap = $derived(
Object.fromEntries(columnMeta.map((c) => [c.id, c.label])) as Record<string, string>
);
let defaultVisibility = $derived.by(() => {
const v: VisibilityState = {};
for (const c of columnMeta) v[c.id] = c.defaultVisible;
return v;
});
// svelte-ignore state_referenced_locally
const storedVisibility = persistentStore<VisibilityState>(
`${storagePrefix}-columns`,
{}
);
// svelte-ignore state_referenced_locally
let columnVisibility = $state<VisibilityState>(
Object.keys($storedVisibility).length > 0 ? $storedVisibility : defaultVisibility
);
// svelte-ignore state_referenced_locally
const storedPageSize = persistentStore<number>(`${storagePrefix}-page-size`, 10);
// svelte-ignore state_referenced_locally
let pagination = $state<PaginationState>({
pageIndex: 0,
pageSize: showPagination ? $storedPageSize : 1,
});
// When not paginating, show every row; reset page on data/pageSize change.
$effect(() => {
if (!showPagination) {
pagination.pageSize = metrics.length || 1;
}
});
$effect(() => {
metrics;
pagination.pageSize;
pagination.pageIndex = 0;
});
let selectedCapture = $state<ReqRespCapture | null>(null); let selectedCapture = $state<ReqRespCapture | null>(null);
let dialogOpen = $state(false); let dialogOpen = $state(false);
let loadingCaptureId = $state<number | null>(null); let loadingCaptureId = $state<number | null>(null);
@@ -229,13 +171,136 @@
selectedCapture = null; selectedCapture = null;
} }
let thClass = $derived(compact ? "px-4 py-2 font-medium" : "px-6 py-3 font-medium"); function buildColumns(withModel: boolean): ColumnDef<ActivityLogEntry>[] {
const cols: ColumnDef<ActivityLogEntry>[] = [
{
id: "id",
header: "ID",
cell: ({ row }) => String(row.original.id + 1),
},
{
id: "time",
header: "Time",
cell: ({ row }) => formatRelativeTime(row.original.timestamp),
},
];
if (withModel) {
cols.push({
id: "model",
header: "Model",
cell: ({ row }) => row.original.model ?? "-",
});
}
cols.push(
{
id: "req_path",
header: "Path",
cell: ({ row }) => row.original.req_path || "-",
},
{
id: "resp_status_code",
header: "Status",
cell: ({ row }) => String(row.original.resp_status_code || "-"),
},
{
id: "resp_content_type",
header: "Content-Type",
cell: ({ row }) => row.original.resp_content_type || "-",
},
{
id: "cached",
header: () => renderComponent(HeaderLabel, { label: "Cached", tooltip: "prompt tokens from cache" }),
cell: ({ row }) =>
row.original.tokens.cache_tokens > 0
? row.original.tokens.cache_tokens.toLocaleString()
: "-",
},
{
id: "prompt",
header: () => renderComponent(HeaderLabel, { label: "Prompt", tooltip: "new prompt tokens processed" }),
cell: ({ row }) => row.original.tokens.input_tokens.toLocaleString(),
},
{
id: "generated",
header: "Generated",
cell: ({ row }) => row.original.tokens.output_tokens.toLocaleString(),
},
{
id: "drafted",
header: () => renderComponent(HeaderLabel, { label: "Drafted", tooltip: "acceptance rate (accepted/drafted)" }),
cell: ({ row }) =>
formatDrafted(row.original.tokens.draft_tokens, row.original.tokens.draft_acc_tokens),
},
{
id: "prompt_speed",
header: "Prompt Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.prompt_per_second),
},
{
id: "gen_speed",
header: "Gen Speed",
cell: ({ row }) => formatSpeed(row.original.tokens.tokens_per_second),
},
{
id: "duration",
header: "Duration",
cell: ({ row }) => formatDuration(row.original.duration_ms),
},
{
id: "capture",
header: "Capture",
cell: ({ row }) =>
renderComponent(ViewCaptureButton, {
hasCapture: row.original.has_capture,
loading: loadingCaptureId === row.original.id,
onclick: () => viewCapture(row.original.id),
}),
},
{
id: "meta",
header: "Meta",
cell: ({ row }) =>
renderComponent(MetaCell, { metadata: row.original.metadata }),
}
);
return cols;
}
let columns = $derived(buildColumns(showModelColumn));
const table = createSvelteTable({
get data() {
return metrics;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
},
get columnVisibility() {
return columnVisibility;
},
},
onPaginationChange: (updater) => {
pagination =
typeof updater === "function" ? updater(pagination) : updater;
if (showPagination) storedPageSize.set(pagination.pageSize);
},
onColumnVisibilityChange: (updater) => {
columnVisibility =
typeof updater === "function" ? updater(columnVisibility) : updater;
storedVisibility.set(columnVisibility);
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
let thClass = $derived(compact ? "px-4 py-2 h-9" : "px-6 py-3 h-12");
let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4"); let tdClass = $derived(compact ? "px-4 py-2" : "px-6 py-4");
let rowClass = $derived(
compact
? "hover:bg-muted/50 whitespace-nowrap border-b"
: "hover:bg-muted/50 whitespace-nowrap border-b text-sm transition-colors"
);
</script> </script>
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0 {cardClass}"> <Card.Root class="shrink-0 gap-0 overflow-hidden py-0 {cardClass}">
@@ -250,13 +315,15 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if showPagination} {#if showPagination}
<span class="text-muted-foreground text-xs">Per page</span> <span class="text-muted-foreground text-xs">Rows</span>
<Select.Root <Select.Root
type="single" type="single"
value={String($pageSizeStore)} value={String(pagination.pageSize)}
onValueChange={(v) => pageSizeStore.set(Number(v))} onValueChange={(v) => table.setPageSize(Number(v))}
> >
<Select.Trigger class="h-7 w-16 text-xs">{$pageSizeStore}</Select.Trigger> <Select.Trigger size="sm" class="h-7 w-[4.5rem] text-xs">
{pagination.pageSize}
</Select.Trigger>
<Select.Content> <Select.Content>
{#each [5, 10, 25, 50] as size (size)} {#each [5, 10, 25, 50] as size (size)}
<Select.Item value={String(size)}>{size}</Select.Item> <Select.Item value={String(size)}>{size}</Select.Item>
@@ -264,193 +331,106 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
{/if} {/if}
<div bind:this={dropdownContainer}> <DropdownMenu.Root>
<div class="relative"> <DropdownMenu.Trigger
<Button class="hover:bg-muted inline-flex size-7 items-center justify-center rounded-[min(var(--radius-md),12px)]"
variant="ghost" title="Select columns"
size="icon-sm" >
onclick={() => (columnsMenuOpen = !columnsMenuOpen)} <Columns3 class="size-4" />
title="Select columns" </DropdownMenu.Trigger>
> <DropdownMenu.Content align="end" class="min-w-[16rem] p-0">
<Columns3 /> <DropdownMenu.Label class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider">
</Button> Columns
{#if columnsMenuOpen} </DropdownMenu.Label>
<div {#each table.getAllColumns() as column (column.id)}
class="bg-popover text-popover-foreground absolute right-0 top-full z-20 mt-1 min-w-[16rem] rounded-md border py-1 shadow-md" {#if column.getCanHide()}
role="list" <DropdownMenu.CheckboxItem
> checked={column.getIsVisible()}
<div onCheckedChange={(v) => column.toggleVisibility(!!v)}
class="text-muted-foreground border-b px-3 py-2 text-xs font-medium uppercase tracking-wider"
role="presentation"
> >
Columns {columnLabelMap[column.id] ?? column.id}
</div> </DropdownMenu.CheckboxItem>
{#each orderedColumns as col (col.key)} {/if}
{@const key = col.key} {/each}
<div </DropdownMenu.Content>
class="hover:bg-accent flex items-center gap-2 px-3 py-1.5 text-sm transition-colors {dragOverKey === </DropdownMenu.Root>
key && dragKey !== key
? 'bg-primary/10 ring-primary/40 ring-1'
: ''} {dragKey === key ? 'opacity-40' : ''}"
role="listitem"
ondragover={(e) => handleDragOver(e, key)}
ondrop={(e) => handleDrop(e, key)}
>
<span
class="text-muted-foreground flex cursor-grab select-none"
draggable={true}
role="button"
tabindex="-1"
aria-label="Drag to reorder {col.label}"
ondragstart={(e) => handleDragStart(e, key)}
ondragend={handleDragEnd}
>
<GripVertical class="size-4" />
</span>
<label class="flex flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isColumnVisible(key)}
onchange={() => toggleColumn(key)}
class="accent-primary rounded-none"
/>
{col.label}
</label>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div> </div>
</Card.Header> </Card.Header>
<Card.Content class="overflow-x-auto p-0"> <Card.Content class="overflow-x-auto p-0">
<table class="min-w-full text-sm"> <Table.Root class="min-w-full">
<thead class="text-muted-foreground border-b text-left text-xs uppercase tracking-wider"> <Table.Header>
<tr> {#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
{#each activeVisibleColumns as key (key)} <Table.Row>
<th class={thClass}> {#each headerGroup.headers as header (header.id)}
{#if key === "cached"} <Table.Head class={thClass} colspan={header.colSpan}>
Cached <Tooltip content="prompt tokens from cache" /> {#if !header.isPlaceholder}
{:else if key === "prompt"} <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
Prompt <Tooltip content="new prompt tokens processed" /> {/if}
{:else if key === "drafted"} </Table.Head>
Drafted <Tooltip content="acceptance rate (accepted/drafted)" /> {/each}
{:else} </Table.Row>
{columnLabelMap[key] ?? key} {/each}
{/if} </Table.Header>
</th> <Table.Body>
{/each} {#each table.getRowModel().rows as row (row.id)}
</tr> <Table.Row>
</thead> {#each row.getVisibleCells() as cell (cell.id)}
<tbody> <Table.Cell class={tdClass}>
{#if displayMetrics.length === 0} <FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
<tr> </Table.Cell>
<td colspan={activeVisibleColumns.length} class="text-muted-foreground px-4 py-6 text-center text-sm"> {/each}
{emptyMessage} </Table.Row>
</td>
</tr>
{:else} {:else}
{#each displayMetrics as metric (metric.id)} <Table.Row>
<tr class={rowClass}> <Table.Cell colspan={columns.length} class="text-muted-foreground py-6 text-center text-sm">
{#each activeVisibleColumns as key (key)} {emptyMessage}
<td class={tdClass}> </Table.Cell>
{#if key === "id"} </Table.Row>
{metric.id + 1} {/each}
{:else if key === "time"} </Table.Body>
{formatRelativeTime(metric.timestamp)} </Table.Root>
{:else if key === "model"}
{metric.model}
{:else if key === "req_path"}
{metric.req_path || "-"}
{:else if key === "resp_status_code"}
{#if metric.error_msg}
<span class="text-destructive cursor-help" title={metric.error_msg}>
{metric.resp_status_code || "-"}
</span>
{:else}
{metric.resp_status_code || "-"}
{/if}
{:else if key === "resp_content_type"}
{metric.resp_content_type || "-"}
{:else if key === "cached"}
{metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"}
{:else if key === "prompt"}
{metric.tokens.input_tokens.toLocaleString()}
{:else if key === "generated"}
{metric.tokens.output_tokens.toLocaleString()}
{:else if key === "drafted"}
{formatDrafted(metric.tokens.draft_tokens, metric.tokens.draft_acc_tokens)}
{:else if key === "prompt_speed"}
{formatSpeed(metric.tokens.prompt_per_second)}
{:else if key === "gen_speed"}
{formatSpeed(metric.tokens.tokens_per_second)}
{:else if key === "duration"}
{formatDuration(metric.duration_ms)}
{:else if key === "capture"}
{#if metric.has_capture}
<Button
variant="outline"
size="xs"
onclick={() => viewCapture(metric.id)}
disabled={loadingCaptureId === metric.id}
>
{loadingCaptureId === metric.id ? "..." : "View"}
</Button>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if key === "meta"}
{#if Object.keys(metric.metadata || {}).length > 0}
<MetadataTooltip metadata={metric.metadata}>
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
</MetadataTooltip>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else}
-
{/if}
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</table>
{#if showPagination && metrics.length > 0} {#if showPagination && metrics.length > 0}
<div class="flex items-center justify-between gap-2 border-t px-4 py-2 text-sm"> <div class="flex items-center justify-between gap-2 border-t px-4 py-2 text-sm">
<span class="text-muted-foreground text-xs"> <span class="text-muted-foreground text-xs">
Page {page + 1} of {totalPages} · {metrics.length} total Page {pagination.pageIndex + 1} of {table.getPageCount()} · {metrics.length} total
</span> </span>
<div class="flex gap-1"> <div class="flex items-center gap-1">
<Button variant="outline" size="sm" onclick={() => (page = 0)} disabled={page === 0}> <Button
First variant="ghost"
size="icon-sm"
onclick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
title="First page"
>
<ChevronsLeft />
</Button> </Button>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon-sm"
onclick={() => (page = Math.max(0, page - 1))} onclick={() => table.previousPage()}
disabled={page === 0} disabled={!table.getCanPreviousPage()}
title="Previous page"
> >
Prev <ChevronLeft />
</Button> </Button>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon-sm"
onclick={() => (page = Math.min(totalPages - 1, page + 1))} onclick={() => table.nextPage()}
disabled={page >= totalPages - 1} disabled={!table.getCanNextPage()}
title="Next page"
> >
Next <ChevronRight />
</Button> </Button>
<Button <Button
variant="outline" variant="ghost"
size="sm" size="icon-sm"
onclick={() => (page = totalPages - 1)} onclick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={page >= totalPages - 1} disabled={!table.getCanNextPage()}
title="Last page"
> >
Last <ChevronsRight />
</Button> </Button>
</div> </div>
</div> </div>
@@ -0,0 +1,17 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
label: string;
tooltip?: string;
}
let { label, tooltip }: Props = $props();
</script>
{label}{#if tooltip}
<Tooltip.Root>
<Tooltip.Trigger class="cursor-help align-middle normal-case">&#9432;</Tooltip.Trigger>
<Tooltip.Content>{tooltip}</Tooltip.Content>
</Tooltip.Root>
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
interface Props {
metadata: Record<string, string> | undefined;
}
let { metadata }: Props = $props();
let entries = $derived(Object.entries(metadata || {}));
</script>
{#if entries.length > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-muted-foreground hover:text-foreground cursor-help">...</span>
</Tooltip.Trigger>
<Tooltip.Content class="min-w-[12rem] max-w-[24rem] normal-case">
<table class="w-full text-left">
<tbody>
{#each entries as [key, value]}
<tr class="border-b border-white/10 last:border-0">
<td class="py-1 pr-3 font-medium whitespace-nowrap text-primary">{key}</td>
<td class="py-1 break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
interface Props {
hasCapture: boolean;
loading: boolean;
onclick: () => void;
}
let { hasCapture, loading, onclick }: Props = $props();
</script>
{#if hasCapture}
<Button variant="outline" size="xs" {onclick} disabled={loading}>
{loading ? "..." : "View"}
</Button>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
@@ -0,0 +1,142 @@
import {
type RowData,
type TableOptions,
type TableOptionsResolved,
type TableState,
type Updater,
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state<TableState>(table.initialState);
function updateOptions() {
table.setOptions(() => {
return mergeObjects(resolvedOptions, options, {
state: mergeObjects(state, options.state || {}),
onStateChange: (updater: Updater<TableState>) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
? H & Intersection<R>
: unknown) & {};
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source.
*
* Proxy-based to avoid known WebKit recursion issue.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
...sources: Sources
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key: PropertyKey) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys(): (string | symbol)[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const all = new Set<string | symbol>();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src as any)[key],
writable: true,
};
},
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
}
@@ -0,0 +1,40 @@
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import type { CellContext, ColumnDefTemplate, HeaderContext } from "@tanstack/table-core";
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
import type { Attachment } from "svelte/attachments";
type Props = {
/** The cell or header field of the current cell's column definition. */
content?: TContext extends HeaderContext<TData, TValue>
? ColumnDefTemplate<HeaderContext<TData, TValue>>
: TContext extends CellContext<TData, TValue>
? ColumnDefTemplate<CellContext<TData, TValue>>
: never;
/** The result of the `getContext()` function of the header or cell */
context: TContext;
/** Used to pass attachments that can't be gotten through context */
attach?: Attachment;
};
let { content, context, attach }: Props = $props();
</script>
{#if typeof content === "string"}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context as any)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} {attach} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet({ ...params, attach })}
{:else}
{result}
{/if}
{/if}
@@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -0,0 +1,111 @@
import type { Component, ComponentProps, Snippet } from "svelte";
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig<TComponent extends Component> {
component: TComponent;
props: ComponentProps<TComponent> | Record<string, never>;
constructor(
component: TComponent,
props: ComponentProps<TComponent> | Record<string, never> = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig<TProps> {
snippet: Snippet<[TProps]>;
params: TProps;
constructor(snippet: Snippet<[TProps]>, params: TProps) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Component<any>,
Props extends ComponentProps<T>,
>(component: T, props: Props = {} as Props) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) {
return new RenderSnippetConfig(snippet, params);
}