feat(scheduler,rotation,envelope,api,ui): pgqueuer jobs and deferred injects, master rotation with handout, vault envelope, jobs page
This commit is contained in:
+205
-35
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user