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:
Generated
+14
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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">ⓘ</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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user