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:
@@ -349,6 +349,44 @@ export interface CreatePubsubTriggerInput {
|
|||||||
retry_base_ms?: number;
|
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.
|
// v1.1.9 — queue:receive trigger.
|
||||||
export interface CreateQueueTriggerInput {
|
export interface CreateQueueTriggerInput {
|
||||||
script_id: string;
|
script_id: string;
|
||||||
@@ -793,6 +831,28 @@ export const api = {
|
|||||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/queue`,
|
||||||
{ method: 'POST', body: JSON.stringify(input) }
|
{ 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) =>
|
remove: (idOrSlug: string, triggerId: string) =>
|
||||||
adminRequest<null>(
|
adminRequest<null>(
|
||||||
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/triggers/${triggerId}`,
|
||||||
|
|||||||
@@ -138,6 +138,40 @@
|
|||||||
let createQueueMaxAttempts = $state(3);
|
let createQueueMaxAttempts = $state(3);
|
||||||
let creatingQueue = $state(false);
|
let creatingQueue = $state(false);
|
||||||
let createQueueError = $state<string | null>(null);
|
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 triggerToRemove = $state<Trigger | null>(null);
|
||||||
let removingTrigger = $state(false);
|
let removingTrigger = $state(false);
|
||||||
// Endpoint scripts only — modules can't be trigger targets.
|
// 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) {
|
async function submitCreateQueue(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
@@ -1295,6 +1432,164 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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}
|
{#if triggers.length === 0}
|
||||||
<p class="muted">No triggers in this app yet.</p>
|
<p class="muted">No triggers in this app yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user