internal/server,shared: support request metadata (#850)
- add support for http handlers in the request chain to append metadata to the request - metrics middleware will include metadata in the activity log - update Activity UI to support metadata, drag sort columns - update Activity UI capture dialog to use more screen space Updates #834
This commit is contained in:
@@ -193,7 +193,7 @@
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
onclose={handleDialogClose}
|
||||
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
|
||||
class="bg-surface text-txtmain rounded-lg shadow-xl max-w-[80%] w-full max-h-[90vh] p-0 backdrop:bg-black/50 m-auto"
|
||||
>
|
||||
{#if capture}
|
||||
<div class="flex flex-col max-h-[90vh]">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
metadata: Record<string, string> | undefined;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { metadata, children }: Props = $props();
|
||||
|
||||
let entries = $derived(Object.entries(metadata || {}));
|
||||
let triggerEl: HTMLElement | undefined = $state();
|
||||
let tooltipEl: HTMLDivElement | undefined = $state();
|
||||
let show = $state(false);
|
||||
let tooltipStyle = $state("");
|
||||
|
||||
function positionTooltip() {
|
||||
if (!triggerEl || !tooltipEl) return;
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const tooltipRect = tooltipEl.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = triggerRect.left;
|
||||
let top = triggerRect.bottom + margin;
|
||||
|
||||
// Keep tooltip within horizontal viewport bounds
|
||||
if (left + tooltipRect.width > viewportWidth - margin) {
|
||||
left = triggerRect.right - tooltipRect.width;
|
||||
}
|
||||
if (left < margin) {
|
||||
left = margin;
|
||||
}
|
||||
|
||||
// Flip above trigger if it would overflow the bottom
|
||||
if (top + tooltipRect.height > viewportHeight - margin) {
|
||||
top = triggerRect.top - tooltipRect.height - margin;
|
||||
}
|
||||
|
||||
tooltipStyle = `left: ${left}px; top: ${top}px; max-width: calc(100vw - ${margin * 2}px);`;
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
show = true;
|
||||
requestAnimationFrame(positionTooltip);
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
show = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={triggerEl}
|
||||
onmouseenter={onEnter}
|
||||
onmouseleave={onLeave}
|
||||
onfocus={onEnter}
|
||||
onblur={onLeave}
|
||||
class="inline-flex"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Show metadata"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
{#if show && entries.length > 0}
|
||||
<div
|
||||
bind:this={tooltipEl}
|
||||
style={tooltipStyle}
|
||||
class="fixed px-3 py-2 bg-gray-900 text-white text-sm rounded-md z-50 normal-case min-w-[12rem] max-w-[24rem] shadow-lg whitespace-normal"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -41,6 +41,7 @@ export interface ActivityLogEntry {
|
||||
tokens: TokenMetrics;
|
||||
duration_ms: number;
|
||||
has_capture: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ReqRespCapture {
|
||||
|
||||
@@ -2,25 +2,13 @@
|
||||
import { metrics, getCapture } from "../stores/api";
|
||||
import ActivityStats from "../components/ActivityStats.svelte";
|
||||
import Tooltip from "../components/Tooltip.svelte";
|
||||
import MetadataTooltip from "../components/MetadataTooltip.svelte";
|
||||
import CaptureDialog from "../components/CaptureDialog.svelte";
|
||||
import { persistentStore } from "../stores/persistent";
|
||||
import { onMount } from "svelte";
|
||||
import type { ReqRespCapture } from "../lib/types";
|
||||
|
||||
type ColumnKey =
|
||||
| "id"
|
||||
| "time"
|
||||
| "model"
|
||||
| "req_path"
|
||||
| "resp_status_code"
|
||||
| "resp_content_type"
|
||||
| "cached"
|
||||
| "prompt"
|
||||
| "generated"
|
||||
| "prompt_speed"
|
||||
| "gen_speed"
|
||||
| "duration"
|
||||
| "capture";
|
||||
type ColumnKey = string;
|
||||
|
||||
interface ColumnDef {
|
||||
key: ColumnKey;
|
||||
@@ -42,17 +30,21 @@
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const defaultVisibleKeys = columns.filter((c) => c.defaultVisible).map((c) => c.key);
|
||||
|
||||
const visibleColumns = persistentStore<ColumnKey[]>(
|
||||
"activity-columns",
|
||||
defaultVisibleKeys
|
||||
const visibleColumns = persistentStore<ColumnKey[]>("activity-columns", defaultVisibleKeys);
|
||||
const columnOrder = persistentStore<ColumnKey[]>(
|
||||
"activity-column-order",
|
||||
columns.map((c) => c.key)
|
||||
);
|
||||
|
||||
let columnsMenuOpen = $state(false);
|
||||
let dropdownContainer: HTMLDivElement | null = null;
|
||||
let dragKey: ColumnKey | null = $state(null);
|
||||
let dragOverKey: ColumnKey | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -84,6 +76,84 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let orderedColumns = $derived(
|
||||
columns.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 activeVisibleColumns = $derived(
|
||||
columns
|
||||
.filter((c) => isColumnVisible(c.key))
|
||||
.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;
|
||||
})
|
||||
.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 {
|
||||
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
|
||||
}
|
||||
@@ -157,22 +227,37 @@
|
||||
</svg>
|
||||
</button>
|
||||
{#if columnsMenuOpen}
|
||||
<div class="absolute right-0 top-full mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10 py-1 min-w-[16rem]">
|
||||
<div class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-white/10">
|
||||
<div class="absolute right-0 top-full mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10 py-1 min-w-[16rem]" role="list">
|
||||
<div class="px-3 py-2 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-white/10" role="presentation">
|
||||
Columns
|
||||
</div>
|
||||
{#each columns as col (col.key)}
|
||||
<label
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer hover:bg-secondary-hover transition-colors"
|
||||
{#each orderedColumns as col (col.key)}
|
||||
{@const key = col.key}
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-secondary-hover transition-colors {dragOverKey === key && dragKey !== key ? 'bg-primary/10 ring-1 ring-primary/40' : ''} {dragKey === key ? 'opacity-40' : ''}"
|
||||
role="listitem"
|
||||
ondragover={(e) => handleDragOver(e, key)}
|
||||
ondrop={(e) => handleDrop(e, key)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={$visibleColumns.includes(col.key)}
|
||||
onchange={() => toggleColumn(col.key)}
|
||||
class="rounded"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
<span
|
||||
class="text-txtsecondary select-none cursor-grab"
|
||||
draggable={true}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label="Drag to reorder {col.label}"
|
||||
ondragstart={(e) => handleDragStart(e, key)}
|
||||
ondragend={handleDragEnd}
|
||||
>⋮⋮</span>
|
||||
<label class="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isColumnVisible(key)}
|
||||
onchange={() => toggleColumn(key)}
|
||||
class="rounded"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -182,112 +267,80 @@
|
||||
<table class="min-w-full divide-y">
|
||||
<thead class="border-gray-200 dark:border-white/10">
|
||||
<tr class="text-left text-xs uppercase tracking-wider">
|
||||
{#if $visibleColumns.includes("id")}
|
||||
<th class="px-6 py-3">ID</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("time")}
|
||||
<th class="px-6 py-3">Time</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("model")}
|
||||
<th class="px-6 py-3">Model</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("req_path")}
|
||||
<th class="px-6 py-3">Path</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("resp_status_code")}
|
||||
<th class="px-6 py-3">Status</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("resp_content_type")}
|
||||
<th class="px-6 py-3">Content-Type</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("cached")}
|
||||
{#each activeVisibleColumns as key (key)}
|
||||
<th class="px-6 py-3">
|
||||
Cached <Tooltip content="prompt tokens from cache" />
|
||||
{#if key === "cached"}
|
||||
Cached <Tooltip content="prompt tokens from cache" />
|
||||
{:else if key === "prompt"}
|
||||
Prompt <Tooltip content="new prompt tokens processed" />
|
||||
{:else}
|
||||
{columnLabelMap[key] ?? key}
|
||||
{/if}
|
||||
</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("prompt")}
|
||||
<th class="px-6 py-3">
|
||||
Prompt <Tooltip content="new prompt tokens processed" />
|
||||
</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("generated")}
|
||||
<th class="px-6 py-3">Generated</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("prompt_speed")}
|
||||
<th class="px-6 py-3">Prompt Speed</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("gen_speed")}
|
||||
<th class="px-6 py-3">Gen Speed</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("duration")}
|
||||
<th class="px-6 py-3">Duration</th>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("capture")}
|
||||
<th class="px-6 py-3">Capture</th>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#if sortedMetrics.length === 0}
|
||||
<tr>
|
||||
<td colspan={$visibleColumns.length} class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<td colspan={activeVisibleColumns.length} class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No activity recorded
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each sortedMetrics as metric (metric.id)}
|
||||
<tr class="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
|
||||
{#if $visibleColumns.includes("id")}
|
||||
<td class="px-4 py-4">{metric.id + 1}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("time")}
|
||||
<td class="px-6 py-4">{formatRelativeTime(metric.timestamp)}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("model")}
|
||||
<td class="px-6 py-4">{metric.model}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("req_path")}
|
||||
<td class="px-6 py-4">{metric.req_path || "-"}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("resp_status_code")}
|
||||
<td class="px-6 py-4">{metric.resp_status_code || "-"}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("resp_content_type")}
|
||||
<td class="px-6 py-4">{metric.resp_content_type || "-"}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("cached")}
|
||||
<td class="px-6 py-4">{metric.tokens.cache_tokens > 0 ? metric.tokens.cache_tokens.toLocaleString() : "-"}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("prompt")}
|
||||
<td class="px-6 py-4">{metric.tokens.input_tokens.toLocaleString()}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("generated")}
|
||||
<td class="px-6 py-4">{metric.tokens.output_tokens.toLocaleString()}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("prompt_speed")}
|
||||
<td class="px-6 py-4">{formatSpeed(metric.tokens.prompt_per_second)}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("gen_speed")}
|
||||
<td class="px-6 py-4">{formatSpeed(metric.tokens.tokens_per_second)}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("duration")}
|
||||
<td class="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
|
||||
{/if}
|
||||
{#if $visibleColumns.includes("capture")}
|
||||
{#each activeVisibleColumns as key (key)}
|
||||
<td class="px-6 py-4">
|
||||
{#if metric.has_capture}
|
||||
<button
|
||||
onclick={() => viewCapture(metric.id)}
|
||||
disabled={loadingCaptureId === metric.id}
|
||||
class="btn btn--sm"
|
||||
>
|
||||
{loadingCaptureId === metric.id ? "..." : "View"}
|
||||
</button>
|
||||
{#if key === "id"}
|
||||
{metric.id + 1}
|
||||
{:else if key === "time"}
|
||||
{formatRelativeTime(metric.timestamp)}
|
||||
{:else if key === "model"}
|
||||
{metric.model}
|
||||
{:else if key === "req_path"}
|
||||
{metric.req_path || "-"}
|
||||
{:else if key === "resp_status_code"}
|
||||
{metric.resp_status_code || "-"}
|
||||
{: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 === "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
|
||||
onclick={() => viewCapture(metric.id)}
|
||||
disabled={loadingCaptureId === metric.id}
|
||||
class="btn btn--sm"
|
||||
>
|
||||
{loadingCaptureId === metric.id ? "..." : "View"}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-txtsecondary">-</span>
|
||||
{/if}
|
||||
{:else if key === "meta"}
|
||||
{#if Object.keys(metric.metadata || {}).length > 0}
|
||||
<MetadataTooltip metadata={metric.metadata}>
|
||||
<span class="cursor-help text-txtsecondary hover:text-txtmain">...</span>
|
||||
</MetadataTooltip>
|
||||
{:else}
|
||||
<span class="text-txtsecondary">-</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-txtsecondary">-</span>
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user