feat(ui,api): strip board redesign - island, rail by day, context view, server search, vault graph
This commit is contained in:
1 parent
85b14e2c2f
commit
025f2dbc3f
60 files changed
+5071
-2347
No files matched your search
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { ModeWatcher } from "mode-watcher";
|
||||
import { onMount, untrack } from "svelte";
|
||||
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 { SECTIONS, SYSTEM } from "$lib/nav";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import Board from "$lib/shell/board.svelte";
|
||||
import Palette from "$lib/shell/palette.svelte";
|
||||
import TopBar from "$lib/shell/top-bar.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isLogin = $derived(page.url.pathname === `${base}/login`);
|
||||
const isPanel = $derived(page.url.pathname.startsWith(`${base}/panel`));
|
||||
const inConversations = $derived(
|
||||
page.url.pathname.startsWith(`${base}/conversations`)
|
||||
const isHome = $derived(
|
||||
page.url.pathname === `${base}/` || page.url.pathname === base
|
||||
);
|
||||
let navBefore: boolean | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!isPanel) {
|
||||
@@ -48,23 +50,47 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (inConversations) {
|
||||
untrack(() => {
|
||||
if (navBefore === null) {
|
||||
navBefore = ui.nav;
|
||||
ui.setNav(false);
|
||||
}
|
||||
});
|
||||
} else if (navBefore !== null) {
|
||||
ui.setNav(navBefore);
|
||||
navBefore = null;
|
||||
if (page.url.pathname) {
|
||||
ui.closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
function typing(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
return Boolean(
|
||||
el &&
|
||||
(el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
ui.toggleNav();
|
||||
if (ui.palette) {
|
||||
ui.closePalette();
|
||||
} else {
|
||||
ui.openPalette();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && (ui.island || ui.palette)) {
|
||||
ui.closeAll();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
typing(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const section = [...SECTIONS, SYSTEM].find((s) => s.key === event.key);
|
||||
if (section) {
|
||||
event.preventDefault();
|
||||
goto(`${base}${section.href}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -80,13 +106,34 @@
|
||||
{#if isLogin || isPanel}
|
||||
{@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>
|
||||
<div class="flex h-dvh flex-col overflow-hidden">
|
||||
<TopBar />
|
||||
<div class="relative flex min-h-0 flex-1 flex-col">
|
||||
<main class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if ui.island && !isHome}
|
||||
<div
|
||||
class="absolute inset-0 z-20 flex items-start justify-center px-3 pt-3 sm:px-6"
|
||||
>
|
||||
<button
|
||||
aria-label="Close the board"
|
||||
class="absolute inset-0 animate-fade-in bg-foreground/10 backdrop-blur-[1.5px]"
|
||||
onclick={() => ui.closeIsland()}
|
||||
type="button"
|
||||
></button>
|
||||
<section
|
||||
aria-label="Now"
|
||||
class="relative max-h-full w-full max-w-4xl animate-island-in overflow-y-auto rounded-2xl border bg-background p-5 shadow-float sm:p-6"
|
||||
>
|
||||
<Board compact />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
<Palette />
|
||||
{: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>
|
||||
|
||||
+3
-388
@@ -1,396 +1,11 @@
|
||||
<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 Endpoints from "$lib/components/endpoints.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));
|
||||
import Board from "$lib/shell/board.svelte";
|
||||
</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"
|
||||
>
|
||||
Endpoints
|
||||
</h2>
|
||||
{#if agents}
|
||||
<Endpoints catalog={agents} />
|
||||
{: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 class="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6 sm:px-6">
|
||||
<Board />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,105 +0,0 @@
|
||||
<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>
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
|
||||
import { goto } from "$app/navigation";
|
||||
import { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import ConversationList from "$lib/components/conversation-list.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import Rail from "$lib/rail/rail.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { ui } from "$lib/ui.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const selected = $derived(page.params.id ?? null);
|
||||
const running = $derived(gateway.running.length);
|
||||
const href = (id: string) => `${base}/conversations/${id}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Conversations · Beaver</title></svelte:head>
|
||||
@@ -20,39 +22,42 @@
|
||||
"grid min-h-0 flex-1 grid-cols-1",
|
||||
ui.rail
|
||||
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
|
||||
: "lg:grid-cols-[2.5rem_minmax(0,1fr)]"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"min-h-0 border-r bg-sidebar/40",
|
||||
"min-h-0 border-r",
|
||||
selected ? "hidden lg:block" : "block"
|
||||
)}
|
||||
>
|
||||
{#if ui.rail}
|
||||
<ConversationList {selected} />
|
||||
{#if session.client}
|
||||
<Rail
|
||||
client={session.client}
|
||||
{href}
|
||||
index={gateway}
|
||||
onOpenFile={(path) =>
|
||||
goto(`${base}/memory?path=${encodeURIComponent(path)}`)}
|
||||
{selected}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
|
||||
<Button
|
||||
<div class="hidden h-full flex-col items-center py-2 lg:flex">
|
||||
<button
|
||||
aria-label="Show conversations"
|
||||
class="rule-word inline-flex size-7 items-center justify-center rounded-md"
|
||||
onclick={() => ui.toggleRail()}
|
||||
size="icon-sm"
|
||||
title="Show conversations"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
>
|
||||
<PanelLeftOpenIcon class="size-4" />
|
||||
</Button>
|
||||
{#if running > 0}
|
||||
<span
|
||||
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
|
||||
title="{running} running"
|
||||
>
|
||||
{running}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h-full lg:hidden">
|
||||
<ConversationList {selected} />
|
||||
{#if session.client}
|
||||
<Rail client={session.client} {href} index={gateway} {selected} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,10 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center p-6 text-muted-foreground text-sm"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-1 p-6 text-center text-muted-foreground text-sm"
|
||||
>
|
||||
<p>Pick a strip on the left.</p>
|
||||
{#if running > 0}
|
||||
{running}
|
||||
running - pick one on the left.
|
||||
{:else}
|
||||
Pick a conversation on the left.
|
||||
<p class="text-signal">{running} in motion right now.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,251 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { JobsResponse, QueuedJob } 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 StatusPill from "$lib/components/status-pill.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { clip, fmtDateTime, fmtPct, fmtRelative } from "$lib/format";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const PAYLOAD_MAX = 120;
|
||||
|
||||
let data = $state<JobsResponse | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let busy = $state<string | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
failure = null;
|
||||
try {
|
||||
data = await client.jobs();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(name: string) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = name;
|
||||
try {
|
||||
await client.runJob(name);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: QueuedJob) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = `queue:${job.id}`;
|
||||
try {
|
||||
await client.cancelJob(job.id);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function describe(job: QueuedJob): string {
|
||||
const { payload } = job;
|
||||
const { text } = payload;
|
||||
if (typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
const raw = JSON.stringify(payload);
|
||||
return raw === "{}" ? "" : raw;
|
||||
}
|
||||
|
||||
function triggers(job: JobsResponse["jobs"][number]): string {
|
||||
const parts: string[] = [];
|
||||
if (job.cron) {
|
||||
parts.push(job.cron);
|
||||
}
|
||||
if (job.webhook) {
|
||||
parts.push(`POST /hooks/${job.name}`);
|
||||
}
|
||||
for (const event of job.events) {
|
||||
parts.push(`on ${event}`);
|
||||
}
|
||||
return parts.join(" · ") || "manual";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
timer = setInterval(load, TICK_MS);
|
||||
});
|
||||
onDestroy(() => clearInterval(timer));
|
||||
|
||||
const pending = $derived(
|
||||
data?.queue.filter((q) => q.status === "queued") ?? []
|
||||
);
|
||||
const picked = $derived(
|
||||
data?.queue.filter((q) => q.status !== "queued") ?? []
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Jobs · Beaver</title></svelte:head>
|
||||
|
||||
<PageHeader
|
||||
subtitle={data
|
||||
? `${data.jobs.length} jobs · ${pending.length} queued · window ${fmtPct(data.utilization)}${data.throttled ? " · throttled" : ""}`
|
||||
: ""}
|
||||
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">
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if data === null}
|
||||
<Skeleton class="h-24 w-full" />
|
||||
{:else}
|
||||
{#if !data.enabled}
|
||||
<EmptyState
|
||||
hint="The gateway is not on Postgres: cron and webhooks are off, `schedule` is unavailable. Event jobs still run in-process."
|
||||
title="Scheduler is off"
|
||||
/>
|
||||
{/if}
|
||||
{#if data.throttled}
|
||||
<p class="text-sm text-warn">
|
||||
Subscription window at {fmtPct(data.utilization)} (threshold
|
||||
{fmtPct(
|
||||
data.threshold
|
||||
)}): non-critical jobs are deferred.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Jobs
|
||||
</h2>
|
||||
{#if data.jobs.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No jobs in config.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each data.jobs as job (job.name)}
|
||||
<li
|
||||
class="ledger-grid grid-cols-[10rem_minmax(0,1fr)_9rem_9rem_5rem] items-center border-b py-2 text-sm"
|
||||
>
|
||||
<span class="truncate font-medium">
|
||||
{job.name}
|
||||
{#if !job.critical}
|
||||
<span class="text-muted-foreground text-xs">· soft</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{triggers(job)}
|
||||
</span>
|
||||
<span
|
||||
class="tabular text-muted-foreground text-xs"
|
||||
title={fmtDateTime(job.next_run)}
|
||||
>
|
||||
{job.next_run ? `next ${fmtRelative(job.next_run)}` : ""}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
{#if job.run}
|
||||
<StatusPill status={job.run.status} />
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
title={fmtDateTime(job.run.started_at)}
|
||||
>
|
||||
{fmtRelative(job.run.started_at)}
|
||||
· {job.run.trigger}
|
||||
</span>
|
||||
{:else if job.last_run}
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
title={fmtDateTime(job.last_run)}
|
||||
>
|
||||
ran {fmtRelative(job.last_run)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
disabled={busy === job.name}
|
||||
onclick={() => run(job.name)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
>
|
||||
Queue
|
||||
</h2>
|
||||
{#if data.queue.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Nothing queued: no deferred injects, no pending webhooks.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each [...picked, ...pending] as job (job.id)}
|
||||
<li
|
||||
class={cn(
|
||||
"ledger-grid grid-cols-[8rem_7rem_minmax(0,1fr)_9rem_5rem] items-center border-b py-2 text-sm",
|
||||
job.status !== "queued" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="tabular text-xs"
|
||||
title={fmtDateTime(job.execute_after)}
|
||||
>
|
||||
{job.status === "queued"
|
||||
? fmtRelative(job.execute_after)
|
||||
: job.status}
|
||||
</span>
|
||||
<span class="truncate text-xs">{job.entrypoint}</span>
|
||||
<span class="truncate" title={JSON.stringify(job.payload)}>
|
||||
{clip(describe(job), PAYLOAD_MAX)}
|
||||
</span>
|
||||
<span class="tabular text-right text-muted-foreground text-xs">
|
||||
{fmtDateTime(job.execute_after)}
|
||||
</span>
|
||||
{#if job.status === "queued"}
|
||||
<Button
|
||||
disabled={busy === `queue:${job.id}`}
|
||||
onclick={() => cancel(job)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+159
-90
@@ -10,27 +10,31 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.99 0.004 340);
|
||||
--foreground: oklch(0.24 0.03 340);
|
||||
--card: oklch(0.985 0.006 340);
|
||||
--card-foreground: oklch(0.24 0.03 340);
|
||||
--popover: oklch(0.985 0.006 340);
|
||||
--popover-foreground: oklch(0.24 0.03 340);
|
||||
--rack: oklch(0.968 0.007 340);
|
||||
--strip: oklch(0.996 0.002 340);
|
||||
--background: var(--rack);
|
||||
--foreground: oklch(0.23 0.03 340);
|
||||
--card: var(--strip);
|
||||
--card-foreground: oklch(0.23 0.03 340);
|
||||
--popover: var(--strip);
|
||||
--popover-foreground: oklch(0.23 0.03 340);
|
||||
--primary: oklch(0.47 0.13 340);
|
||||
--primary-foreground: oklch(0.99 0.005 340);
|
||||
--secondary: oklch(0.96 0.012 340);
|
||||
--secondary-foreground: oklch(0.24 0.03 340);
|
||||
--muted: oklch(0.96 0.012 340);
|
||||
--muted-foreground: oklch(0.5 0.04 340);
|
||||
--accent: oklch(0.96 0.012 340);
|
||||
--accent-foreground: oklch(0.24 0.03 340);
|
||||
--secondary: oklch(0.95 0.012 340);
|
||||
--secondary-foreground: oklch(0.23 0.03 340);
|
||||
--muted: oklch(0.945 0.012 340);
|
||||
--muted-foreground: oklch(0.5 0.035 340);
|
||||
--accent: oklch(0.95 0.012 340);
|
||||
--accent-foreground: oklch(0.23 0.03 340);
|
||||
--destructive: oklch(0.58 0.22 25);
|
||||
--destructive-foreground: oklch(0.99 0 0);
|
||||
--border: oklch(0.9 0.02 340);
|
||||
--input: oklch(0.9 0.02 340);
|
||||
--border: oklch(0.9 0.016 340);
|
||||
--input: oklch(0.9 0.016 340);
|
||||
--ring: oklch(0.55 0.2 342);
|
||||
--icon: oklch(0.55 0.03 340);
|
||||
--signal: oklch(0.48 0.21 342);
|
||||
--signal: oklch(0.5 0.21 342);
|
||||
--attention: oklch(0.72 0.16 72);
|
||||
--attention-foreground: oklch(0.5 0.14 70);
|
||||
--note: oklch(0.52 0.13 78);
|
||||
--warn: oklch(0.55 0.13 75);
|
||||
--link: var(--primary);
|
||||
@@ -43,64 +47,81 @@
|
||||
--status-meeting: oklch(0.5 0.2 300);
|
||||
--status-work: oklch(0.55 0.13 195);
|
||||
|
||||
--radius: 0.45rem;
|
||||
--sidebar: oklch(0.975 0.01 340);
|
||||
--sidebar-foreground: oklch(0.24 0.03 340);
|
||||
--sidebar-primary: oklch(0.47 0.13 340);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.005 340);
|
||||
--sidebar-accent: oklch(0.94 0.016 340);
|
||||
--sidebar-accent-foreground: oklch(0.24 0.03 340);
|
||||
--sidebar-border: oklch(0.9 0.02 340);
|
||||
--sidebar-ring: oklch(0.55 0.2 342);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: var(--rack);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--primary);
|
||||
--sidebar-primary-foreground: var(--primary-foreground);
|
||||
--sidebar-accent: oklch(0.93 0.016 340);
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
|
||||
--shadow-lift:
|
||||
0 8px 20px -12px oklch(0.25 0.06 340 / 0.35),
|
||||
0 1px 2px oklch(0.25 0.06 340 / 0.08);
|
||||
--shadow-float:
|
||||
0 24px 60px -24px oklch(0.2 0.06 340 / 0.45),
|
||||
0 2px 6px oklch(0.2 0.06 340 / 0.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #22111e;
|
||||
--foreground: #f5f5f5;
|
||||
--card: #341d2f;
|
||||
--card-foreground: #f5f5f5;
|
||||
--popover: #341d2f;
|
||||
--popover-foreground: #f5f5f5;
|
||||
--primary: #7c3871;
|
||||
--primary-foreground: #f5f5f5;
|
||||
--secondary: #341d2f;
|
||||
--secondary-foreground: #f5f5f5;
|
||||
--muted: #40283a;
|
||||
--muted-foreground: #a989a3;
|
||||
--accent: #40283a;
|
||||
--accent-foreground: #f5f5f5;
|
||||
--destructive: oklch(0.62 0.2 25);
|
||||
--destructive-foreground: #f5f5f5;
|
||||
--border: oklch(0.92 0.04 340 / 10%);
|
||||
--input: oklch(0.92 0.04 340 / 14%);
|
||||
--ring: #ff82f3;
|
||||
--icon: #877384;
|
||||
--signal: #ff82f3;
|
||||
--rack: oklch(0.2 0.025 335);
|
||||
--strip: oklch(0.255 0.028 335);
|
||||
--background: var(--rack);
|
||||
--foreground: oklch(0.96 0.005 340);
|
||||
--card: var(--strip);
|
||||
--card-foreground: oklch(0.96 0.005 340);
|
||||
--popover: oklch(0.27 0.03 335);
|
||||
--popover-foreground: oklch(0.96 0.005 340);
|
||||
--primary: oklch(0.72 0.16 340);
|
||||
--primary-foreground: oklch(0.18 0.03 340);
|
||||
--secondary: oklch(0.29 0.03 335);
|
||||
--secondary-foreground: oklch(0.96 0.005 340);
|
||||
--muted: oklch(0.3 0.03 335);
|
||||
--muted-foreground: oklch(0.72 0.03 340);
|
||||
--accent: oklch(0.3 0.03 335);
|
||||
--accent-foreground: oklch(0.96 0.005 340);
|
||||
--destructive: oklch(0.66 0.2 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
--border: oklch(0.96 0.02 340 / 10%);
|
||||
--input: oklch(0.96 0.02 340 / 14%);
|
||||
--ring: oklch(0.8 0.15 340);
|
||||
--icon: oklch(0.65 0.03 340);
|
||||
--signal: oklch(0.8 0.17 340);
|
||||
--attention: oklch(0.8 0.15 75);
|
||||
--attention-foreground: oklch(0.85 0.14 78);
|
||||
--note: oklch(0.84 0.14 88);
|
||||
--warn: oklch(0.8 0.13 75);
|
||||
--link: #ff82f3;
|
||||
--link: oklch(0.8 0.15 340);
|
||||
|
||||
--status-new: #ff82f3;
|
||||
--status-new: oklch(0.8 0.17 340);
|
||||
--status-done: oklch(0.74 0.14 155);
|
||||
--status-skip: #a989a3;
|
||||
--status-skip: oklch(0.7 0.03 340);
|
||||
--status-reply: oklch(0.72 0.13 235);
|
||||
--status-snooze: oklch(0.8 0.13 75);
|
||||
--status-meeting: oklch(0.72 0.16 300);
|
||||
--status-work: oklch(0.75 0.12 195);
|
||||
|
||||
--sidebar: #2c1b29;
|
||||
--sidebar-foreground: #f5f5f5;
|
||||
--sidebar-primary: #ff82f3;
|
||||
--sidebar-primary-foreground: #22111e;
|
||||
--sidebar-accent: #341d2f;
|
||||
--sidebar-accent-foreground: #f5f5f5;
|
||||
--sidebar-border: oklch(0.92 0.04 340 / 10%);
|
||||
--sidebar-ring: #ff82f3;
|
||||
--sidebar: var(--rack);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--primary);
|
||||
--sidebar-primary-foreground: var(--primary-foreground);
|
||||
--sidebar-accent: oklch(0.3 0.03 335);
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
|
||||
--shadow-lift:
|
||||
0 8px 20px -12px oklch(0 0 0 / 0.7), 0 1px 2px oklch(0 0 0 / 0.3);
|
||||
--shadow-float:
|
||||
0 24px 60px -24px oklch(0 0 0 / 0.8), 0 2px 6px oklch(0 0 0 / 0.3);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter Variable", sans-serif;
|
||||
--font-heading: "Inter Variable", sans-serif;
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Inter Variable", system-ui, sans-serif;
|
||||
--font-heading: var(--font-sans);
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
@@ -108,6 +129,12 @@
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 2rem;
|
||||
|
||||
--color-rack: var(--rack);
|
||||
--color-strip: var(--strip);
|
||||
--color-attention: var(--attention);
|
||||
--color-attention-foreground: var(--attention-foreground);
|
||||
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
@@ -150,11 +177,37 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
|
||||
--shadow-lift: var(--shadow-lift);
|
||||
--shadow-float: var(--shadow-float);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
|
||||
--animate-island-in: island-in 260ms cubic-bezier(0.2, 0.9, 0.25, 1.05) both;
|
||||
--animate-fade-in: fade-in 180ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes island-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -163,6 +216,7 @@
|
||||
}
|
||||
body {
|
||||
font-feature-settings: "cv11", "ss01";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html,
|
||||
@@ -177,18 +231,6 @@
|
||||
::selection {
|
||||
background: color-mix(in oklab, var(--primary) 28%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@utility hue-chip {
|
||||
color: oklch(0.45 0.13 var(--hue));
|
||||
background: oklch(0.6 0.09 var(--hue) / 0.12);
|
||||
border-color: oklch(0.6 0.09 var(--hue) / 0.4);
|
||||
.dark & {
|
||||
color: oklch(0.82 0.08 var(--hue));
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-color: color-mix(in oklab, var(--foreground) 22%, transparent)
|
||||
transparent;
|
||||
@@ -210,6 +252,14 @@
|
||||
textarea {
|
||||
caret-color: var(--primary);
|
||||
}
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
--tw-ring-color: transparent;
|
||||
outline: none;
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
a {
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
@@ -229,10 +279,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility hue-chip {
|
||||
color: oklch(0.45 0.13 var(--hue));
|
||||
background: oklch(0.6 0.09 var(--hue) / 0.12);
|
||||
border-color: oklch(0.6 0.09 var(--hue) / 0.4);
|
||||
.dark & {
|
||||
color: oklch(0.82 0.08 var(--hue));
|
||||
}
|
||||
}
|
||||
|
||||
@utility hairline {
|
||||
border-color: color-mix(in oklab, var(--border) 100%, transparent);
|
||||
}
|
||||
|
||||
@utility label-quiet {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
@utility rule-word {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
transition: color 150ms ease-out;
|
||||
&:hover,
|
||||
&[aria-current="page"],
|
||||
&[data-active="true"] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/* chat markdown: typography plugin on the theme tokens, compact rhythm */
|
||||
.prose {
|
||||
--tw-prose-body: var(--foreground);
|
||||
@@ -258,34 +336,25 @@
|
||||
:where(p, ul, ol, pre, blockquote, table):not(
|
||||
:where([class~="not-prose"] *)
|
||||
) {
|
||||
margin-block: 0.4em;
|
||||
margin-top: 0.4em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) {
|
||||
margin-block: 0.8em 0.4em;
|
||||
margin-top: 0.9em;
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose :where(li):not(:where([class~="not-prose"] *)) {
|
||||
margin-block: 0.15em;
|
||||
.prose :where(code):not(:where([class~="not-prose"] *)) {
|
||||
padding: 0.1em 0.3em;
|
||||
font-weight: 500;
|
||||
background: var(--muted);
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
.prose :where(code):not(:where([class~="not-prose"] *))::before,
|
||||
.prose :where(code):not(:where([class~="not-prose"] *))::after {
|
||||
content: none;
|
||||
}
|
||||
.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) {
|
||||
padding: 0.1em 0.3em;
|
||||
font-weight: 500;
|
||||
background: color-mix(in oklch, var(--muted) 70%, transparent);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.prose :where(pre):not(:where([class~="not-prose"] *)) {
|
||||
padding: 0.6em 0.8em;
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
.prose :where(a.internal-link):not(:where([class~="not-prose"] *)) {
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
<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 { page } from "$app/state";
|
||||
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 Markdown from "$lib/panel/markdown.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
@@ -18,7 +15,9 @@
|
||||
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());
|
||||
let expanded = $state<Set<string>>(new Set());
|
||||
let raw = $state(false);
|
||||
const MD_SUFFIX = /\.md$/;
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
@@ -47,6 +46,12 @@
|
||||
selected = path;
|
||||
file = null;
|
||||
fileError = null;
|
||||
const parts = path.split("/");
|
||||
const next = new Set(expanded);
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
next.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
expanded = next;
|
||||
try {
|
||||
file = await client.memoryFile(path);
|
||||
} catch (cause) {
|
||||
@@ -55,16 +60,22 @@
|
||||
}
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(collapsed);
|
||||
const next = new Set(expanded);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
collapsed = next;
|
||||
expanded = next;
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
onMount(async () => {
|
||||
await load();
|
||||
const wanted = page.url.searchParams.get("path");
|
||||
if (wanted) {
|
||||
open(wanted);
|
||||
}
|
||||
});
|
||||
|
||||
const count = $derived.by(() => {
|
||||
let files = 0;
|
||||
@@ -80,67 +91,94 @@
|
||||
walk(tree?.tree ?? []);
|
||||
return files;
|
||||
});
|
||||
|
||||
const isMarkdown = $derived(file?.path.endsWith(".md") ?? false);
|
||||
</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="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[20rem_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"
|
||||
class="min-h-0 overflow-y-auto border-b px-3 py-3 md:border-r md:border-b-0"
|
||||
>
|
||||
<div class="flex items-baseline gap-2 px-1 pb-2">
|
||||
<h1 class="font-semibold text-base tracking-tight">Memory</h1>
|
||||
{#if tree}
|
||||
<span class="tabular text-muted-foreground text-xs">{count} files</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#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"
|
||||
/>
|
||||
<p class="doc px-1 text-muted-foreground text-sm">
|
||||
No memory root. Point ApiFrontend(memory_root=…) at the agent's zone of
|
||||
the vault.
|
||||
</p>
|
||||
{: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>
|
||||
<p class="px-1 text-muted-foreground text-sm">Loading…</p>
|
||||
{:else if tree.tree.length === 0}
|
||||
<EmptyState hint="The zone is empty." title="Nothing here" />
|
||||
<p class="px-1 text-muted-foreground text-sm">The zone is empty.</p>
|
||||
{: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 class="mx-auto max-w-2xl px-6 py-10 text-muted-foreground text-sm">
|
||||
<p>
|
||||
The agent's own zone: state, handouts, observations, prompts, skills.
|
||||
Pick a file on the left, or search for a line with ⌘K.
|
||||
</p>
|
||||
</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>
|
||||
<p class="px-6 py-10 text-center text-muted-foreground text-xs">
|
||||
Opening…
|
||||
</p>
|
||||
{: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>
|
||||
<article class="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-6">
|
||||
<header class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h2 class="min-w-0 truncate font-semibold text-lg tracking-tight">
|
||||
{file.path.split("/").at(-1)?.replace(MD_SUFFIX, "")}
|
||||
</h2>
|
||||
{#if file.path.includes("/")}
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{file.path.split("/").slice(0, -1).join(" / ")}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtBytes(file.size)}
|
||||
</span>
|
||||
<span
|
||||
class="tabular text-muted-foreground text-xs"
|
||||
title={fmtDateTime(file.mtime)}
|
||||
>
|
||||
{fmtRelative(file.mtime)}
|
||||
</span>
|
||||
{#if isMarkdown}
|
||||
<button
|
||||
class="rule-word ml-auto text-xs"
|
||||
data-active={raw}
|
||||
onclick={() => {
|
||||
raw = !raw;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{raw ? "rendered" : "source"}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
{#if isMarkdown && !raw}
|
||||
<Markdown class="prose-base" text={file.content} />
|
||||
{:else}
|
||||
<pre
|
||||
class="whitespace-pre-wrap break-words text-sm leading-relaxed"
|
||||
>{file.content}</pre>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,23 +188,28 @@
|
||||
{#each list as node (node.path)}
|
||||
<li>
|
||||
{#if node.type === "dir"}
|
||||
{@const isOpen = expanded.has(node.path)}
|
||||
<button
|
||||
aria-expanded={!collapsed.has(node.path)}
|
||||
aria-expanded={isOpen}
|
||||
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"
|
||||
style="padding-left: {depth * 0.875 + 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"
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<FolderIcon class="size-4 shrink-0 text-icon" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
<span class="truncate font-medium">{node.name}</span>
|
||||
{#if !isOpen && node.children}
|
||||
<span class="tabular ml-auto text-muted-foreground text-xs">
|
||||
{node.children.length}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if !collapsed.has(node.path) && node.children}
|
||||
{#if isOpen && node.children}
|
||||
{@render nodes(node.children, depth + 1)}
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -174,14 +217,13 @@
|
||||
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"
|
||||
selected === node.path && "bg-accent font-medium"
|
||||
)}
|
||||
onclick={() => open(node.path)}
|
||||
style="padding-left: {depth * 0.75 + 1.5}rem"
|
||||
style="padding-left: {depth * 0.875 + 1.5}rem"
|
||||
type="button"
|
||||
>
|
||||
<FileTextIcon class="size-4 shrink-0 text-icon" />
|
||||
<span class="truncate">{node.name}</span>
|
||||
<span class="truncate">{node.name.replace(MD_SUFFIX, "")}</span>
|
||||
<span class="tabular ml-auto text-muted-foreground text-xs">
|
||||
{fmtBytes(node.size)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { base } from "$app/paths";
|
||||
import { page } from "$app/state";
|
||||
import { isActive, SYSTEM_PAGES } from "$lib/nav";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>System · Beaver</title></svelte:head>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 px-4 py-6 sm:px-6">
|
||||
<nav
|
||||
aria-label="System pages"
|
||||
class="flex flex-wrap items-baseline gap-x-5 gap-y-1"
|
||||
>
|
||||
<h1 class="font-semibold text-lg tracking-tight">System</h1>
|
||||
{#each SYSTEM_PAGES as item (item.href)}
|
||||
{@const active = isActive(page.url.pathname, base, item.href)}
|
||||
<a
|
||||
aria-current={active ? "page" : undefined}
|
||||
class={cn("rule-word", active && "text-foreground")}
|
||||
href="{base}{item.href}"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import type { SessionsResponse } from "$lib/api/types";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { fmtBytes, fmtSeconds, shortId } from "$lib/format";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
let sessions = $state<SessionsResponse | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessions = await client.sessions();
|
||||
failure = null;
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
const poll = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(poll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h2 class="font-medium text-sm">Live claude processes</h2>
|
||||
{#if sessions}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{sessions.sessions.length}
|
||||
· {fmtBytes(sessions.rss)}
|
||||
{#if sessions.rss_limit}
|
||||
of {fmtBytes(sessions.rss_limit)}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if !sessions}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{:else if sessions.sessions.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
No claude processes alive. Sessions spawn on the first turn and are reaped
|
||||
by idle time or memory pressure.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-muted-foreground text-xs">
|
||||
<tr class="border-b text-left">
|
||||
<th class="px-3 py-2 font-medium">conversation</th>
|
||||
<th class="px-3 py-2 font-medium">agent</th>
|
||||
<th class="px-3 py-2 font-medium">state</th>
|
||||
<th class="px-3 py-2 text-right font-medium">rss</th>
|
||||
<th class="px-3 py-2 text-right font-medium">idle</th>
|
||||
<th class="px-3 py-2 text-right font-medium">age</th>
|
||||
<th class="px-3 py-2 text-right font-medium">turns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions.sessions as s (s.key)}
|
||||
<tr class="row-hover border-b last:border-b-0">
|
||||
<td class="px-3 py-1.5">
|
||||
<a
|
||||
class="flex items-center gap-2 hover:underline"
|
||||
href="{base}/conversations/{s.key}"
|
||||
>
|
||||
<KindMark kind={s.kind} />
|
||||
<span class="tabular text-xs">{shortId(s.key)}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-3 py-1.5">{s.agent}</td>
|
||||
<td class="px-3 py-1.5 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-attention-foreground">question</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">{fmtBytes(s.rss)}</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtSeconds(s.idle_seconds)}
|
||||
</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtSeconds(s.age_seconds)}
|
||||
</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">{s.turns}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { AgentsResponse } from "$lib/api/types";
|
||||
import Endpoints from "$lib/components/endpoints.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
|
||||
let agents = $state<AgentsResponse | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
agents = await client.agents();
|
||||
failure = null;
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if !agents}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{:else}
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="font-medium text-sm">Agents</h2>
|
||||
<ul class="rounded-lg border bg-strip">
|
||||
{#each agents.agents as a (a.name)}
|
||||
<li
|
||||
class="ledger-grid grid-cols-[minmax(0,1fr)_auto] border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<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}` : ""}
|
||||
· {a.type}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex gap-1">
|
||||
{#each a.kinds as k (k)}
|
||||
<KindMark kind={k} />
|
||||
{/each}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="font-medium text-sm">Endpoints</h2>
|
||||
<p class="doc text-muted-foreground text-sm">
|
||||
Where clients connect. Copy a URL into the Obsidian plugin, Cursor, or
|
||||
curl together with a token of the matching scope.
|
||||
</p>
|
||||
<div class="rounded-lg border bg-strip px-3">
|
||||
<Endpoints catalog={agents} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,97 @@
|
||||
<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 { Button } from "$lib/components/ui/button";
|
||||
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>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={() => load()} />
|
||||
{:else if records === null}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{: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>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import type { JobsResponse, QueuedJob } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import StatusPill from "$lib/components/status-pill.svelte";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { clip, fmtDateTime, fmtPct, fmtRelative } from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import Strip from "$lib/shell/strip.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const PAYLOAD_MAX = 120;
|
||||
|
||||
let data = $state<JobsResponse | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let busy = $state<string | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
failure = null;
|
||||
try {
|
||||
data = await client.jobs();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(name: string) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = name;
|
||||
try {
|
||||
await client.runJob(name);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: QueuedJob) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = `queue:${job.id}`;
|
||||
try {
|
||||
await client.cancelJob(job.id);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function describe(job: QueuedJob): string {
|
||||
const { payload } = job;
|
||||
const { text } = payload;
|
||||
if (typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
const raw = JSON.stringify(payload);
|
||||
return raw === "{}" ? "" : raw;
|
||||
}
|
||||
|
||||
function triggers(job: JobsResponse["jobs"][number]): string {
|
||||
const parts: string[] = [];
|
||||
if (job.cron) {
|
||||
parts.push(job.cron);
|
||||
}
|
||||
if (job.webhook) {
|
||||
parts.push(`POST /hooks/${job.name}`);
|
||||
}
|
||||
for (const event of job.events) {
|
||||
parts.push(`on ${event}`);
|
||||
}
|
||||
return parts.join(" · ") || "manual";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
timer = setInterval(load, TICK_MS);
|
||||
});
|
||||
onDestroy(() => clearInterval(timer));
|
||||
|
||||
const runs = $derived(
|
||||
gateway.conversations
|
||||
.filter((row) => row.kind === "job" || row.kind === "fork")
|
||||
.slice(0, 30)
|
||||
);
|
||||
const pending = $derived(
|
||||
data?.queue.filter((q) => q.status === "queued") ?? []
|
||||
);
|
||||
const picked = $derived(
|
||||
data?.queue.filter((q) => q.status !== "queued") ?? []
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-baseline gap-3">
|
||||
<h2 class="font-medium text-sm">Scheduler</h2>
|
||||
{#if data}
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{data.jobs.length}
|
||||
jobs · {pending.length} queued · window {fmtPct(data.utilization)}
|
||||
{data.throttled ? " · throttled" : ""}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={load} />
|
||||
{:else if data === null}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{:else}
|
||||
{#if !data.enabled}
|
||||
<EmptyState
|
||||
hint="The gateway is not on Postgres: cron and webhooks are off, `schedule` is unavailable. Event jobs still run in-process."
|
||||
title="Scheduler is off"
|
||||
/>
|
||||
{/if}
|
||||
{#if data.throttled}
|
||||
<p class="text-sm text-warn">
|
||||
Subscription window at {fmtPct(data.utilization)} (threshold
|
||||
{fmtPct(
|
||||
data.threshold
|
||||
)}): non-critical jobs are deferred.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="label-quiet">Jobs</h3>
|
||||
{#if data.jobs.length === 0}
|
||||
<p class="text-muted-foreground text-sm">No jobs in config.</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each data.jobs as job (job.name)}
|
||||
<li
|
||||
class="ledger-grid grid-cols-[10rem_minmax(0,1fr)_9rem_9rem_5rem] items-center border-b py-2 text-sm"
|
||||
>
|
||||
<span class="truncate font-medium">
|
||||
{job.name}
|
||||
{#if !job.critical}
|
||||
<span class="text-muted-foreground text-xs">· soft</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate text-muted-foreground text-xs">
|
||||
{triggers(job)}
|
||||
</span>
|
||||
<span
|
||||
class="tabular text-muted-foreground text-xs"
|
||||
title={fmtDateTime(job.next_run)}
|
||||
>
|
||||
{job.next_run ? `next ${fmtRelative(job.next_run)}` : ""}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-xs">
|
||||
{#if job.run}
|
||||
<StatusPill status={job.run.status} />
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
title={fmtDateTime(job.run.started_at)}
|
||||
>
|
||||
{fmtRelative(job.run.started_at)}
|
||||
· {job.run.trigger}
|
||||
</span>
|
||||
{:else if job.last_run}
|
||||
<span
|
||||
class="text-muted-foreground"
|
||||
title={fmtDateTime(job.last_run)}
|
||||
>
|
||||
ran {fmtRelative(job.last_run)}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<Button
|
||||
disabled={busy === job.name}
|
||||
onclick={() => run(job.name)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="label-quiet">Queue</h3>
|
||||
{#if data.queue.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Nothing queued: no deferred injects, no pending webhooks.
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="flex flex-col">
|
||||
{#each [...picked, ...pending] as job (job.id)}
|
||||
<li
|
||||
class={cn(
|
||||
"ledger-grid grid-cols-[8rem_7rem_minmax(0,1fr)_9rem_5rem] items-center border-b py-2 text-sm",
|
||||
job.status !== "queued" && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
class="tabular text-xs"
|
||||
title={fmtDateTime(job.execute_after)}
|
||||
>
|
||||
{job.status === "queued"
|
||||
? fmtRelative(job.execute_after)
|
||||
: job.status}
|
||||
</span>
|
||||
<span class="truncate text-xs">{job.entrypoint}</span>
|
||||
<span class="truncate" title={JSON.stringify(job.payload)}>
|
||||
{clip(describe(job), PAYLOAD_MAX)}
|
||||
</span>
|
||||
<span class="tabular text-right text-muted-foreground text-xs">
|
||||
{fmtDateTime(job.execute_after)}
|
||||
</span>
|
||||
{#if job.status === "queued"}
|
||||
<Button
|
||||
disabled={busy === `queue:${job.id}`}
|
||||
onclick={() => cancel(job)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="label-quiet">Background conversations</h3>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Headless runs the scheduler and the merges spawned: they never enter the
|
||||
rail.
|
||||
</p>
|
||||
{#if runs.length === 0}
|
||||
<p class="text-muted-foreground text-sm">None yet.</p>
|
||||
{:else}
|
||||
<div class="bay-rack">
|
||||
{#each runs as row (row.id)}
|
||||
<Strip
|
||||
detail={row.last_item?.text ?? ""}
|
||||
href="{base}/conversations/{row.id}"
|
||||
{row}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -5,13 +5,11 @@
|
||||
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";
|
||||
@@ -92,99 +90,98 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Tokens · Beaver</title></svelte:head>
|
||||
|
||||
<PageHeader title="Tokens">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<h2 class="font-medium text-sm">Bearer tokens</h2>
|
||||
<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>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
onclick={() => {
|
||||
createOpen = true;
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="flex flex-col gap-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={() => {
|
||||
createOpen = true;
|
||||
}}
|
||||
onclick={() => copy(created?.plaintext ?? "")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Create the first token
|
||||
<CopyIcon class="size-4" />
|
||||
Copy
|
||||
</Button>
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<Button
|
||||
onclick={() => {
|
||||
created = null;
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if failure}
|
||||
<ErrorNote message={failure} retry={() => load()} />
|
||||
{:else if tokens === null}
|
||||
<p class="text-muted-foreground text-sm">Loading…</p>
|
||||
{: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}
|
||||
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||||
<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>
|
||||
<th class="px-3 py-2 font-medium">name</th>
|
||||
<th class="px-3 py-2 font-medium">scope</th>
|
||||
<th class="px-3 py-2 font-medium">created</th>
|
||||
<th class="px-3 py-2 font-medium">last used</th>
|
||||
<th class="px-3 py-2 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")}
|
||||
class={cn("row-hover border-b last:border-b-0", token.revoked_at && "text-muted-foreground")}
|
||||
>
|
||||
<td class="py-1.5 pr-3 font-medium">
|
||||
<td class="px-3 py-1.5 font-medium">
|
||||
{token.name}
|
||||
{#if token.revoked_at}
|
||||
<span class="ml-2 text-xs"
|
||||
@@ -192,18 +189,18 @@
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1.5 pr-3">
|
||||
<td class="px-3 py-1.5">
|
||||
<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">
|
||||
<td class="tabular px-3 py-1.5 text-xs">
|
||||
{fmtDateTime(token.created_at)}
|
||||
</td>
|
||||
<td class="tabular py-1.5 pr-3 text-xs">
|
||||
<td class="tabular px-3 py-1.5 text-xs">
|
||||
{token.last_used_at ? fmtRelative(token.last_used_at) : "never"}
|
||||
</td>
|
||||
<td class="py-1.5 text-right">
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
{#if !token.revoked_at}
|
||||
<Button
|
||||
onclick={() => revoke(token)}
|
||||
@@ -218,8 +215,8 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
+177
-121
@@ -2,26 +2,21 @@
|
||||
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,
|
||||
fmtCountdown,
|
||||
fmtDateTime,
|
||||
fmtMoney,
|
||||
fmtPct,
|
||||
fmtRelative,
|
||||
fmtTokens,
|
||||
shortId,
|
||||
} from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { limitLabel } from "$lib/limits";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const RANGES: { value: string; label: string; hours: number }[] = [
|
||||
@@ -36,12 +31,19 @@
|
||||
{ label: "by day", value: "day" },
|
||||
];
|
||||
const TICK_MS = 30_000;
|
||||
const LOW_CACHE = 0.5;
|
||||
const BAR: Record<string, string> = {
|
||||
allowed: "bg-primary",
|
||||
allowed_warning: "bg-primary",
|
||||
rejected: "bg-destructive",
|
||||
};
|
||||
|
||||
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());
|
||||
let historyOpen = $state(false);
|
||||
|
||||
async function load(rangeValue: string, groupValue: UsageGroup) {
|
||||
const { client } = session;
|
||||
@@ -85,137 +87,181 @@
|
||||
}
|
||||
return row.key;
|
||||
}
|
||||
|
||||
const pct = (u: number | null | undefined) =>
|
||||
Math.min(100, Math.round((u ?? 0) * 100));
|
||||
</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">
|
||||
<div class="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-6 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>
|
||||
<section class="flex flex-col gap-4">
|
||||
<h1 class="font-semibold text-lg tracking-tight">Subscription</h1>
|
||||
{#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"
|
||||
/>
|
||||
<p class="doc text-muted-foreground text-sm">
|
||||
No rate-limit report yet. The SDK sends one the first time a window
|
||||
changes state; until then there is nothing to show.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y">
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each windows as w (w.window)}
|
||||
<LimitBar {now} window={w} />
|
||||
{@const known = w.utilization !== null && w.utilization !== undefined}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="label-quiet">{limitLabel(w.window)}</span>
|
||||
{#if known}
|
||||
<span
|
||||
class={cn(
|
||||
"tabular font-semibold text-4xl leading-none tracking-tight",
|
||||
w.status === "rejected" && "text-destructive"
|
||||
)}
|
||||
>
|
||||
{pct(w.utilization)}
|
||||
<span class="font-normal text-lg text-muted-foreground"
|
||||
>%</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
aria-valuemax="100"
|
||||
aria-valuemin="0"
|
||||
aria-valuenow={pct(w.utilization)}
|
||||
class="h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||
role="progressbar"
|
||||
>
|
||||
<span
|
||||
class={cn("block h-full rounded-full", BAR[w.status])}
|
||||
style="width: {pct(w.utilization)}%"
|
||||
></span>
|
||||
</span>
|
||||
<span class="tabular text-muted-foreground text-xs">
|
||||
{fmtCountdown(w.resets_at, now)}
|
||||
· gateway share
|
||||
{fmtTokens(
|
||||
w.gateway.input + w.gateway.output + w.gateway.cache_creation
|
||||
)}
|
||||
tokens · {fmtMoney(w.gateway.cost_usd)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-2xl text-muted-foreground leading-none">
|
||||
no report
|
||||
</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
last seen {fmtRelative(w.ts, now)} ·
|
||||
{fmtCountdown(
|
||||
w.resets_at,
|
||||
now
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-baseline gap-x-5 gap-y-2">
|
||||
<h2 class="font-semibold text-lg tracking-tight">Tokens</h2>
|
||||
<span class="flex items-center gap-3">
|
||||
{#each RANGES as r (r.value)}
|
||||
<button
|
||||
class="rule-word text-sm"
|
||||
data-active={range === r.value}
|
||||
onclick={() => {
|
||||
range = r.value;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-3">
|
||||
{#each GROUPS as g (g.value)}
|
||||
<button
|
||||
class="rule-word text-xs"
|
||||
data-active={group === g.value}
|
||||
onclick={() => {
|
||||
group = g.value;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{#if !(data && total)}
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<p class="text-muted-foreground text-sm">Summing…</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 border-y py-3 sm:grid-cols-4 lg:grid-cols-6"
|
||||
class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 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)}
|
||||
/>
|
||||
{#each [["spent", fmtMoney(total.cost_usd), ""], ["turns", String(total.turns), ""], ["input", fmtTokens(total.input), ""], ["output", fmtTokens(total.output), ""], ["cache share", fmtPct(share), share !== null && share < LOW_CACHE && total.turns > 0 ? "warn" : ""], ["cache written", fmtTokens(total.cache_creation), ""]] as [name, value, tone] (name)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="label-quiet">{name}</span>
|
||||
<span
|
||||
class={cn(
|
||||
"tabular font-semibold text-2xl leading-none tracking-tight",
|
||||
tone === "warn" && "text-primary"
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
API-price equivalent of what went through the gateway since
|
||||
{fmtDateTime(
|
||||
data.since
|
||||
)}; the subscription is billed differently.
|
||||
</p>
|
||||
{#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"
|
||||
/>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
No turns in this range. Widen it, or wait for the agents to work.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<div class="overflow-x-auto rounded-lg border bg-strip">
|
||||
<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 class="px-3 py-2 font-medium">
|
||||
{group.replace("_", " ")}
|
||||
</th>
|
||||
<th class="py-1.5 text-right font-medium">cache share</th>
|
||||
<th class="w-36 px-3 py-2 font-medium">spent</th>
|
||||
<th class="px-3 py-2 text-right font-medium">turns</th>
|
||||
<th class="px-3 py-2 text-right font-medium">in</th>
|
||||
<th class="px-3 py-2 text-right font-medium">out</th>
|
||||
<th class="px-3 py-2 text-right font-medium">cache read</th>
|
||||
<th class="px-3 py-2 text-right font-medium">cache write</th>
|
||||
<th class="px-3 py-2 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">
|
||||
<tr class="row-hover border-b last:border-b-0">
|
||||
<td class="max-w-64 px-3 py-1.5">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
{#if group === "conversation"}
|
||||
{#if row.kind}
|
||||
<KindBadge kind={row.kind} />
|
||||
<KindMark kind={row.kind} />
|
||||
{/if}
|
||||
<a
|
||||
class="truncate font-medium text-link hover:underline"
|
||||
class="truncate font-medium hover:underline"
|
||||
href="{base}/conversations/{row.key}"
|
||||
>
|
||||
{label(row)}
|
||||
</a>
|
||||
{#if row.status && row.status !== "open"}
|
||||
<StatusPill status={row.status} />
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{row.status}</span
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="truncate font-medium">{label(row)}</span>
|
||||
@@ -227,10 +273,10 @@
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-1.5 pr-3">
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-1.5 w-16 overflow-hidden rounded-full bg-muted"
|
||||
class="h-1 w-16 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<span
|
||||
class="block h-full rounded-full bg-primary"
|
||||
@@ -242,23 +288,23 @@
|
||||
<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">
|
||||
<td class="tabular px-3 py-1.5 text-right">{row.turns}</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtTokens(row.input)}
|
||||
</td>
|
||||
<td class="tabular py-1.5 pr-3 text-right">
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtTokens(row.output)}
|
||||
</td>
|
||||
<td class="tabular py-1.5 pr-3 text-right">
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtTokens(row.cache_read)}
|
||||
</td>
|
||||
<td class="tabular py-1.5 pr-3 text-right">
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtTokens(row.cache_creation)}
|
||||
</td>
|
||||
<td
|
||||
class={cn(
|
||||
"tabular py-1.5 text-right",
|
||||
rowShare !== null && rowShare < 0.5 && "text-warn"
|
||||
"tabular px-3 py-1.5 text-right",
|
||||
rowShare !== null && rowShare < LOW_CACHE && "text-primary"
|
||||
)}
|
||||
>
|
||||
{fmtPct(rowShare)}
|
||||
@@ -274,25 +320,35 @@
|
||||
|
||||
{#if history.length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h2
|
||||
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
|
||||
<button
|
||||
aria-expanded={historyOpen}
|
||||
class="rule-word flex items-center gap-2 self-start text-sm"
|
||||
onclick={() => {
|
||||
historyOpen = !historyOpen;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
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
|
||||
Rate-limit reports
|
||||
<span class="tabular text-xs">{history.length}</span>
|
||||
</button>
|
||||
{#if historyOpen}
|
||||
<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>{limitLabel(h.window)}</span>
|
||||
<span class="tabular">{fmtPct(h.utilization)}</span>
|
||||
<StatusPill status={h.status} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<span class="tabular text-muted-foreground"
|
||||
>{fmtDateTime(h.ts)}</span
|
||||
>
|
||||
<span>{limitLabel(h.window)}</span>
|
||||
<span class="tabular">{fmtPct(h.utilization)}</span>
|
||||
<span class="text-muted-foreground"
|
||||
>{h.status.replace("_", " ")}</span
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user