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