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;
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user