fix(dashboard): F-U-001 add KV / Docs / Files / Dead-letter trigger create forms

Backend triggers_api.rs has long exposed POST /apps/{id}/triggers/{kv,
docs,files,dead_letter}; the dashboard Triggers tab shipped create
forms only for cron / pubsub / email / queue. Listing showed kv/docs/
files/dead_letter rows but operators couldn't create them from the UI.

This commit adds:
- 4 new CreateXxxTriggerInput types in api.ts.
- 4 new api.triggers.createXxx methods.
- 4 new submitCreateXxx functions in apps/[slug]/+page.svelte.
- 4 new <form class="create-form"> sections under the queue form.

KV / Docs / Files share the (script_id, collection_glob, ops[]) shape
with checkbox UI for the per-kind ops (insert/update/delete vs
create/update/delete). Dead-letter takes (script_id, source_filter,
trigger_id_filter, script_id_filter) — all but script_id optional, with
"leaving blank routes every dead-letter to this script" inline help.

`npm run check` clean (only pre-existing tests/e2e/* Playwright errors
remain, documented out-of-scope in the AUDIT.md methodology notes).

AUDIT.md anchor: F-U-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:33:33 +02:00
parent efb644efe9
commit 99558e1987
2 changed files with 355 additions and 0 deletions

View File

@@ -349,6 +349,44 @@ export interface CreatePubsubTriggerInput {
retry_base_ms?: number;
}
// F-U-001: dashboard create surfaces for the four data-plane triggers.
export interface CreateKvTriggerInput {
script_id: string;
collection_glob: string;
ops?: Array<'insert' | 'update' | 'delete'>;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface CreateDocsTriggerInput {
script_id: string;
collection_glob: string;
ops?: Array<'create' | 'update' | 'delete'>;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface CreateFilesTriggerInput {
script_id: string;
collection_glob: string;
ops?: Array<'create' | 'update' | 'delete'>;
dispatch_mode?: TriggerDispatchMode;
retry_max_attempts?: number;
retry_backoff?: 'exponential' | 'linear' | 'constant';
retry_base_ms?: number;
}
export interface CreateDeadLetterTriggerInput {
script_id: string;
source_filter?: string | null;
trigger_id_filter?: string | null;
script_id_filter?: string | null;
}
// v1.1.9 — queue:receive trigger.
export interface CreateQueueTriggerInput {
script_id: string;
@@ -793,6 +831,28 @@ export const api = {
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
{ method: 'POST', body: JSON.stringify(input) }
),
// F-U-001: matching frontend methods for the four data-plane
// trigger kinds the backend has long supported.
createKv: (idOrSlug: string, input: CreateKvTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/kv`,
{ method: 'POST', body: JSON.stringify(input) }
),
createDocs: (idOrSlug: string, input: CreateDocsTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/docs`,
{ method: 'POST', body: JSON.stringify(input) }
),
createFiles: (idOrSlug: string, input: CreateFilesTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/files`,
{ method: 'POST', body: JSON.stringify(input) }
),
createDeadLetter: (idOrSlug: string, input: CreateDeadLetterTriggerInput) =>
adminRequest<Trigger>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/dead_letter`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, triggerId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,

View File

@@ -138,6 +138,40 @@
let createQueueMaxAttempts = $state(3);
let creatingQueue = $state(false);
let createQueueError = $state<string | null>(null);
// F-U-001: KV / Docs / Files / Dead-letter trigger forms.
let createKvScriptId = $state('');
let createKvCollectionGlob = $state('*');
let createKvOps = $state<{ insert: boolean; update: boolean; delete: boolean }>({
insert: true,
update: true,
delete: true
});
let creatingKv = $state(false);
let createKvError = $state<string | null>(null);
let createDocsScriptId = $state('');
let createDocsCollectionGlob = $state('*');
let createDocsOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
create: true,
update: true,
delete: true
});
let creatingDocs = $state(false);
let createDocsError = $state<string | null>(null);
let createFilesScriptId = $state('');
let createFilesCollectionGlob = $state('*');
let createFilesOps = $state<{ create: boolean; update: boolean; delete: boolean }>({
create: true,
update: true,
delete: true
});
let creatingFiles = $state(false);
let createFilesError = $state<string | null>(null);
let createDeadLetterScriptId = $state('');
let createDeadLetterSourceFilter = $state('');
let createDeadLetterTriggerIdFilter = $state('');
let createDeadLetterScriptIdFilter = $state('');
let creatingDeadLetter = $state(false);
let createDeadLetterError = $state<string | null>(null);
let triggerToRemove = $state<Trigger | null>(null);
let removingTrigger = $state(false);
// Endpoint scripts only — modules can't be trigger targets.
@@ -215,6 +249,109 @@
}
}
// F-U-001: handlers for kv / docs / files / dead-letter trigger creation.
function selectedOps<T extends Record<string, boolean>>(ops: T): Array<keyof T> {
return (Object.keys(ops) as Array<keyof T>).filter((k) => ops[k]);
}
async function submitCreateKv(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingKv = true;
createKvError = null;
try {
await api.triggers.createKv(app.id, {
script_id: createKvScriptId,
collection_glob: createKvCollectionGlob.trim(),
ops: selectedOps(createKvOps) as Array<'insert' | 'update' | 'delete'>
});
createKvScriptId = '';
createKvCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createKvError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingKv = false;
}
}
async function submitCreateDocs(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingDocs = true;
createDocsError = null;
try {
await api.triggers.createDocs(app.id, {
script_id: createDocsScriptId,
collection_glob: createDocsCollectionGlob.trim(),
ops: selectedOps(createDocsOps) as Array<'create' | 'update' | 'delete'>
});
createDocsScriptId = '';
createDocsCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createDocsError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingDocs = false;
}
}
async function submitCreateFiles(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingFiles = true;
createFilesError = null;
try {
await api.triggers.createFiles(app.id, {
script_id: createFilesScriptId,
collection_glob: createFilesCollectionGlob.trim(),
ops: selectedOps(createFilesOps) as Array<'create' | 'update' | 'delete'>
});
createFilesScriptId = '';
createFilesCollectionGlob = '*';
await loadTriggers(app.id);
} catch (err) {
createFilesError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingFiles = false;
}
}
async function submitCreateDeadLetter(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
creatingDeadLetter = true;
createDeadLetterError = null;
try {
await api.triggers.createDeadLetter(app.id, {
script_id: createDeadLetterScriptId,
source_filter:
createDeadLetterSourceFilter.trim() === '' ? null : createDeadLetterSourceFilter.trim(),
trigger_id_filter:
createDeadLetterTriggerIdFilter.trim() === ''
? null
: createDeadLetterTriggerIdFilter.trim(),
script_id_filter:
createDeadLetterScriptIdFilter.trim() === ''
? null
: createDeadLetterScriptIdFilter.trim()
});
createDeadLetterScriptId = '';
createDeadLetterSourceFilter = '';
createDeadLetterTriggerIdFilter = '';
createDeadLetterScriptIdFilter = '';
await loadTriggers(app.id);
} catch (err) {
createDeadLetterError =
err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
creatingDeadLetter = false;
}
}
async function submitCreateQueue(e: SubmitEvent) {
e.preventDefault();
if (!app) return;
@@ -1295,6 +1432,164 @@
</div>
</form>
<!-- F-U-001: KV / Docs / Files / Dead-letter trigger create forms. -->
<h3>Create kv:event trigger</h3>
<form class="create-form" onsubmit={submitCreateKv}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createKvScriptId} 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>Collection glob</span>
<input
type="text"
bind:value={createKvCollectionGlob}
placeholder="users.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createKvOps.insert} /> insert</label>
<label><input type="checkbox" bind:checked={createKvOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createKvOps.delete} /> delete</label>
</div>
{#if createKvError}<div class="error">{createKvError}</div>{/if}
<div class="actions">
<button type="submit" disabled={creatingKv || !createKvScriptId || !createKvCollectionGlob}>
{creatingKv ? 'Creating…' : 'Create kv:event trigger'}
</button>
</div>
</form>
<h3>Create docs:event trigger</h3>
<form class="create-form" onsubmit={submitCreateDocs}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createDocsScriptId} 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>Collection glob</span>
<input
type="text"
bind:value={createDocsCollectionGlob}
placeholder="users.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createDocsOps.create} /> create</label>
<label><input type="checkbox" bind:checked={createDocsOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createDocsOps.delete} /> delete</label>
</div>
{#if createDocsError}<div class="error">{createDocsError}</div>{/if}
<div class="actions">
<button
type="submit"
disabled={creatingDocs || !createDocsScriptId || !createDocsCollectionGlob}
>
{creatingDocs ? 'Creating…' : 'Create docs:event trigger'}
</button>
</div>
</form>
<h3>Create files:event trigger</h3>
<form class="create-form" onsubmit={submitCreateFiles}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createFilesScriptId} 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>Collection glob</span>
<input
type="text"
bind:value={createFilesCollectionGlob}
placeholder="avatars.* or *"
required
/>
</label>
</div>
<div class="row">
<span>Ops:</span>
<label><input type="checkbox" bind:checked={createFilesOps.create} /> create</label>
<label><input type="checkbox" bind:checked={createFilesOps.update} /> update</label>
<label><input type="checkbox" bind:checked={createFilesOps.delete} /> delete</label>
</div>
{#if createFilesError}<div class="error">{createFilesError}</div>{/if}
<div class="actions">
<button
type="submit"
disabled={creatingFiles || !createFilesScriptId || !createFilesCollectionGlob}
>
{creatingFiles ? 'Creating…' : 'Create files:event trigger'}
</button>
</div>
</form>
<h3>Create dead_letter trigger</h3>
<p class="hint">
Fires when any other trigger or route dead-letters. All filter fields are optional;
leaving them blank routes every dead-letter to this script.
</p>
<form class="create-form" onsubmit={submitCreateDeadLetter}>
<div class="row">
<label>
<span>Target script</span>
<select bind:value={createDeadLetterScriptId} 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>Source filter (optional)</span>
<input
type="text"
bind:value={createDeadLetterSourceFilter}
placeholder="kv, docs, queue, pubsub, …"
/>
</label>
</div>
<div class="row">
<label class="grow">
<span>Trigger ID filter (optional)</span>
<input type="text" bind:value={createDeadLetterTriggerIdFilter} />
</label>
<label class="grow">
<span>Script ID filter (optional)</span>
<input type="text" bind:value={createDeadLetterScriptIdFilter} />
</label>
</div>
{#if createDeadLetterError}<div class="error">{createDeadLetterError}</div>{/if}
<div class="actions">
<button type="submit" disabled={creatingDeadLetter || !createDeadLetterScriptId}>
{creatingDeadLetter ? 'Creating…' : 'Create dead_letter trigger'}
</button>
</div>
</form>
{#if triggers.length === 0}
<p class="muted">No triggers in this app yet.</p>
{:else}