feat(ui,api,admin): sveltekit admin spa, usage by day/agent/conversation/model, rate limits, memory tree, turn snapshot
This commit is contained in:
1 parent
61562e947d
commit
98796a82c6
158 files changed
+8459
-2531
No files matched your search
+428
-5
@@ -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>
|
||||
Reference in new issue
Block a user