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
2026-08-28 22:14:15 +02:00
parent 61562e947d
commit 98796a82c6
158 changed files with 8459 additions and 2531 deletions
+56 -1
View File
@@ -1,9 +1,64 @@
<script lang="ts">
import "./layout.css";
import { ModeWatcher } from "mode-watcher";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import favicon from "$lib/assets/favicon.svg";
import AppSidebar from "$lib/components/app-sidebar.svelte";
import BottomNav from "$lib/components/bottom-nav.svelte";
import { Toaster } from "$lib/components/ui/sonner";
import * as Tooltip from "$lib/components/ui/tooltip";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`);
onMount(() => {
session.check();
});
$effect(() => {
if (!session.ready) {
return;
}
if (!(session.user || isLogin)) {
goto(`${base}/login`);
} else if (session.user && isLogin) {
goto(`${base}/`);
}
});
$effect(() => {
if (session.client) {
gateway.start();
return () => gateway.stop();
}
});
</script>
<svelte:head><link href={favicon} rel="icon"></svelte:head>
{@render children()}
<ModeWatcher />
<Toaster position="bottom-right" richColors />
<Tooltip.Provider delayDuration={300}>
{#if isLogin}
{@render children()}
{:else if session.ready && session.user}
<div class="flex h-dvh flex-col overflow-hidden sm:flex-row">
<AppSidebar />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
{@render children()}
</main>
<BottomNav />
</div>
{:else if session.ready && session.error}
<div class="flex h-dvh items-center justify-center p-6 text-sm">
<p class="text-destructive">{session.error}</p>
</div>
{/if}
</Tooltip.Provider>
+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>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from "svelte";
import type { AuditRecord } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Button } from "$lib/components/ui/button";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtDateTime } from "$lib/format";
import { session } from "$lib/session.svelte";
const PAGE = 100;
let records = $state<AuditRecord[] | null>(null);
let nextBefore = $state<number | null>(null);
let failure = $state<string | null>(null);
let busy = $state(false);
async function load(more = false) {
const { client } = session;
if (!client) {
return;
}
failure = null;
busy = true;
try {
const page = await client.audit({
before: more && nextBefore ? nextBefore : undefined,
limit: PAGE,
});
records = more ? [...(records ?? []), ...page.records] : page.records;
nextBefore = page.next_before;
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
onMount(() => {
load();
});
function detail(record: AuditRecord): string {
if (typeof record.detail === "string") {
return record.detail;
}
return Object.entries(record.detail)
.map(
([key, value]) =>
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`
)
.join(" ");
}
</script>
<svelte:head><title>Audit · Beaver</title></svelte:head>
<PageHeader title="Audit" />
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-3 px-4 py-4 sm:px-6">
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if records === null}
<Skeleton class="h-40 w-full" />
{:else if records.length === 0}
<EmptyState
hint="Logins, token changes and API writes land here."
title="Nothing audited yet"
/>
{:else}
<ul class="flex flex-col text-xs">
{#each records as record (record.id)}
<li
class="ledger-grid grid-cols-[9rem_7rem_9rem_minmax(0,1fr)] border-b py-1.5"
>
<span class="tabular text-muted-foreground"
>{fmtDateTime(record.ts)}</span
>
<span class="truncate font-medium">{record.kind}</span>
<span class="truncate text-muted-foreground">{record.actor}</span>
<span class="truncate font-mono text-muted-foreground">
{#if record.agent}
{record.agent}
·
{/if}
{detail(record)}
</span>
</li>
{/each}
</ul>
{#if nextBefore !== null}
<Button
class="self-start"
disabled={busy}
onclick={() => load(true)}
size="sm"
variant="outline"
>
Older
</Button>
{/if}
{/if}
</div>
</div>
+287
View File
@@ -0,0 +1,287 @@
<script lang="ts">
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { AgentInfo, ConversationSummary } 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 PageHeader from "$lib/components/page-header.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
const KINDS = ["all", "master", "branch", "deep", "job", "fork"];
const STATUSES = ["open", "all", "merged", "closed", "archived"];
let kind = $state("all");
let statusFilter = $state("open");
let search = $state("");
let createOpen = $state(false);
let agents = $state<AgentInfo[]>([]);
let form = $state({
agent: "",
kind: "deep",
seed: "clean",
text: "",
title: "",
});
let busy = $state(false);
const rows = $derived.by(() => {
const needle = search.trim().toLowerCase();
return gateway.conversations
.filter((row) => kind === "all" || row.kind === kind)
.filter((row) => statusFilter === "all" || row.status === statusFilter)
.filter(
(row) =>
!needle ||
(row.title ?? "").toLowerCase().includes(needle) ||
row.id.startsWith(needle) ||
row.agent.toLowerCase().includes(needle)
)
.sort(byActivity);
});
function byActivity(a: ConversationSummary, b: ConversationSummary) {
if (Boolean(a.running_turn) !== Boolean(b.running_turn)) {
return a.running_turn ? -1 : 1;
}
return (b.last_activity_at ?? "").localeCompare(a.last_activity_at ?? "");
}
const agentsForKind = $derived(
agents.filter((a) =>
a.kinds.includes(form.kind as AgentInfo["kinds"][number])
)
);
async function openCreate() {
createOpen = true;
if (agents.length === 0 && session.client) {
({ agents } = await session.client.agents());
}
}
async function create() {
if (!session.client) {
return;
}
busy = true;
try {
const created = await session.client.spawn({
agent: form.agent || undefined,
kind: form.kind,
seed: form.seed,
text: form.text || undefined,
title: form.title || undefined,
});
createOpen = false;
await goto(`${base}/conversations/${created.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
</script>
<svelte:head><title>Conversations · Beaver</title></svelte:head>
<PageHeader subtitle={`${rows.length} shown`} title="Conversations">
<Select.Root
onValueChange={(value) => {
kind = value;
}}
type="single"
value={kind}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{kind === "all" ? "any kind" : kind}
</Select.Trigger>
<Select.Content>
{#each KINDS as option (option)}
<Select.Item
label={option === "all" ? "any kind" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
onValueChange={(value) => {
statusFilter = value;
}}
type="single"
value={statusFilter}
>
<Select.Trigger class="h-8 text-xs" size="sm">
{statusFilter === "all" ? "any status" : statusFilter}
</Select.Trigger>
<Select.Content>
{#each STATUSES as option (option)}
<Select.Item
label={option === "all" ? "any status" : option}
value={option}
/>
{/each}
</Select.Content>
</Select.Root>
<Input
aria-label="Search conversations"
class="h-8 w-40 text-xs"
placeholder="title, id, agent"
bind:value={search}
/>
{#snippet actions()}
<Button onclick={openCreate} size="sm">
<PlusIcon class="size-4" />
New
</Button>
{/snippet}
</PageHeader>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
<div class="p-4">
<ErrorNote
message={gateway.live.detail ?? "event stream failed"}
retry={() => gateway.start()}
/>
</div>
{:else if !gateway.loaded}
<div class="flex flex-col gap-2 p-4">
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
<Skeleton class="h-9 w-full" />
</div>
{:else if rows.length === 0}
<div class="p-4">
<EmptyState
hint="Change the filters, or start one - a deep chat, a branch off the master, a headless job."
title="No conversations match"
>
<Button onclick={openCreate} size="sm" variant="outline"
>New conversation</Button
>
</EmptyState>
</div>
{:else}
<ul class="flex flex-col">
{#each rows as row (row.id)}
<li>
<a
class="row-hover ledger-grid grid-cols-[auto_minmax(0,1fr)_auto] border-b px-4 py-2 text-sm sm:grid-cols-[4.5rem_minmax(0,1fr)_9rem_6rem_7rem] sm:px-6"
href="{base}/conversations/{row.id}"
>
<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 sm:hidden">
{row.agent}
· {fmtRelative(row.last_activity_at)}
</span>
</span>
<span
class="hidden truncate text-muted-foreground text-xs sm:inline"
>
{row.agent}
</span>
<StatusPill status={row.running_turn ? "running" : row.status} />
<span
class="tabular hidden text-right text-muted-foreground text-xs sm:inline"
>
{fmtRelative(row.last_activity_at)}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New conversation</Dialog.Title>
<Dialog.Description>
It opens in the home window of its kind (a vault file, a Telegram topic)
and stays silent until someone speaks.
</Dialog.Description>
</Dialog.Header>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label>Kind</Label>
<Select.Root
onValueChange={(value) => {
form.kind = value;
form.agent = "";
}}
type="single"
value={form.kind}
>
<Select.Trigger class="w-full">{form.kind}</Select.Trigger>
<Select.Content>
{#each ["deep", "branch", "master", "job"] as option (option)}
<Select.Item label={option} value={option} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-1.5">
<Label>Agent</Label>
<Select.Root
onValueChange={(value) => {
form.agent = value === "default" ? "" : value;
}}
type="single"
value={form.agent || "default"}
>
<Select.Trigger class="w-full"
>{form.agent || "frontend default"}</Select.Trigger
>
<Select.Content>
<Select.Item label="frontend default" value="default" />
{#each agentsForKind as agent (agent.name)}
<Select.Item label={agent.name} value={agent.name} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-title">Title</Label>
<Input id="new-title" placeholder="optional" bind:value={form.title} />
</div>
<div class="col-span-2 flex flex-col gap-1.5">
<Label for="new-text">First message</Label>
<textarea
class="min-h-20 rounded-md border bg-input/30 px-3 py-2 text-sm focus-visible:border-ring focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
id="new-text"
placeholder="optional - without it the window waits"
bind:value={form.text}
></textarea>
</div>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy} onclick={create}>Create</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,23 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
import ConversationView from "$lib/panel/conversation-view.svelte";
import { session } from "$lib/session.svelte";
const id = $derived(page.params.id ?? "");
const href = (target: string) => `${base}/conversations/${target}`;
</script>
<svelte:head><title>Conversation · Beaver</title></svelte:head>
{#if session.client && id}
{#key id}
<ConversationView
client={session.client}
{href}
{id}
onOpen={(target) => goto(href(target))}
/>
{/key}
{/if}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Schedule } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtDateTime, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let schedules = $state<Schedule[] | null>(null);
let failure = $state<string | null>(null);
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
({ schedules } = await client.schedules());
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
onMount(load);
const pending = $derived(schedules?.filter((s) => !s.delivered_at) ?? []);
const delivered = $derived(schedules?.filter((s) => s.delivered_at) ?? []);
</script>
<svelte:head><title>Jobs · Beaver</title></svelte:head>
<PageHeader
subtitle={schedules ? `${pending.length} pending` : ""}
title="Jobs"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-6 px-4 py-4 sm:px-6">
<EmptyState
hint="Cron jobs, the master rotation and the envelope arrive with the scheduler (S6). Until then this tab lists the deferred injects the agent scheduled for itself."
title="Scheduler not wired yet"
/>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if schedules === null}
<Skeleton class="h-24 w-full" />
{:else if schedules.length === 0}
<p class="text-muted-foreground text-sm">No deferred injects.</p>
{:else}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Deferred injects
</h2>
<ul class="flex flex-col">
{#each [...pending, ...delivered] as s (s.id)}
<li
class={cn(
"ledger-grid grid-cols-[9rem_minmax(0,1fr)_7rem] border-b py-2 text-sm",
s.delivered_at && "text-muted-foreground"
)}
>
<span class="tabular text-xs" title={fmtDateTime(s.execute_at)}>
{s.delivered_at ? "delivered" : fmtRelative(s.execute_at)}
</span>
<span class="truncate">{clip(s.text, 160)}</span>
<span class="tabular text-right text-muted-foreground text-xs">
{fmtDateTime(s.execute_at)}
</span>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</div>
+97
View File
@@ -31,6 +31,8 @@
--icon: oklch(0.55 0.03 340);
--signal: oklch(0.48 0.21 342);
--note: oklch(0.52 0.13 78);
--warn: oklch(0.55 0.13 75);
--link: var(--primary);
--status-new: oklch(0.55 0.22 342);
--status-done: oklch(0.55 0.14 155);
@@ -74,6 +76,8 @@
--icon: #877384;
--signal: #ff82f3;
--note: oklch(0.84 0.14 88);
--warn: oklch(0.8 0.13 75);
--link: #ff82f3;
--status-new: #ff82f3;
--status-done: oklch(0.74 0.14 155);
@@ -200,3 +204,96 @@
display: none;
}
}
@theme inline {
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--color-kind-master: var(--signal);
--color-kind-branch: var(--status-reply);
--color-kind-deep: var(--status-meeting);
--color-kind-job: var(--status-work);
--color-kind-fork: var(--status-skip);
--color-ok: var(--status-done);
--color-warn: var(--warn);
--color-link: var(--link);
--animate-pulse-dot: pulse-dot 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-rise: rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes pulse-dot {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--signal) 55%, transparent);
}
50% {
box-shadow: 0 0 0 5px color-mix(in oklab, var(--signal) 0%, transparent);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: none;
}
}
@layer base {
* {
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
transparent;
scrollbar-width: thin;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--foreground) 22%, transparent);
border-radius: 999px;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
input,
textarea {
caret-color: var(--primary);
}
a {
text-underline-offset: 3px;
}
code,
pre,
kbd {
font-family: var(--font-mono);
font-size: 0.8125rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
}
@utility row-hover {
transition: background-color 150ms ease-out;
&:hover {
background: color-mix(in oklab, var(--foreground) 4%, transparent);
}
}
@utility ledger-grid {
display: grid;
column-gap: 0.75rem;
align-items: center;
}
@utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent);
}
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { session } from "$lib/session.svelte";
let username = $state("");
let password = $state("");
let failure = $state<string | null>(null);
let busy = $state(false);
async function submit(event: SubmitEvent) {
event.preventDefault();
busy = true;
failure = null;
try {
await session.login(username, password);
await goto(`${base}/`);
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
</script>
<svelte:head><title>Sign in · Beaver</title></svelte:head>
<div class="flex min-h-dvh items-center justify-center bg-sidebar p-6">
<form
class="flex w-full max-w-xs flex-col gap-4 rounded-xl border bg-card p-6 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.25)]"
onsubmit={submit}
>
<div class="flex items-center gap-2 font-semibold tracking-tight">
<span class="size-2.5 rounded-sm bg-primary"></span>
Beaver
</div>
<p class="text-muted-foreground text-sm">
Operator console for beaver-gateway. Sign in with the admin credentials
from the gateway environment.
</p>
<div class="flex flex-col gap-1.5">
<Label for="username">User</Label>
<Input
autocomplete="username"
id="username"
required
bind:value={username}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="password">Password</Label>
<Input
autocomplete="current-password"
id="password"
required
type="password"
bind:value={password}
/>
</div>
{#if failure}
<p class="text-destructive text-sm" role="alert">{failure}</p>
{/if}
<Button disabled={busy} type="submit">
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
</div>
+193
View File
@@ -0,0 +1,193 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import FolderIcon from "@lucide/svelte/icons/folder";
import { onMount } from "svelte";
import type { MemoryFile, MemoryNode, MemoryTree } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import { fmtBytes, fmtDateTime, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let tree = $state<MemoryTree | null>(null);
let failure = $state<string | null>(null);
let notConfigured = $state(false);
let selected = $state<string | null>(null);
let file = $state<MemoryFile | null>(null);
let fileError = $state<string | null>(null);
let collapsed = $state<Set<string>>(new Set());
async function load() {
const { client } = session;
if (!client) {
return;
}
failure = null;
notConfigured = false;
try {
tree = await client.memory();
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
if (message.includes("memory root")) {
notConfigured = true;
} else {
failure = message;
}
}
}
async function open(path: string) {
const { client } = session;
if (!client) {
return;
}
selected = path;
file = null;
fileError = null;
try {
file = await client.memoryFile(path);
} catch (cause) {
fileError = cause instanceof Error ? cause.message : String(cause);
}
}
function toggle(path: string) {
const next = new Set(collapsed);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
collapsed = next;
}
onMount(load);
const count = $derived.by(() => {
let files = 0;
const walk = (nodes: MemoryNode[]) => {
for (const node of nodes) {
if (node.type === "file") {
files += 1;
} else if (node.children) {
walk(node.children);
}
}
};
walk(tree?.tree ?? []);
return files;
});
</script>
<svelte:head><title>Memory · Beaver</title></svelte:head>
<PageHeader
subtitle={tree ? `${count} files · ${tree.root}` : ""}
title="Memory"
/>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[18rem_minmax(0,1fr)]">
<div
class="min-h-0 overflow-y-auto border-b bg-sidebar/50 p-2 md:border-r md:border-b-0"
>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if notConfigured}
<EmptyState
hint="Set ApiFrontend(memory_root=...) in config.py to the agent's zone of the vault."
title="No memory root"
/>
{:else if !tree}
<div class="flex flex-col gap-2 p-1">
<Skeleton class="h-6 w-3/4" />
<Skeleton class="h-6 w-1/2" />
<Skeleton class="h-6 w-2/3" />
</div>
{:else if tree.tree.length === 0}
<EmptyState hint="The zone is empty." title="Nothing here" />
{:else}
{@render nodes(tree.tree, 0)}
{/if}
</div>
<div class="min-h-0 overflow-y-auto">
{#if !selected}
<div class="p-6 text-muted-foreground text-sm">
Pick a file to read it. This is the agent's own zone: state, handouts,
prompts, skills - everything it can write.
</div>
{:else if fileError}
<div class="p-4">
<ErrorNote message={fileError} retry={() => open(selected ?? "")} />
</div>
{:else if !file}
<div class="flex flex-col gap-2 p-6">
<Skeleton class="h-5 w-1/3" />
<Skeleton class="h-40 w-full" />
</div>
{:else}
<div class="flex items-baseline gap-3 border-b px-4 py-2 text-xs sm:px-6">
<span class="font-medium text-sm">{file.path}</span>
<span class="tabular text-muted-foreground">{fmtBytes(file.size)}</span>
<span
class="tabular text-muted-foreground"
title={fmtDateTime(file.mtime)}
>
modified {fmtRelative(file.mtime)}
</span>
</div>
<pre
class="max-w-[90ch] px-4 py-3 text-sm whitespace-pre-wrap break-words sm:px-6"
>{file.content}</pre>
{/if}
</div>
</div>
{#snippet nodes(list: MemoryNode[], depth: number)}
<ul class="flex flex-col">
{#each list as node (node.path)}
<li>
{#if node.type === "dir"}
<button
aria-expanded={!collapsed.has(node.path)}
class="row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm"
onclick={() => toggle(node.path)}
style="padding-left: {depth * 0.75 + 0.25}rem"
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 shrink-0 text-icon transition-transform duration-150",
!collapsed.has(node.path) && "rotate-90"
)}
/>
<FolderIcon class="size-4 shrink-0 text-icon" />
<span class="truncate">{node.name}</span>
</button>
{#if !collapsed.has(node.path) && node.children}
{@render nodes(node.children, depth + 1)}
{/if}
{:else}
<button
aria-current={selected === node.path ? "true" : undefined}
class={cn(
"row-hover flex h-7 w-full items-center gap-1.5 rounded-md pr-2 text-left text-sm",
selected === node.path && "bg-sidebar-accent font-medium"
)}
onclick={() => open(node.path)}
style="padding-left: {depth * 0.75 + 1.5}rem"
type="button"
>
<FileTextIcon class="size-4 shrink-0 text-icon" />
<span class="truncate">{node.name}</span>
<span class="tabular ml-auto text-muted-foreground text-xs">
{fmtBytes(node.size)}
</span>
</button>
{/if}
</li>
{/each}
</ul>
{/snippet}
+287
View File
@@ -0,0 +1,287 @@
<script lang="ts">
import CopyIcon from "@lucide/svelte/icons/copy";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import type { TokenRow } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import PageHeader from "$lib/components/page-header.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Switch } from "$lib/components/ui/switch";
import { fmtDateTime, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const SCOPES = [
{ hint: "every frontend", value: "*" },
{ hint: "conversations API and SSE (panel, plugin)", value: "api" },
{ hint: "Anthropic /v1/messages", value: "messages" },
{ hint: "MCP server", value: "mcp" },
{ hint: "tokens and audit over the API", value: "admin" },
];
let tokens = $state<TokenRow[] | null>(null);
let includeRevoked = $state(false);
let failure = $state<string | null>(null);
let createOpen = $state(false);
let name = $state("");
let scope = $state("api");
let busy = $state(false);
let created = $state<{ name: string; plaintext: string } | null>(null);
async function load(revoked = includeRevoked) {
const { client } = session;
if (!client) {
return;
}
failure = null;
try {
({ tokens } = await client.tokens(revoked));
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
$effect(() => {
load(includeRevoked);
});
async function create() {
const { client } = session;
if (!client) {
return;
}
busy = true;
try {
const result = await client.createToken(name.trim(), scope);
created = { name: result.token.name, plaintext: result.plaintext };
createOpen = false;
name = "";
await load();
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
} finally {
busy = false;
}
}
async function revoke(token: TokenRow) {
const { client } = session;
if (!client) {
return;
}
try {
await client.revokeToken(token.id);
toast.success(`Revoked ${token.name}`);
await load();
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
}
}
function copy(text: string) {
navigator.clipboard
.writeText(text)
.then(() => toast.success("Copied"))
.catch(() => toast.error("Clipboard is not available"));
}
</script>
<svelte:head><title>Tokens · Beaver</title></svelte:head>
<PageHeader title="Tokens">
<span class="flex items-center gap-2 text-muted-foreground text-xs">
<Switch aria-label="Show revoked tokens" bind:checked={includeRevoked} />
show revoked
</span>
{#snippet actions()}
<Button
onclick={() => {
createOpen = true;
}}
size="sm"
>
<PlusIcon class="size-4" />
New token
</Button>
{/snippet}
</PageHeader>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="flex flex-col gap-4 px-4 py-4 sm:px-6">
{#if created}
<div
class="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/5 p-3 text-sm"
role="status"
>
<p>
Token <span class="font-medium">{created.name}</span> created. This is
the only time the plaintext is shown - copy it into the client now.
</p>
<div class="flex items-center gap-2">
<code
class="min-w-0 flex-1 truncate rounded-md bg-background px-2 py-1"
>
{created.plaintext}
</code>
<Button
onclick={() => copy(created?.plaintext ?? "")}
size="sm"
variant="outline"
>
<CopyIcon class="size-4" />
Copy
</Button>
<Button
onclick={() => {
created = null;
}}
size="sm"
variant="ghost"
>
Done
</Button>
</div>
</div>
{/if}
{#if failure}
<ErrorNote message={failure} retry={() => load()} />
{:else if tokens === null}
<Skeleton class="h-24 w-full" />
{:else if tokens.length === 0}
<EmptyState
hint="Bearer tokens let clients (Cursor, the Obsidian plugin, curl) reach the gateway. The plaintext is shown once at creation; only the hash is stored."
title="No tokens"
>
<Button
onclick={() => {
createOpen = true;
}}
size="sm"
variant="outline"
>
Create the first token
</Button>
</EmptyState>
{:else}
<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">name</th>
<th class="py-1.5 pr-3 font-medium">scope</th>
<th class="py-1.5 pr-3 font-medium">created</th>
<th class="py-1.5 pr-3 font-medium">last used</th>
<th class="py-1.5 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{#each tokens as token (token.id)}
<tr
class={cn("row-hover border-b", token.revoked_at && "text-muted-foreground")}
>
<td class="py-1.5 pr-3 font-medium">
{token.name}
{#if token.revoked_at}
<span class="ml-2 text-xs"
>revoked {fmtRelative(token.revoked_at)}</span
>
{/if}
</td>
<td class="py-1.5 pr-3">
<code class="rounded bg-muted px-1.5 py-0.5"
>{token.scope}</code
>
</td>
<td class="tabular py-1.5 pr-3 text-xs">
{fmtDateTime(token.created_at)}
</td>
<td class="tabular py-1.5 pr-3 text-xs">
{token.last_used_at ? fmtRelative(token.last_used_at) : "never"}
</td>
<td class="py-1.5 text-right">
{#if !token.revoked_at}
<Button
onclick={() => revoke(token)}
size="sm"
variant="ghost"
>
Revoke
</Button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<Dialog.Root bind:open={createOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>New bearer token</Dialog.Title>
<Dialog.Description>
The value is generated by the gateway and shown once.
</Dialog.Description>
</Dialog.Header>
<form
class="flex flex-col gap-3"
onsubmit={(event) => {
event.preventDefault();
create();
}}
>
<div class="flex flex-col gap-1.5">
<Label for="token-name">Name</Label>
<Input
id="token-name"
placeholder="cursor-laptop"
required
bind:value={name}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label>Scope</Label>
<Select.Root
onValueChange={(value) => {
scope = value;
}}
type="single"
value={scope}
>
<Select.Trigger class="w-full">{scope}</Select.Trigger>
<Select.Content>
{#each SCOPES as option (option.value)}
<Select.Item label={option.value} value={option.value}>
<span class="flex flex-col">
<span>{option.value}</span>
<span class="text-muted-foreground text-xs"
>{option.hint}</span
>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Dialog.Footer>
<Button
onclick={() => {
createOpen = false;
}}
type="button"
variant="ghost"
>
Cancel
</Button>
<Button disabled={busy || !name.trim()} type="submit">Create</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
+299
View File
@@ -0,0 +1,299 @@
<script lang="ts">
import { onMount } from "svelte";
import { base } from "$app/paths";
import type { UsageGroup, UsageResponse, UsageRow } 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 Stat from "$lib/components/stat.svelte";
import StatusPill from "$lib/components/status-pill.svelte";
import { Skeleton } from "$lib/components/ui/skeleton";
import * as Tabs from "$lib/components/ui/tabs";
import {
cacheShare,
fmtDateTime,
fmtMoney,
fmtPct,
fmtTokens,
shortId,
} from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { limitLabel } from "$lib/limits";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
const RANGES: { value: string; label: string; hours: number }[] = [
{ hours: 24, label: "24 h", value: "day" },
{ hours: 168, label: "7 d", value: "week" },
{ hours: 720, label: "30 d", value: "month" },
];
const GROUPS: { value: UsageGroup; label: string }[] = [
{ label: "by agent", value: "agent" },
{ label: "by conversation", value: "conversation" },
{ label: "by model", value: "model" },
{ label: "by day", value: "day" },
];
const TICK_MS = 30_000;
let range = $state("week");
let group = $state<UsageGroup>("agent");
let data = $state<UsageResponse | null>(null);
let failure = $state<string | null>(null);
let now = $state(Date.now());
async function load(rangeValue: string, groupValue: UsageGroup) {
const { client } = session;
if (!client) {
return;
}
failure = null;
const hours = RANGES.find((r) => r.value === rangeValue)?.hours ?? 168;
try {
data = await client.usage({ group_by: groupValue, hours });
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
$effect(() => {
load(range, group);
});
onMount(() => {
gateway.refreshLimits().catch(() => undefined);
const tick = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => clearInterval(tick);
});
const total = $derived(data?.total ?? null);
const share = $derived(
total ? cacheShare(total.input, total.cache_read) : null
);
const maxCost = $derived(
data ? Math.max(...data.rows.map((r) => r.cost_usd), 0) : 0
);
const windows = $derived(gateway.limits?.windows ?? []);
const history = $derived(gateway.limits?.history ?? []);
function label(row: UsageRow): string {
if (group === "conversation") {
return row.title || `${row.kind ?? "conversation"} ${shortId(row.key)}`;
}
return row.key;
}
</script>
<svelte:head><title>Usage · Beaver</title></svelte:head>
<PageHeader
subtitle={data ? `${fmtDateTime(data.since)} now` : ""}
title="Usage"
>
<Tabs.Root
onValueChange={(value) => {
range = value;
}}
value={range}
>
<Tabs.List>
{#each RANGES as r (r.value)}
<Tabs.Trigger value={r.value}>{r.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</PageHeader>
<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(range, group)} />
{/if}
<section 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="Windows show up once the SDK emits its first rate-limit event. Utilization is for the whole subscription; the gateway's own share is the line under each bar."
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}
</section>
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Tokens and API-price equivalent
</h2>
<Tabs.Root
class="ml-auto"
onValueChange={(value) => {
group = value as UsageGroup;
}}
value={group}
>
<Tabs.List>
{#each GROUPS as g (g.value)}
<Tabs.Trigger value={g.value}>{g.label}</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
</div>
{#if !(data && total)}
<Skeleton class="h-40 w-full" />
{:else}
<div
class="grid grid-cols-2 gap-4 border-y py-3 sm:grid-cols-4 lg:grid-cols-6"
>
<Stat label="cost" value={fmtMoney(total.cost_usd)} />
<Stat label="turns" value={String(total.turns)} />
<Stat label="input" value={fmtTokens(total.input)} />
<Stat label="output" value={fmtTokens(total.output)} />
<Stat
hint="cache read of all input"
label="cache share"
tone={share !== null && share < 0.5 && total.turns > 0 ? "warn" : "default"}
value={fmtPct(share)}
/>
<Stat
hint={total.web_searches ? `${total.web_searches} web searches` : ""}
label="cache written"
value={fmtTokens(total.cache_creation)}
/>
</div>
{#if data.rows.length === 0}
<EmptyState
hint="No turns in this range. Widen it, or wait for the agents to work."
title="Nothing to sum"
/>
{: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">{group}</th>
<th class="w-32 py-1.5 pr-3 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</th>
<th class="py-1.5 pr-3 text-right font-medium">out</th>
<th class="py-1.5 pr-3 text-right font-medium">cache read</th>
<th class="py-1.5 pr-3 text-right font-medium">
cache write
</th>
<th class="py-1.5 text-right font-medium">cache share</th>
</tr>
</thead>
<tbody>
{#each data.rows as row (row.key)}
{@const rowShare = cacheShare(row.input, row.cache_read)}
<tr class="row-hover border-b">
<td class="max-w-64 py-1.5 pr-3">
<span class="flex min-w-0 items-center gap-2">
{#if group === "conversation"}
{#if row.kind}
<KindBadge kind={row.kind} />
{/if}
<a
class="truncate font-medium text-link hover:underline"
href="{base}/conversations/{row.key}"
>
{label(row)}
</a>
{#if row.status && row.status !== "open"}
<StatusPill status={row.status} />
{/if}
{:else}
<span class="truncate font-medium">{label(row)}</span>
{#if group === "day"}
<span class="text-muted-foreground text-xs"
>{row.agent}</span
>
{/if}
{/if}
</span>
</td>
<td class="py-1.5 pr-3">
<span class="flex items-center gap-2">
<span
class="h-1.5 w-16 overflow-hidden rounded-full bg-muted"
>
<span
class="block h-full rounded-full bg-primary"
style="width: {maxCost > 0
? Math.round((row.cost_usd / maxCost) * 100)
: 0}%"
></span>
</span>
<span class="tabular">{fmtMoney(row.cost_usd)}</span>
</span>
</td>
<td class="tabular py-1.5 pr-3 text-right">{row.turns}</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(row.input)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(row.output)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(row.cache_read)}
</td>
<td class="tabular py-1.5 pr-3 text-right">
{fmtTokens(row.cache_creation)}
</td>
<td
class={cn(
"tabular py-1.5 text-right",
rowShare !== null && rowShare < 0.5 && "text-warn"
)}
>
{fmtPct(rowShare)}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</section>
{#if history.length > 0}
<section class="flex flex-col gap-2">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Rate-limit history
</h2>
<ul class="flex flex-col text-xs">
{#each history as h (h.id)}
<li
class="ledger-grid grid-cols-[9rem_7rem_4rem_minmax(0,1fr)] border-b py-1"
>
<span class="tabular text-muted-foreground"
>{fmtDateTime(h.ts)}</span
>
<span>{limitLabel(h.window)}</span>
<span class="tabular">{fmtPct(h.utilization)}</span>
<StatusPill status={h.status} />
</li>
{/each}
</ul>
</section>
{/if}
</div>
</div>