feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page

This commit is contained in:
hh
2026-08-29 02:29:05 +02:00
parent fd69eae05d
commit bc4233977d
27 changed files with 2123 additions and 120 deletions
+205 -35
View File
@@ -1,16 +1,23 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Schedule } from "$lib/api/types";
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, fmtRelative } from "$lib/format";
import { clip, fmtDateTime, fmtPct, fmtRelative } from "$lib/format";
import { session } from "$lib/session.svelte";
import { cn } from "$lib/utils";
let schedules = $state<Schedule[] | null>(null);
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;
@@ -19,62 +26,225 @@
}
failure = null;
try {
({ schedules } = await client.schedules());
data = await client.jobs();
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
onMount(load);
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;
}
}
const pending = $derived(schedules?.filter((s) => !s.delivered_at) ?? []);
const delivered = $derived(schedules?.filter((s) => s.delivered_at) ?? []);
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={schedules ? `${pending.length} pending` : ""}
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">
<EmptyState
hint="Cron jobs, the master rotation and the envelope arrive with the scheduler (S6). Until then this tab lists the deferred injects the agent scheduled for itself."
title="Scheduler not wired yet"
/>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if schedules === null}
{:else if data === null}
<Skeleton class="h-24 w-full" />
{:else if schedules.length === 0}
<p class="text-muted-foreground text-sm">No deferred injects.</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">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
>
Deferred injects
Jobs
</h2>
<ul class="flex flex-col">
{#each [...pending, ...delivered] as s (s.id)}
<li
class={cn(
"ledger-grid grid-cols-[9rem_minmax(0,1fr)_7rem] border-b py-2 text-sm",
s.delivered_at && "text-muted-foreground"
)}
>
<span class="tabular text-xs" title={fmtDateTime(s.execute_at)}>
{s.delivered_at ? "delivered" : fmtRelative(s.execute_at)}
</span>
<span class="truncate">{clip(s.text, 160)}</span>
<span class="tabular text-right text-muted-foreground text-xs">
{fmtDateTime(s.execute_at)}
</span>
</li>
{/each}
</ul>
{#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>