ui: add ModelsDash route, clickable sidebar headings, and dialog tweaks

- Add /models route (ModelsDash) with unload-all, model list with
  start/stop buttons, and show-unlisted toggle
- Make sidebar Models and Playground headings navigate to their pages
  while the chevron independently expands/collapses the section
- Extract shared model load/unload orchestration into modelLoad store
- Left-align model names in the ConcurrencyInterface load-test list
- Widen CaptureDialog to 90% with flex-based scroll overflow

- Use sm:max-w-[90%] to override the shadcn dialog's sm:max-w-sm cap
This commit is contained in:
Benson Wong
2026-06-28 03:04:04 +00:00
parent 55c3678906
commit 82cad1b84e
7 changed files with 276 additions and 82 deletions
+6
View File
@@ -4,6 +4,7 @@
import AppSidebar from "./components/AppSidebar.svelte";
import LogViewer from "./routes/LogViewer.svelte";
import ModelDetail from "./routes/ModelDetail.svelte";
import ModelsDash from "./routes/ModelsDash.svelte";
import Activity from "./routes/Activity.svelte";
import Performance from "./routes/Performance.svelte";
import Playground from "./routes/Playground.svelte";
@@ -17,6 +18,7 @@
const routes = {
"/": PlaygroundStub,
"/models": ModelsDash,
"/models/:id": ModelDetail,
"/logs": LogViewer,
"/activity": Activity,
@@ -26,6 +28,7 @@
const routeTitles: Record<string, string> = {
"/": "Playground",
"/models": "Models",
"/activity": "Activity",
"/logs": "Logs",
"/performance": "Performance",
@@ -40,6 +43,9 @@
const id = $currentRoute.slice("/models/".length);
return id ? `Models / ${decodeURIComponent(id)}` : "Models";
}
if ($currentRoute === "/models") {
return "Models";
}
return routeTitles[$currentRoute] ?? "Playground";
});
+52 -20
View File
@@ -79,21 +79,37 @@
onOpenChange={(v) => modelsMenuOpen.set(v)}
class="gap-0"
>
<Collapsible.Trigger>
<Sidebar.MenuButton
isActive={$currentRoute.startsWith("/models")}
tooltipContent="Models"
>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
isActive={$currentRoute.startsWith("/models")}
tooltipContent="Models"
>
<a href="/models" use:link {...props}>
<Boxes />
<span>Models</span>
<ChevronRight
<span
class="ml-auto transition-transform duration-200 {$modelsMenuOpen ? 'rotate-90' : ''}"
/>
</Sidebar.MenuButton>
role="button"
tabindex="0"
aria-label="Toggle models section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
modelsMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Collapsible.Trigger>
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each $models as model (model.id)}
@@ -121,21 +137,37 @@
onOpenChange={(v) => playgroundMenuOpen.set(v)}
class="gap-0"
>
<Collapsible.Trigger>
<Sidebar.MenuButton
isActive={isActive("/", $currentRoute)}
tooltipContent="Playground"
>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
isActive={isActive("/", $currentRoute)}
tooltipContent="Playground"
>
<a href="/" use:link {...props}>
<House />
<span class={$playgroundActivity ? "activity-link" : ""}>Playground</span>
<ChevronRight
<span
class="ml-auto transition-transform duration-200 {$playgroundMenuOpen ? 'rotate-90' : ''}"
/>
</Sidebar.MenuButton>
role="button"
tabindex="0"
aria-label="Toggle playground section"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
playgroundMenuOpen.update((v) => !v);
}
}}
>
<ChevronRight />
</span>
</a>
{/snippet}
</Collapsible.Trigger>
</Sidebar.MenuButton>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each playgroundTabs as tab (tab.id)}
@@ -184,7 +184,7 @@
if (!v) onclose();
}}
>
<Dialog.Content class="max-h-[90vh] w-full max-w-[80%] gap-0 p-0">
<Dialog.Content class="flex max-h-[90vh] w-[90%] sm:max-w-[90%] flex-col gap-0 p-0">
{#if capture}
<Dialog.Header class="border-b border-border px-4 py-3">
<Dialog.Title class="text-lg font-bold">
@@ -193,7 +193,7 @@
</Dialog.Title>
</Dialog.Header>
<div class="overflow-y-auto flex-1 space-y-4 p-4">
<div class="min-h-0 flex-1 overflow-y-auto space-y-4 p-4">
<!-- Request Headers -->
<details class="group" open>
<summary
@@ -396,22 +396,20 @@
{#if !hasModels}
<div class="p-3 text-sm text-muted-foreground text-center">No models configured.</div>
{:else}
<ul class="divide-y divide-gray-100 dark:divide-white/5">
<div class="divide-y divide-gray-100 dark:divide-white/5">
{#each availableModels as m (m.id)}
<li>
<Button
variant="ghost"
class="w-full justify-start px-2 py-1.5 text-sm h-auto font-normal"
onclick={() => addModel(m.id)}
disabled={isRunning}
title="Add {m.id}"
>
<span class="text-primary" aria-hidden="true">+</span>
<span class="truncate flex-1">{m.id}</span>
</Button>
</li>
<button
type="button"
class="hover:bg-accent hover:text-foreground flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm font-normal transition-colors disabled:pointer-events-none disabled:opacity-50"
onclick={() => addModel(m.id)}
disabled={isRunning}
title="Add {m.id}"
>
<span class="text-primary" aria-hidden="true">+</span>
<span class="truncate flex-1">{m.id}</span>
</button>
{/each}
</ul>
</div>
{/if}
</div>
</div>
+5 -46
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { params } from "svelte-spa-router";
import { models, loadModel, unloadSingleModel } from "../stores/api";
import { models } from "../stores/api";
import { pendingLoads, onToggleLoad, statusDotColor } from "../stores/modelLoad";
import type { Model } from "../lib/types";
import * as Card from "$lib/components/ui/card/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
@@ -12,48 +13,6 @@
let modelId = $derived($params?.id ?? "");
let model = $derived<Model | undefined>($models.find((m) => m.id === modelId));
// Load / unload orchestration (ported from AppSidebar.svelte)
let pendingLoads = $state<Record<string, boolean>>({});
const loadControllers = new Map<string, AbortController>();
async function handleLoadModel(id: string): Promise<void> {
if (pendingLoads[id]) return;
const controller = new AbortController();
loadControllers.set(id, controller);
pendingLoads[id] = true;
try {
await loadModel(id, controller.signal);
} catch (e) {
console.error(e);
} finally {
loadControllers.delete(id);
delete pendingLoads[id];
}
}
function cancelLoad(id: string): void {
loadControllers.get(id)?.abort();
}
function onToggleLoad(e: MouseEvent, m: Model): void {
e.preventDefault();
e.stopPropagation();
if (m.state === "stopped" && pendingLoads[m.id]) {
cancelLoad(m.id);
} else if (m.state === "stopped") {
handleLoadModel(m.id);
} else if (m.state === "ready") {
unloadSingleModel(m.id);
}
}
function statusDotColor(m: Model | undefined): string {
if (!m) return "bg-muted-foreground/40";
if (m.state === "ready") return "bg-success";
if (m.state === "starting" || m.state === "stopping") return "bg-warning";
return "bg-muted-foreground/40";
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-2">
@@ -84,12 +43,12 @@
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
title={model.state === "ready" ? "Unload" : pendingLoads[model.id] ? "Cancel" : "Load"}
title={model.state === "ready" ? "Unload" : $pendingLoads[model.id] ? "Cancel" : "Load"}
aria-label={model.state === "ready" ? "Unload model" : "Load model"}
disabled={model.state === "starting" || model.state === "stopping"}
onclick={(e) => onToggleLoad(e, model)}
onclick={() => onToggleLoad(model)}
>
{#if pendingLoads[model.id] && model.state === "stopped"}
{#if $pendingLoads[model.id] && model.state === "stopped"}
<Loader2 class="size-3.5 animate-spin" />
{:else if model.state === "ready"}
<PowerOff class="size-3.5" />
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import { link } from "svelte-spa-router";
import { models, unloadAllModels } from "../stores/api";
import { pendingLoads, onToggleLoad, statusDotColor } from "../stores/modelLoad";
import { persistentStore } from "../stores/persistent";
import * as Card from "$lib/components/ui/card/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import * as Switch from "$lib/components/ui/switch/index.js";
import * as Label from "$lib/components/ui/label/index.js";
import { Play, PowerOff, Loader2, ExternalLink, SquareStack, Eye } from "@lucide/svelte";
const showUnlisted = persistentStore<boolean>("models-dash-show-unlisted", false);
let unloadingAll = $state(false);
let visibleModels = $derived(
showUnlisted ? $models : $models.filter((m) => !m.unlisted)
);
let readyCount = $derived($models.filter((m) => m.state === "ready").length);
let anyReady = $derived(readyCount > 0);
async function handleUnloadAll(): Promise<void> {
unloadingAll = true;
try {
await unloadAllModels();
} catch (e) {
console.error(e);
} finally {
unloadingAll = false;
}
}
</script>
<div class="flex h-full flex-col gap-4 overflow-y-auto p-2">
<Card.Root class="shrink-0 gap-0 overflow-hidden py-0">
<Card.Header class="shrink-0 gap-2 border-b px-4 py-3">
<div class="flex items-center gap-2">
<SquareStack class="size-5" />
<Card.Title class="text-lg">Models</Card.Title>
<span class="text-muted-foreground text-sm">
({visibleModels.length} of {$models.length})
</span>
<span class="text-muted-foreground text-xs uppercase tracking-wide">
{readyCount} ready
</span>
<div class="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={handleUnloadAll}
disabled={!anyReady || unloadingAll}
>
{#if unloadingAll}
<Loader2 class="size-3.5 animate-spin" />
{:else}
<PowerOff class="size-3.5" />
{/if}
Unload All
</Button>
</div>
</div>
</Card.Header>
<Card.Content class="flex items-center gap-2 px-4 py-2">
<Eye class="text-muted-foreground size-4" />
<Label.Root for="show-unlisted-toggle" class="text-sm">
Show unlisted models
</Label.Root>
<Switch.Root
id="show-unlisted-toggle"
checked={$showUnlisted}
onCheckedChange={(v) => showUnlisted.set(v)}
/>
<span class="text-muted-foreground text-xs">
{$models.filter((m) => m.unlisted).length} unlisted
</span>
</Card.Content>
</Card.Root>
<Card.Root class="min-h-0 flex-1 gap-0 overflow-hidden py-0">
<Card.Content class="overflow-y-auto p-0">
{#if visibleModels.length === 0}
<div class="text-muted-foreground px-4 py-8 text-center text-sm">
No models available
</div>
{:else}
<div class="divide-y">
{#each visibleModels as model (model.id)}
<div class="hover:bg-muted/50 flex items-center gap-3 px-4 py-2.5">
<span class={`size-2.5 shrink-0 rounded-full ${statusDotColor(model)}`}></span>
<a
href="/models/{encodeURIComponent(model.id)}"
use:link
class="min-w-0 flex-1"
>
<div class="truncate text-sm font-medium">{model.name || model.id}</div>
<div class="text-muted-foreground truncate text-xs">
{model.id}
{#if model.aliases && model.aliases.length > 0}
· {model.aliases.join(", ")}
{/if}
</div>
</a>
<span class="text-muted-foreground text-xs uppercase tracking-wide">
{model.state}
</span>
{#if model.unlisted}
<span class="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[0.625rem] font-medium uppercase">
unlisted
</span>
{/if}
<a
href="/upstream/{encodeURIComponent(model.id)}/"
target="_blank"
rel="noopener noreferrer"
class="text-muted-foreground hover:text-foreground"
title="Open model server"
aria-label="Open model server"
>
<ExternalLink class="size-4" />
</a>
<button
type="button"
class="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
title={model.state === "ready" ? "Unload" : $pendingLoads[model.id] ? "Cancel" : "Load"}
aria-label={model.state === "ready" ? "Unload model" : "Load model"}
disabled={model.state === "starting" || model.state === "stopping"}
onclick={() => onToggleLoad(model)}
>
{#if $pendingLoads[model.id] && model.state === "stopped"}
<Loader2 class="size-4 animate-spin" />
{:else if model.state === "ready"}
<PowerOff class="size-4" />
{:else if model.state === "starting" || model.state === "stopping"}
<Loader2 class="size-4 animate-spin" />
{:else}
<Play class="size-4" />
{/if}
</button>
</div>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
+53
View File
@@ -0,0 +1,53 @@
import { writable } from "svelte/store";
import { loadModel, unloadSingleModel } from "./api";
import type { Model } from "../lib/types";
export const pendingLoads = writable<Record<string, boolean>>({});
const loadControllers = new Map<string, AbortController>();
export async function handleLoadModel(id: string): Promise<void> {
if (isPending(id)) return;
const controller = new AbortController();
loadControllers.set(id, controller);
pendingLoads.update((p) => ({ ...p, [id]: true }));
try {
await loadModel(id, controller.signal);
} catch (e) {
console.error(e);
} finally {
loadControllers.delete(id);
pendingLoads.update((p) => {
const next = { ...p };
delete next[id];
return next;
});
}
}
export function cancelLoad(id: string): void {
loadControllers.get(id)?.abort();
}
export function isPending(id: string): boolean {
let val = false;
pendingLoads.subscribe((p) => (val = !!p[id]))();
return val;
}
export function onToggleLoad(m: Model): void {
if (m.state === "stopped" && isPending(m.id)) {
cancelLoad(m.id);
} else if (m.state === "stopped") {
void handleLoadModel(m.id);
} else if (m.state === "ready") {
void unloadSingleModel(m.id);
}
}
export function statusDotColor(m: Model | undefined): string {
if (!m) return "bg-muted-foreground/40";
if (m.state === "ready") return "bg-success";
if (m.state === "starting" || m.state === "stopping") return "bg-warning";
return "bg-muted-foreground/40";
}