feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot

This commit is contained in:
hh committed 2026-08-28 22:14:15 +02:00
1 parent 61562e947d
commit 98796a82c6
158 files changed
+8459 -2531

No files matched your search

+428 -5
View File
@@ -1,5 +1,428 @@
<h1>Welcome to SvelteKit</h1>
<p>
Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read
the documentation
</p>
<script lang="ts">
import { onMount } from "svelte";
import { base } from "$app/paths";
import type {
AgentsResponse,
SessionsResponse,
UsageResponse,
} from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import KindBadge from "$lib/components/kind-badge.svelte";
import LimitBar from "$lib/components/limit-bar.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import {
cacheShare,
elapsedMs,
fmtBytes,
fmtDuration,
fmtMoney,
fmtPct,
fmtSeconds,
fmtTime,
fmtTokens,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const HOURS_5H = 5;
const HOURS_WEEK = 168;
const SESSIONS_POLL_MS = 10_000;
const TICK_MS = 1000;
let sessions = $state<SessionsResponse | null>(null);
let usage5h = $state<UsageResponse | null>(null);
let usageWeek = $state<UsageResponse | null>(null);
let agents = $state<AgentsResponse | null>(null);
let failure = $state<string | null>(null);
let now = $state(Date.now());
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
[sessions, usage5h, usageWeek, agents] = await Promise.all([
client.sessions(),
client.usage({ group_by: "agent", hours: HOURS_5H }),
client.usage({ group_by: "agent", hours: HOURS_WEEK }),
client.agents(),
]);
await gateway.refreshLimits();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
async function pollSessions() {
const { client } = session;
if (!client) {
return;
}
sessions = await client.sessions().catch(() => sessions);
}
onMount(() => {
load();
const poll = setInterval(pollSessions, SESSIONS_POLL_MS);
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => {
clearInterval(poll);
clearInterval(tick);
};
});
const running = $derived(gateway.running);
const subtitle = $derived.by(() => {
if (running.length > 0) {
return `${running.length} running`;
}
return gateway.loaded ? "idle" : "";
});
const windows = $derived(gateway.limits?.windows ?? []);
const tape = $derived(gateway.tape.slice(0, 40));
function frontendUrl(port: number | null, publicBase: string | null) {
if (publicBase) {
return publicBase;
}
return port
? `${window.location.protocol}//${window.location.hostname}:${port}`
: "";
}
</script>
<svelte:head><title>Now · Beaver</title></svelte:head>
<PageHeader {subtitle} title="Now" />
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-8 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{/if}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Running
</h2>
{#if !gateway.loaded}
<Skeleton class="h-10 w-full" />
{:else if running.length === 0}
<EmptyState
hint="Nothing is in a turn. The ledger fills in the moment a message or an inject lands."
title="The agents are idle"
/>
{:else}
<ul class="flex flex-col">
{#each running as row (row.id)}
<li>
<a
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)_auto] border-b py-2 text-sm"
href="{base}/conversations/{row.id}"
>
<span
class="size-2 animate-pulse-dot rounded-full bg-signal"
></span>
<KindBadge kind={row.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{row.title || `${row.kind} ${shortId(row.id)}`}
</span>
<span class="truncate text-muted-foreground text-xs">
{row.agent}
{row.pending_question ? " · waiting for an answer" : ""}
</span>
</span>
<span class="tabular text-muted-foreground text-xs">
{fmtDuration(elapsedMs(row.last_activity_at ?? "", null, now))}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Subscription quota
</h2>
{#if windows.length === 0}
<EmptyState
hint="The SDK reports a window the first time its state changes; until then there is nothing to show."
title="No rate-limit reports yet"
/>
{:else}
<div class="flex flex-col divide-y">
{#each windows as w (w.window)}
<LimitBar {now} window={w} />
{/each}
</div>
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Spend via gateway
</h2>
{#if usage5h && usageWeek}
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">range</th>
<th class="py-1.5 pr-3 text-right font-medium">cost</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 pr-3 text-right font-medium">in / out</th>
<th class="py-1.5 pr-3 text-right font-medium">cache share</th>
<th class="py-1.5 text-right font-medium">written</th>
</tr>
</thead>
<tbody>
{#each [["last 5 h", usage5h.total], ["last 7 d", usageWeek.total]] as [label, total] (label)}
{@const t = total as UsageResponse["total"]}
{@const share = cacheShare(t.input, t.cache_read)}
<tr class="border-b">
<td class="py-1.5 pr-3 font-medium">{label}</td>
<td class="tabular py-1.5 pr-3 text-right font-semibold">
{fmtMoney(t.cost_usd)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{t.turns}</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(t.input)}
/ {fmtTokens(t.output)}
</td>
<td
class={cn("tabular py-1.5 pr-3 text-right", share !== null && share < 0.5 && t.turns > 0 && "text-warn")}
>
{fmtPct(share)}
</td>
<td class="tabular py-1.5 text-right">
{fmtTokens(t.cache_creation)}
</td>
</tr>
{/each}
</tbody>
</table>
<a class="text-link text-xs hover:underline" href="{base}/usage">
Full breakdown →
</a>
{:else}
<Skeleton class="h-32 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<div class="flex items-baseline gap-3">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Live sessions
</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
processes · {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#if !sessions}
<Skeleton class="h-10 w-full" />
{:else if sessions.sessions.length === 0}
<EmptyState
hint="Sessions spawn on the first turn and are reaped by idle time or memory pressure."
title="No claude processes alive"
/>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">agent</th>
<th class="py-1.5 pr-3 font-medium">kind</th>
<th class="py-1.5 pr-3 font-medium">state</th>
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
<th class="py-1.5 pr-3 text-right font-medium">idle</th>
<th class="py-1.5 pr-3 text-right font-medium">age</th>
<th class="py-1.5 pr-3 text-right font-medium">turns</th>
<th class="py-1.5 font-medium">conversation</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b">
<td class="py-1.5 pr-3 font-medium">{s.agent}</td>
<td class="py-1.5 pr-3"><KindBadge kind={s.kind} /></td>
<td class="py-1.5 pr-3 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-link">question</span>
{/if}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtBytes(s.rss)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular py-1.5 pr-3 text-right">{s.turns}</td>
<td class="tabular py-1.5 text-xs">
<a
class="text-link hover:underline"
href="{base}/conversations/{s.key}"
>
{shortId(s.key)}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Agents
</h2>
{#if agents}
<ul class="flex flex-col">
{#each agents.agents as a (a.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{a.name}</span>
<span class="truncate text-muted-foreground text-xs">
{a.model}{a.effort ? ` · ${a.effort}` : ""}
</span>
</span>
<span class="flex gap-1">
{#each a.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
<div class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Frontends
</h2>
{#if agents}
<ul class="flex flex-col">
{#each agents.frontends as fe (fe.type + fe.name)}
<li
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b py-1.5 text-sm"
>
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">{fe.name}</span>
<span class="truncate text-muted-foreground text-xs">
{frontendUrl(fe.port, fe.public_base_url) || fe.type}
{#if Object.keys(fe.default_agents).length > 0}
·
{Object.entries(fe.default_agents)
.map(([k, v]) => `${k}→${v}`)
.join(", ")}
{/if}
</span>
</span>
<span class="flex gap-1">
{#each fe.kinds as k (k)}
<KindBadge kind={k} />
{/each}
</span>
</li>
{/each}
</ul>
{:else}
<Skeleton class="h-20 w-full" />
{/if}
</div>
</section>
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Event tape
</h2>
{#if tape.length === 0}
<p class="text-muted-foreground text-xs">
Quiet. Bus events (turns, tools, injects, questions) scroll here as
they happen.
</p>
{:else}
<ul class="flex flex-col font-mono text-xs">
{#each tape as item (item.seq)}
<li class="ledger-grid grid-cols-[5rem_8rem_minmax(0,1fr)] py-0.5">
<span class="tabular text-muted-foreground"
>{fmtTime(item.ts)}</span
>
<span class="truncate">{item.type}</span>
<span class="truncate text-muted-foreground">
{#if item.conversation_id}
<a
class="hover:underline"
href="{base}/conversations/{item.conversation_id}"
>
{shortId(item.conversation_id)}
</a>
{/if}
{#if typeof item.name === "string"}
{item.name}
{/if}
{#if typeof item.text === "string"}
{item.text.slice(0, 80)}
{/if}
{#if typeof item.stop === "string"}
{item.stop}
{/if}
</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
</div>