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