diff --git a/ui-svelte/src/App.svelte b/ui-svelte/src/App.svelte index 895a057f..89b1463a 100644 --- a/ui-svelte/src/App.svelte +++ b/ui-svelte/src/App.svelte @@ -3,7 +3,7 @@ import Router from "svelte-spa-router"; import AppSidebar from "./components/AppSidebar.svelte"; import LogViewer from "./routes/LogViewer.svelte"; - import Models from "./routes/Models.svelte"; + import ModelDetail from "./routes/ModelDetail.svelte"; import Activity from "./routes/Activity.svelte"; import Performance from "./routes/Performance.svelte"; import Playground from "./routes/Playground.svelte"; @@ -17,7 +17,7 @@ const routes = { "/": PlaygroundStub, - "/models": Models, + "/models/:id": ModelDetail, "/logs": LogViewer, "/activity": Activity, "/performance": Performance, @@ -26,7 +26,6 @@ const routeTitles: Record = { "/": "Playground", - "/models": "Models", "/activity": "Activity", "/logs": "Logs", "/performance": "Performance", @@ -37,12 +36,19 @@ const tab = playgroundTabs.find((t) => t.id === $selectedPlaygroundTab); return `Playground / ${tab?.label ?? ""}`; } + if ($currentRoute.startsWith("/models/")) { + const id = $currentRoute.slice("/models/".length); + return id ? `Models / ${decodeURIComponent(id)}` : "Models"; + } return routeTitles[$currentRoute] ?? "Playground"; }); - function handleRouteLoaded(event: { detail: { route: string | RegExp } }) { + function handleRouteLoaded(event: { detail: { route: string | RegExp; location?: string } }) { const route = event.detail.route; - currentRoute.set(typeof route === "string" ? route : "/"); + // Prefer the actual URL path so parameterised routes (e.g. /models/:id) + // are reflected accurately in currentRoute for sidebar highlighting. + const loc = event.detail.location; + currentRoute.set(loc ?? (typeof route === "string" ? route : "/")); } $effect(() => { diff --git a/ui-svelte/src/components/AppSidebar.svelte b/ui-svelte/src/components/AppSidebar.svelte index 603f4d67..46ac4cb0 100644 --- a/ui-svelte/src/components/AppSidebar.svelte +++ b/ui-svelte/src/components/AppSidebar.svelte @@ -1,21 +1,18 @@ @@ -118,7 +83,7 @@ {#snippet child({ props })} @@ -134,30 +99,12 @@ {#each $models as model (model.id)} {#snippet child({ props })} - + {model.id} - {/snippet} diff --git a/ui-svelte/src/components/LogPanel.svelte b/ui-svelte/src/components/LogPanel.svelte index a5824498..519e74f0 100644 --- a/ui-svelte/src/components/LogPanel.svelte +++ b/ui-svelte/src/components/LogPanel.svelte @@ -3,6 +3,7 @@ import { Type, WrapText, Search, SearchX, CircleX } from "@lucide/svelte"; import { Button } from "$lib/components/ui/button/index.js"; import { Input } from "$lib/components/ui/input/index.js"; + import * as Card from "$lib/components/ui/card/index.js"; interface Props { id: string; @@ -84,11 +85,10 @@ }); -
-
-
-

{title}

- + + + {title} +
-
- + {#if $showFilterStore} -
+
{/if} -
-
+ +
{filteredLogs}
-
-
+ + diff --git a/ui-svelte/src/components/ModelsPanel.svelte b/ui-svelte/src/components/ModelsPanel.svelte deleted file mode 100644 index f0fb3f3c..00000000 --- a/ui-svelte/src/components/ModelsPanel.svelte +++ /dev/null @@ -1,214 +0,0 @@ - - - - -
- Models - - {#if $isNarrow} - - - {#snippet child({ props })} - - {/snippet} - - - - - {$showIdorNameStore === "id" ? "Show Name" : "Show ID"} - - - {#if $showUnlistedStore}{:else}{/if} - {$showUnlistedStore ? "Hide Unlisted" : "Show Unlisted"} - - - - - {isUnloading ? "Unloading..." : "Unload All"} - - - - {:else} -
- - - -
- {/if} -
-
- - - - - - - - - - - - {#each filteredModels.regularModels as model (model.id)} - - - - - - {/each} - -
{$showIdorNameStore === "id" ? "Model ID" : "Name"}State
- - {getModelDisplay(model)} - - {#if model.description} -

{model.description}

- {/if} - {#if model.aliases && model.aliases.length > 0} -

Aliases: {model.aliases.join(", ")}

- {/if} -
- {#if model.state === "stopped" && pendingLoads[model.id]} - - {:else if model.state === "stopped"} - - {:else} - - {/if} - - {#if model.state === "stopped" && pendingLoads[model.id]} - queued - {:else} - {model.state} - {/if} -
- - {#if Object.keys(filteredModels.peerModelsByPeerId).length > 0} -

Peer Models

- {#each Object.entries(filteredModels.peerModelsByPeerId).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)} -
- - - - - - - - {#each peerModels as model (model.id)} - - - - {/each} - -
{peerId}
- {model.id} -
-
- {/each} - {/if} -
-
diff --git a/ui-svelte/src/routes/ModelDetail.svelte b/ui-svelte/src/routes/ModelDetail.svelte new file mode 100644 index 00000000..27290649 --- /dev/null +++ b/ui-svelte/src/routes/ModelDetail.svelte @@ -0,0 +1,605 @@ + + +
+ {#if !model} + +

Model “{modelId}” not found.

+ Back to Playground +
+ {:else} + + +
+ + {model.name || model.id} + ({model.id}) + {model.state} +
+ + + + +
+
+ {#if model.description} +

{model.description}

+ {/if} + {#if model.aliases && model.aliases.length > 0} +

Aliases: {model.aliases.join(", ")}

+ {/if} +
+
+ + + + Activity + Logs + Details + + + + + + + + Recent Activity + ({modelMetrics.length}) + +
+ Per page + pageSizeStore.set(Number(v))} + > + {$pageSizeStore} + + {#each [5, 10, 25, 50] as size (size)} + {size} + {/each} + + +
+
+ + {#if columnsMenuOpen} +
+ + {#each orderedColumns as col (col.key)} + {@const key = col.key} +
handleDragOver(e, key)} + ondrop={(e) => handleDrop(e, key)} + > + handleDragStart(e, key)} + ondragend={handleDragEnd} + > + + + +
+ {/each} +
+ {/if} +
+
+
+
+ + + + + {#each activeVisibleColumns as key (key)} + + {/each} + + + + {#if pageMetrics.length === 0} + + + + {:else} + {#each pageMetrics as metric (metric.id)} + + {#each activeVisibleColumns as key (key)} + + {/each} + + {/each} + {/if} + +
+ {#if key === "cached"} + Cached + {:else if key === "prompt"} + Prompt + {:else if key === "drafted"} + Drafted + {:else} + {columnLabelMap[key] ?? key} + {/if} +
+ No activity recorded for this model +
+ {#if key === "id"} + {metric.id + 1} + {:else if key === "time"} + {formatRelativeTime(metric.timestamp)} + {:else if key === "req_path"} + {metric.req_path || "-"} + {:else if key === "resp_status_code"} + {#if metric.error_msg} + + {metric.resp_status_code || "-"} + + {: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} + + {:else} + - + {/if} + {:else if key === "meta"} + {#if Object.keys(metric.metadata || {}).length > 0} + + ... + + {:else} + - + {/if} + {:else} + - + {/if} +
+ + {#if modelMetrics.length > 0} +
+ + Page {page + 1} of {totalPages} · {modelMetrics.length} total + +
+ + + + +
+
+ {/if} +
+
+
+ + + + + + + + + + + Capabilities + + + {#if capabilities.length === 0} + No capabilities reported. + {:else} +
+ {#each capabilities as [key] (key)} + + {capabilityLabels[key] ?? key} + + {/each} +
+ {/if} +
+
+
+
+ {/if} +
+ + diff --git a/ui-svelte/src/routes/Models.svelte b/ui-svelte/src/routes/Models.svelte deleted file mode 100644 index ba1361e6..00000000 --- a/ui-svelte/src/routes/Models.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - - - {#snippet leftPanel()} - - {/snippet} - {#snippet rightPanel()} - - {/snippet} - diff --git a/ui-svelte/src/stores/modelLogs.ts b/ui-svelte/src/stores/modelLogs.ts new file mode 100644 index 00000000..769c9a7d --- /dev/null +++ b/ui-svelte/src/stores/modelLogs.ts @@ -0,0 +1,63 @@ +import { writable, type Readable } from "svelte/store"; + +const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */ + +/** + * Stream a model's log tail by opening a long-lived fetch to + * GET /logs/stream/{modelId} and accumulating text into a store. The + * returned store is Readable: callers never write to it. The stream is + * closed automatically when the last subscriber unsubscribes. + */ +export function streamModelLog(modelId: string): Readable { + const store = writable(""); + let controller: AbortController | null = null; + let started = false; + + async function run() { + controller = new AbortController(); + try { + const res = await fetch(`/logs/stream/${encodeURIComponent(modelId)}`, { + method: "GET", + signal: controller.signal, + }); + if (!res.ok || !res.body) { + store.set(`Failed to load logs (HTTP ${res.status})\n`); + return; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let acc = ""; + // eslint-disable-next-line no-constant-condition + while (true) { + const { value, done } = await reader.read(); + if (done) break; + acc += decoder.decode(value, { stream: true }); + if (acc.length > LOG_LENGTH_LIMIT) { + acc = acc.slice(-LOG_LENGTH_LIMIT); + } + store.set(acc); + } + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; + console.error(`Failed to stream logs for ${modelId}:`, err); + store.set(`Failed to load logs: ${String(err)}\n`); + } + } + + return { + subscribe(sub: (v: string) => void) { + const unsub = store.subscribe(sub); + if (!started) { + started = true; + void run(); + } + return () => { + unsub(); + controller?.abort(); + controller = null; + started = false; + store.set(""); + }; + }, + }; +}