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
@@ -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>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script lang="ts">
|
||||
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { TokenRow } from "$lib/api/types";
|
||||
import EmptyState from "$lib/components/empty-state.svelte";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { 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 { Switch } from "$lib/components/ui/switch";
|
||||
import { fmtDateTime, fmtRelative } from "$lib/format";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const SCOPES = [
|
||||
{ hint: "every frontend", value: "*" },
|
||||
{ hint: "conversations API and SSE (panel, plugin)", value: "api" },
|
||||
{ hint: "Anthropic /v1/messages", value: "messages" },
|
||||
{ hint: "MCP server", value: "mcp" },
|
||||
{ hint: "tokens and audit over the API", value: "admin" },
|
||||
];
|
||||
|
||||
let tokens = $state<TokenRow[] | null>(null);
|
||||
let includeRevoked = $state(false);
|
||||
let failure = $state<string | null>(null);
|
||||
let createOpen = $state(false);
|
||||
let name = $state("");
|
||||
let scope = $state("api");
|
||||
let busy = $state(false);
|
||||
let created = $state<{ name: string; plaintext: string } | null>(null);
|
||||
|
||||
async function load(revoked = includeRevoked) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
failure = null;
|
||||
try {
|
||||
({ tokens } = await client.tokens(revoked));
|
||||
} catch (cause) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load(includeRevoked);
|
||||
});
|
||||
|
||||
async function create() {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const result = await client.createToken(name.trim(), scope);
|
||||
created = { name: result.token.name, plaintext: result.plaintext };
|
||||
createOpen = false;
|
||||
name = "";
|
||||
await load();
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token: TokenRow) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.revokeToken(token.id);
|
||||
toast.success(`Revoked ${token.name}`);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
toast.error(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => toast.success("Copied"))
|
||||
.catch(() => toast.error("Clipboard is not available"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
onclick={() => {
|
||||
createOpen = true;
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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={() => 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}
|
||||
<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="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 last:border-b-0", token.revoked_at && "text-muted-foreground")}
|
||||
>
|
||||
<td class="px-3 py-1.5 font-medium">
|
||||
{token.name}
|
||||
{#if token.revoked_at}
|
||||
<span class="ml-2 text-xs"
|
||||
>revoked {fmtRelative(token.revoked_at)}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<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 px-3 py-1.5 text-xs">
|
||||
{fmtDateTime(token.created_at)}
|
||||
</td>
|
||||
<td class="tabular px-3 py-1.5 text-xs">
|
||||
{token.last_used_at ? fmtRelative(token.last_used_at) : "never"}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right">
|
||||
{#if !token.revoked_at}
|
||||
<Button
|
||||
onclick={() => revoke(token)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={createOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>New bearer token</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
The value is generated by the gateway and shown once.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
class="flex flex-col gap-3"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
create();
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="token-name">Name</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="cursor-laptop"
|
||||
required
|
||||
bind:value={name}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>Scope</Label>
|
||||
<Select.Root
|
||||
onValueChange={(value) => {
|
||||
scope = value;
|
||||
}}
|
||||
type="single"
|
||||
value={scope}
|
||||
>
|
||||
<Select.Trigger class="w-full">{scope}</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each SCOPES as option (option.value)}
|
||||
<Select.Item label={option.value} value={option.value}>
|
||||
<span class="flex flex-col">
|
||||
<span>{option.value}</span>
|
||||
<span class="text-muted-foreground text-xs"
|
||||
>{option.hint}</span
|
||||
>
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
onclick={() => {
|
||||
createOpen = false;
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !name.trim()} type="submit">Create</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in new issue
Block a user