feat(backends,api,ui): process trees with cpu and stderr per session, interrupt, close and kill from the system page
This commit is contained in:
1 parent
9875f08016
commit
712c24de4e
12 files changed
+996
-127
No files matched your search
@@ -1,16 +1,39 @@
|
||||
<script lang="ts">
|
||||
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||
import { onMount } from "svelte";
|
||||
import { base } from "$app/paths";
|
||||
import type { SessionsResponse } from "$lib/api/types";
|
||||
import type {
|
||||
ProcessRow,
|
||||
SessionRow,
|
||||
SessionsResponse,
|
||||
} from "$lib/api/types";
|
||||
import ErrorNote from "$lib/components/error-note.svelte";
|
||||
import { fmtBytes, fmtSeconds, shortId } from "$lib/format";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import { fmtBytes, fmtCpu, fmtSeconds, shortId } from "$lib/format";
|
||||
import { gateway } from "$lib/gateway.svelte";
|
||||
import { session } from "$lib/session.svelte";
|
||||
import KindMark from "$lib/shell/kind-mark.svelte";
|
||||
import ProcessTable from "$lib/system/process-table.svelte";
|
||||
import SessionDetail from "$lib/system/session-detail.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
const POLL_MS = 10_000;
|
||||
interface Order {
|
||||
confirm: string;
|
||||
lines: string[];
|
||||
run: () => Promise<unknown>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const POLL_MS = 3000;
|
||||
const SECOND_LOOK_FOR_CPU_MS = 1000;
|
||||
const COLUMNS = 8;
|
||||
let sessions = $state<SessionsResponse | null>(null);
|
||||
let failure = $state<string | null>(null);
|
||||
let unfolded = $state<Set<string>>(new Set());
|
||||
let order = $state<Order | null>(null);
|
||||
let orderFailure = $state<string | null>(null);
|
||||
let notes = $state<Record<string, string>>({});
|
||||
let busy = $state(false);
|
||||
|
||||
async function load() {
|
||||
const { client } = session;
|
||||
@@ -25,90 +48,315 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
const next = new Set(unfolded);
|
||||
if (!next.delete(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
unfolded = next;
|
||||
}
|
||||
|
||||
function ask(next: Order) {
|
||||
orderFailure = null;
|
||||
order = next;
|
||||
}
|
||||
|
||||
async function carryOut(pending: Order) {
|
||||
busy = true;
|
||||
try {
|
||||
await pending.run();
|
||||
order = null;
|
||||
await load();
|
||||
} catch (cause) {
|
||||
orderFailure = cause instanceof Error ? cause.message : String(cause);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function interrupt(row: SessionRow) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const { interrupted } = await client.interruptSession(row.key);
|
||||
notes = {
|
||||
...notes,
|
||||
[row.key]: interrupted ? "" : "Nothing was running.",
|
||||
};
|
||||
await load();
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
notes = { ...notes, [row.key]: message };
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function askClose(row: SessionRow) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
"The claude process exits. The conversation stays and resumes from its transcript on the next message.",
|
||||
];
|
||||
if (row.busy) {
|
||||
lines.push("A turn is running: it is cut and its reply is lost.");
|
||||
}
|
||||
if (row.pinned) {
|
||||
lines.push("This session is pinned: the gateway never closes it itself.");
|
||||
}
|
||||
ask({
|
||||
confirm: "Close session",
|
||||
lines,
|
||||
run: () => client.closeSession(row.key),
|
||||
title: `Close the ${row.kind} session?`,
|
||||
});
|
||||
}
|
||||
|
||||
function askKill(process: ProcessRow) {
|
||||
const { client } = session;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const owner = sessions?.sessions.find((row) => row.pid === process.pid);
|
||||
const lines = [
|
||||
`SIGKILL to ${process.name} and everything it started. Nothing gets to clean up.`,
|
||||
];
|
||||
if (owner) {
|
||||
lines.push(
|
||||
"It is a session's claude: the session is dropped, and the conversation resumes from its transcript on the next message."
|
||||
);
|
||||
}
|
||||
ask({
|
||||
confirm: "Kill",
|
||||
lines,
|
||||
run: () => client.killProcess(process.pid),
|
||||
title: `Kill process ${process.pid}?`,
|
||||
});
|
||||
}
|
||||
|
||||
const titles = $derived(
|
||||
new Map(gateway.conversations.map((row) => [row.id, row.title]))
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
const second = setTimeout(load, SECOND_LOOK_FOR_CPU_MS);
|
||||
const poll = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(poll);
|
||||
return () => {
|
||||
clearTimeout(second);
|
||||
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>
|
||||
{#snippet state(s: SessionRow)}
|
||||
<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}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-wrap 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}
|
||||
{sessions.sessions.length === 1 ? "session" : "sessions"}
|
||||
· {fmtBytes(sessions.rss)}
|
||||
{#if sessions.rss_limit}
|
||||
of {fmtBytes(sessions.rss_limit)}
|
||||
{/if}
|
||||
· cpu {fmtCpu(sessions.cpu)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#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}"
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="label-quiet">Sessions</h3>
|
||||
{#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="hidden px-3 py-2 font-medium sm:table-cell">
|
||||
agent
|
||||
</th>
|
||||
<th class="hidden px-3 py-2 font-medium sm:table-cell">
|
||||
state
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right font-medium">cpu</th>
|
||||
<th class="px-3 py-2 text-right font-medium">rss</th>
|
||||
<th
|
||||
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
|
||||
>
|
||||
<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")}
|
||||
idle
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
|
||||
>
|
||||
{s.busy ? "busy" : "idle"}
|
||||
</span>
|
||||
{#if s.pinned}
|
||||
<span class="ml-1 text-muted-foreground">pinned</span>
|
||||
age
|
||||
</th>
|
||||
<th
|
||||
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
|
||||
>
|
||||
turns
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sessions.sessions as s (s.key)}
|
||||
{@const expanded = unfolded.has(s.key)}
|
||||
<tr class={cn("border-b", !expanded && "last:border-b-0")}>
|
||||
<td class="max-w-0 px-1.5 py-1 sm:w-2/5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
class="row-hover flex max-w-full items-center gap-2 rounded-md px-1.5 py-1 text-left max-sm:min-h-10"
|
||||
onclick={() => toggle(s.key)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
class={cn(
|
||||
"size-3.5 text-icon transition-transform duration-150",
|
||||
expanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<KindMark kind={s.kind} />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-medium">
|
||||
{titles.get(s.key) ?? shortId(s.key)}
|
||||
</span>
|
||||
<span class="text-xs sm:hidden">
|
||||
{@render state(s)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 sm:table-cell">{s.agent}</td>
|
||||
<td
|
||||
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
|
||||
>
|
||||
{@render state(s)}
|
||||
</td>
|
||||
<td class="tabular px-3 py-1.5 text-right">
|
||||
{fmtCpu(s.cpu)}
|
||||
</td>
|
||||
<td class="tabular whitespace-nowrap px-3 py-1.5 text-right">
|
||||
{fmtBytes(s.rss)}
|
||||
</td>
|
||||
<td
|
||||
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
|
||||
>
|
||||
{fmtSeconds(s.idle_seconds)}
|
||||
</td>
|
||||
<td
|
||||
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
|
||||
>
|
||||
{fmtSeconds(s.age_seconds)}
|
||||
</td>
|
||||
<td
|
||||
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
|
||||
>
|
||||
{s.turns}
|
||||
</td>
|
||||
</tr>
|
||||
{#if expanded && session.client}
|
||||
<tr class="border-b last:border-b-0">
|
||||
<td class="bg-background/60" colspan={COLUMNS}>
|
||||
<SessionDetail
|
||||
{busy}
|
||||
client={session.client}
|
||||
note={notes[s.key] ?? ""}
|
||||
onClose={askClose}
|
||||
onInterrupt={interrupt}
|
||||
onKill={askKill}
|
||||
row={s}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="label-quiet">Outside any session</h3>
|
||||
{#if sessions.others.length === 0}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Nothing else runs under the gateway.
|
||||
</p>
|
||||
{:else}
|
||||
<ProcessTable onKill={askKill} rows={sessions.others} />
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Root
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
order = null;
|
||||
}
|
||||
}}
|
||||
open={order !== null}
|
||||
>
|
||||
<Dialog.Content>
|
||||
{#if order}
|
||||
{@const pending = order}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{pending.title}</Dialog.Title>
|
||||
{#each pending.lines as line (line)}
|
||||
<Dialog.Description>{line}</Dialog.Description>
|
||||
{/each}
|
||||
{#if orderFailure}
|
||||
<p class="text-destructive text-sm" role="alert">{orderFailure}</p>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
order = null;
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() => carryOut(pending)}
|
||||
variant="destructive"
|
||||
>
|
||||
{pending.confirm}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in new issue
Block a user