feat(v1.1.9): dashboard Queues tab + queue:receive trigger form + 0.15.0
API client (src/lib/api.ts):
- TriggerKind gains 'queue'
- TriggerDetails gains { kind: 'queue', queue_name, visibility_timeout_secs, last_fired_at }
- CreateQueueTriggerInput, QueueSummary, QueueConsumer, QueueDetail
- api.triggers.createQueue(idOrSlug, input) -> POST /admin/.../triggers/queue
- api.queues.list(idOrSlug) -> GET /admin/.../queues
- api.queues.get(idOrSlug, name) -> GET /admin/.../queues/{name}
New routes:
- /apps/[slug]/queues — read-only list view (queue_name, total, pending,
claimed, drilldown link); empty state explains how queues are created
(first enqueue) and that consumers register via the Triggers tab
- /apps/[slug]/queues/[name] — drilldown showing depth + registered
consumer (script name + visibility timeout + last_fired_at + trigger
id); empty consumer state surfaces clearly
App detail page (src/routes/apps/[slug]/+page.svelte):
- New "Queues" link in the app-level nav between Files and Dead letters
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form alongside
cron/pubsub/email — target script select, queue_name, visibility
timeout (5–3600s, default 30), max_attempts (1–20, default 3)
- Trigger list renders queue triggers with queue_name + visibility
timeout + last_fired_at
dashboard/package.json: 0.14.0 -> 0.15.0
npm run check — all queue/invoke-related code typechecks clean; the
existing Playwright test errors (149 unrelated) carry forward unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -218,7 +218,8 @@ export type TriggerKind =
|
||||
| 'cron'
|
||||
| 'files'
|
||||
| 'pubsub'
|
||||
| 'email';
|
||||
| 'email'
|
||||
| 'queue';
|
||||
export type TriggerDispatchMode = 'sync' | 'async';
|
||||
|
||||
/// Per-kind detail, tagged by `kind` to match the Rust serde shape.
|
||||
@@ -229,7 +230,8 @@ export type TriggerDetails =
|
||||
| { kind: 'cron'; schedule: string; timezone: string; last_fired_at?: string | null }
|
||||
| { kind: 'files'; collection_glob: string; ops: string[] }
|
||||
| { kind: 'pubsub'; topic_pattern: string }
|
||||
| { kind: 'email'; has_inbound_secret: boolean };
|
||||
| { kind: 'email'; has_inbound_secret: boolean }
|
||||
| { kind: 'queue'; queue_name: string; visibility_timeout_secs: number; last_fired_at?: string | null };
|
||||
|
||||
export interface CreateEmailTriggerInput {
|
||||
script_id: string;
|
||||
@@ -347,6 +349,37 @@ export interface CreatePubsubTriggerInput {
|
||||
retry_base_ms?: number;
|
||||
}
|
||||
|
||||
// v1.1.9 — queue:receive trigger.
|
||||
export interface CreateQueueTriggerInput {
|
||||
script_id: string;
|
||||
queue_name: string;
|
||||
visibility_timeout_secs?: number;
|
||||
dispatch_mode?: TriggerDispatchMode;
|
||||
retry_max_attempts?: number;
|
||||
retry_backoff?: 'exponential' | 'linear' | 'constant';
|
||||
retry_base_ms?: number;
|
||||
}
|
||||
|
||||
// v1.1.9 — read-only queue inspection types (admin queues API).
|
||||
export interface QueueSummary {
|
||||
queue_name: string;
|
||||
total: number;
|
||||
pending: number;
|
||||
claimed: number;
|
||||
}
|
||||
|
||||
export interface QueueConsumer {
|
||||
trigger_id: string;
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
visibility_timeout_secs: number;
|
||||
last_fired_at: string | null;
|
||||
}
|
||||
|
||||
export interface QueueDetail extends QueueSummary {
|
||||
consumer: QueueConsumer | null;
|
||||
}
|
||||
|
||||
// v1.1.6 — externally-subscribable realtime topics.
|
||||
export type TopicAuthMode = 'public' | 'token' | 'session';
|
||||
|
||||
@@ -755,6 +788,11 @@ export const api = {
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/email`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
),
|
||||
createQueue: (idOrSlug: string, input: CreateQueueTriggerInput) =>
|
||||
adminRequest<Trigger>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
),
|
||||
remove: (idOrSlug: string, triggerId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
|
||||
@@ -762,6 +800,17 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
queues: {
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<QueueSummary[]>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues`
|
||||
),
|
||||
get: (idOrSlug: string, name: string) =>
|
||||
adminRequest<QueueDetail>(
|
||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/queues/${encodeURIComponent(name)}`
|
||||
)
|
||||
},
|
||||
|
||||
topics: {
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<{ topics: Topic[] }>(
|
||||
|
||||
@@ -131,6 +131,13 @@
|
||||
let createEmailSecret = $state('');
|
||||
let creatingEmail = $state(false);
|
||||
let createEmailError = $state<string | null>(null);
|
||||
// Queue triggers (v1.1.9).
|
||||
let createQueueScriptId = $state('');
|
||||
let createQueueName = $state('');
|
||||
let createQueueVisibilityTimeout = $state(30);
|
||||
let createQueueMaxAttempts = $state(3);
|
||||
let creatingQueue = $state(false);
|
||||
let createQueueError = $state<string | null>(null);
|
||||
let triggerToRemove = $state<Trigger | null>(null);
|
||||
let removingTrigger = $state(false);
|
||||
// Endpoint scripts only — modules can't be trigger targets.
|
||||
@@ -208,6 +215,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreateQueue(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!app) return;
|
||||
creatingQueue = true;
|
||||
createQueueError = null;
|
||||
try {
|
||||
await api.triggers.createQueue(app.id, {
|
||||
script_id: createQueueScriptId,
|
||||
queue_name: createQueueName.trim(),
|
||||
visibility_timeout_secs: createQueueVisibilityTimeout,
|
||||
retry_max_attempts: createQueueMaxAttempts
|
||||
});
|
||||
createQueueScriptId = '';
|
||||
createQueueName = '';
|
||||
createQueueVisibilityTimeout = 30;
|
||||
createQueueMaxAttempts = 3;
|
||||
await loadTriggers(app.id);
|
||||
} catch (err) {
|
||||
createQueueError =
|
||||
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
creatingQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The inbound-email webhook URL for a given email trigger (shown so
|
||||
// the operator can configure their provider).
|
||||
function emailInboundUrl(triggerId: string): string {
|
||||
@@ -797,6 +829,13 @@
|
||||
>
|
||||
Files
|
||||
</a>
|
||||
<a
|
||||
class="tab-link"
|
||||
href="{base}/apps/{slug}/queues"
|
||||
title="Queues — depth of per-app durable queues (v1.1.9, read-only)"
|
||||
>
|
||||
Queues
|
||||
</a>
|
||||
<a
|
||||
class="tab-link"
|
||||
href="{base}/apps/{slug}/dead-letters"
|
||||
@@ -1192,6 +1231,70 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Queue:receive trigger (v1.1.9)</h3>
|
||||
<p class="muted">
|
||||
Register a script as the consumer for a per-app durable queue. Exactly
|
||||
one consumer per queue is allowed — the registration will be rejected
|
||||
if another consumer already exists. Use the
|
||||
<a href="{base}/apps/{slug}/queues">Queues</a> tab for a read-only view
|
||||
of message depths.
|
||||
</p>
|
||||
<form class="create-form" onsubmit={submitCreateQueue}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Target script</span>
|
||||
<select bind:value={createQueueScriptId} required>
|
||||
<option value="" disabled>Select an endpoint script…</option>
|
||||
{#each endpointScripts as s (s.id)}
|
||||
<option value={s.id}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="grow">
|
||||
<span>Queue name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={createQueueName}
|
||||
placeholder="payments.process"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Visibility timeout (5–3600s)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={createQueueVisibilityTimeout}
|
||||
min="5"
|
||||
max="3600"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Max attempts (1–20)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={createQueueMaxAttempts}
|
||||
min="1"
|
||||
max="20"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if createQueueError}
|
||||
<div class="error">{createQueueError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingQueue || !createQueueScriptId || !createQueueName}
|
||||
>
|
||||
{creatingQueue ? 'Creating…' : 'Create queue:receive trigger'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if triggers.length === 0}
|
||||
<p class="muted">No triggers in this app yet.</p>
|
||||
{:else}
|
||||
@@ -1216,6 +1319,12 @@
|
||||
{t.details.has_inbound_secret ? 'signed (HMAC)' : 'unsigned'}
|
||||
</span>
|
||||
<code class="webhook-url">{emailInboundUrl(t.id)}</code>
|
||||
{:else if t.details.kind === 'queue'}
|
||||
<code>{t.details.queue_name}</code>
|
||||
<span class="muted">— vt {t.details.visibility_timeout_secs}s</span>
|
||||
<span class="muted small">
|
||||
last fired: {t.details.last_fired_at ?? 'never'}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="muted small">→ {t.script_id}</span>
|
||||
</div>
|
||||
|
||||
128
dashboard/src/routes/apps/[slug]/queues/+page.svelte
Normal file
128
dashboard/src/routes/apps/[slug]/queues/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type QueueSummary } from '$lib/api';
|
||||
|
||||
let slug = $derived(page.params.slug ?? '');
|
||||
let app = $state<App | null>(null);
|
||||
let queues = $state<QueueSummary[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueues() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
queues = await api.queues.list(slug);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void loadApp();
|
||||
void loadQueues();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Queues — {app?.name ?? slug} — PiCloud</title>
|
||||
</svelte:head>
|
||||
|
||||
<header>
|
||||
<a class="back" href="{base}/apps/{slug}">← Back to {app?.name ?? slug}</a>
|
||||
<h1>Queues</h1>
|
||||
<p class="subtle">
|
||||
Per-app durable queues. Producers call <code>queue::enqueue(name, msg)</code>;
|
||||
consumers register a <code>queue:receive</code> trigger. v1.1.9.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && queues.length === 0}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if queues.length === 0}
|
||||
<p class="muted">
|
||||
No queues in this app yet. A queue is created implicitly the first time a
|
||||
message is enqueued. Register a <code>queue:receive</code> trigger from the
|
||||
Triggers tab to consume messages.
|
||||
</p>
|
||||
{:else}
|
||||
<table class="queues">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Queue</th>
|
||||
<th class="num">Total</th>
|
||||
<th class="num">Pending</th>
|
||||
<th class="num">Claimed</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each queues as q (q.queue_name)}
|
||||
<tr>
|
||||
<td><code>{q.queue_name}</code></td>
|
||||
<td class="num">{q.total}</td>
|
||||
<td class="num">{q.pending}</td>
|
||||
<td class="num">{q.claimed}</td>
|
||||
<td>
|
||||
<a href="{base}/apps/{slug}/queues/{encodeURIComponent(q.queue_name)}">
|
||||
drilldown →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.back {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-link);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.subtle {
|
||||
color: var(--color-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.error {
|
||||
color: var(--color-error, #c00);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
table.queues {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.queues th,
|
||||
table.queues td {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #eee);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
123
dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte
Normal file
123
dashboard/src/routes/apps/[slug]/queues/[name]/+page.svelte
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type QueueDetail } from '$lib/api';
|
||||
|
||||
let slug = $derived(page.params.slug ?? '');
|
||||
let name = $derived(page.params.name ?? '');
|
||||
let app = $state<App | null>(null);
|
||||
let detail = $state<QueueDetail | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
detail = await api.queues.get(slug, name);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void name;
|
||||
void load();
|
||||
});
|
||||
|
||||
function fmtTime(iso: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString() : 'never';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{name} — Queues — {app?.name ?? slug} — PiCloud</title>
|
||||
</svelte:head>
|
||||
|
||||
<header>
|
||||
<a class="back" href="{base}/apps/{slug}/queues">← Back to Queues</a>
|
||||
<h1>Queue: <code>{name}</code></h1>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if loading && !detail}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if detail}
|
||||
<section>
|
||||
<h2>Depth</h2>
|
||||
<dl class="kv">
|
||||
<dt>Total</dt>
|
||||
<dd>{detail.total}</dd>
|
||||
<dt>Pending</dt>
|
||||
<dd>{detail.pending}</dd>
|
||||
<dt>Claimed</dt>
|
||||
<dd>{detail.claimed}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Consumer</h2>
|
||||
{#if detail.consumer}
|
||||
<dl class="kv">
|
||||
<dt>Script</dt>
|
||||
<dd>
|
||||
<a href="{base}/apps/{slug}/scripts/{detail.consumer.script_id}">
|
||||
{detail.consumer.script_name}
|
||||
</a>
|
||||
</dd>
|
||||
<dt>Visibility timeout</dt>
|
||||
<dd>{detail.consumer.visibility_timeout_secs}s</dd>
|
||||
<dt>Last fired</dt>
|
||||
<dd>{fmtTime(detail.consumer.last_fired_at)}</dd>
|
||||
<dt>Trigger ID</dt>
|
||||
<dd><code>{detail.consumer.trigger_id}</code></dd>
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="muted">
|
||||
No <code>queue:receive</code> trigger registered for this queue. Messages
|
||||
enqueued here will accumulate without a consumer. Register one in the
|
||||
Triggers tab of the parent app page.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.back {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-link);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
section {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
color: var(--color-error, #c00);
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user