This commit is contained in:
Ruben Fiszel
2026-05-28 16:26:52 +00:00
parent e9ae7a3d5a
commit d8da723be4
4 changed files with 163 additions and 57 deletions
@@ -111,15 +111,15 @@
// `scriptPath` is the related script — the drawer locks its
// script-picker to it so the trigger can't be reassigned off the
// pipeline from this entry point.
onEditTrigger?: (
kind: NativeTriggerKind,
triggerPath: string,
scriptPath: string
) => void
onEditTrigger?: (kind: NativeTriggerKind, triggerPath: string, scriptPath: string) => void
// Click handler for the kebab → Delete entry on an attached trigger.
// The page is expected to confirm + call the matching delete API
// and refetch the graph.
onDeleteTrigger?: (kind: NativeTriggerKind, triggerPath: string) => void
// Click handler for a webhook node — opens the webhook drawer (URLs +
// webhook-specific token creation) for the given script. Webhooks have
// no trigger row, so they don't use the create/edit/delete flows.
onOpenWebhook?: (scriptPath: string) => void
}
let {
graph,
@@ -136,7 +136,8 @@
runStates,
onCreateMissingTrigger,
onEditTrigger,
onDeleteTrigger
onDeleteTrigger,
onOpenWebhook
}: Props = $props()
const ADD_NODE_ID = '__add__'
@@ -149,12 +150,7 @@
// the DAG. They force sugiyama to put + at layer 0 (top) and center
// it horizontally over the roots — same mechanism the flow editor
// uses for its Trigger node. Filtered out of rendered edges.
kind:
| 'lineage-write'
| 'lineage-read'
| 'trigger-asset'
| 'trigger-native'
| 'add-anchor'
kind: 'lineage-write' | 'lineage-read' | 'trigger-asset' | 'trigger-native' | 'add-anchor'
unsaved?: boolean
// Edge from a missing-trigger placeholder — styled red dashed to
// signal "this script declared `// on kafka` but no trigger row
@@ -434,7 +430,8 @@
: false,
onCreateMissingTrigger,
onEditTrigger,
onDeleteTrigger
onDeleteTrigger,
onOpenWebhook
}
})
}
@@ -135,9 +135,15 @@
runnable_unsaved?: boolean
// Page-supplied dispatcher that opens the matching native
// trigger drawer with `script_path` pre-filled. When absent
// (e.g. webhook, schedule, or a kind without an editor) the
// placeholder is non-clickable.
// (e.g. schedule, or a kind without an editor) the placeholder is
// non-clickable. Webhook is handled separately via `onOpenWebhook`.
onCreateMissingTrigger?: (kind: NativeTriggerKind, scriptPath: string) => void
// Page-supplied dispatcher that opens the webhook drawer (URLs +
// webhook-specific token creation) for `scriptPath`. Webhooks have
// no trigger row, so they never use the create/edit/delete flows —
// the node is always clickable to view its endpoint instead of
// rendering a dead "missing" placeholder.
onOpenWebhook?: (scriptPath: string) => void
// Page-supplied dispatcher to open the matching native trigger
// drawer in edit mode for an attached (non-missing) trigger.
// `triggerPath` is the trigger row's path (e.g. the mqtt_trigger
@@ -145,11 +151,7 @@
// drawer locks its script-picker to this so the user can't
// reassign the trigger off the pipeline. Absent for kinds
// without an editor.
onEditTrigger?: (
kind: NativeTriggerKind,
triggerPath: string,
scriptPath: string
) => void
onEditTrigger?: (kind: NativeTriggerKind, triggerPath: string, scriptPath: string) => void
// Page-supplied dispatcher to delete an attached (non-missing)
// trigger. Confirmation is the caller's responsibility — the
// node just exposes the entry point on the kebab menu.
@@ -162,20 +164,25 @@
let menuOpen = $state(false)
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
let Icon = $derived(data.missing ? AlertTriangle : style.icon)
// Webhooks are never genuinely "missing" — every deployed runnable has an
// implicit endpoint. Suppress the broken/red treatment for them so they
// render as a normal clickable source node.
let isWebhook = $derived(data.kind === 'webhook')
let displayMissing = $derived(data.missing && !isWebhook)
let Icon = $derived(displayMissing ? AlertTriangle : style.icon)
let missingTitle = $derived(
data.missing
displayMissing
? `Missing ${style.label} trigger: ${data.runnable_path ?? ''} declares \`// on ${style.label}\` but no ${style.label} trigger targets it. Click to create one, or remove the annotation.`
: undefined
)
// Webhook has no dedicated drawer (it's an implicit endpoint), so the
// placeholder is not clickable for that kind. Schedule + the other
// native kinds all have dedicated editors.
// Webhook gets a drawer (URLs + webhook-specific token creation) rather
// than a create/edit flow, so it's clickable whenever the page supplies
// the handler.
let canOpenWebhook = $derived(isWebhook && !!data.runnable_path && !!data.onOpenWebhook)
// Schedule + the other native kinds all have dedicated editors. Webhook is
// excluded — it routes through `canOpenWebhook` instead.
let canCreate = $derived(
data.missing &&
data.kind !== 'webhook' &&
!!data.runnable_path &&
!!data.onCreateMissingTrigger
data.missing && data.kind !== 'webhook' && !!data.runnable_path && !!data.onCreateMissingTrigger
)
// Attached native trigger → clickable to open its drawer in edit mode.
let canEdit = $derived(
@@ -190,10 +197,7 @@
// non-missing ref + a backing editor (i.e. excludes webhook). Schedule
// has its own delete endpoint, same shape as the other natives.
let canDelete = $derived(
!data.missing &&
data.kind !== 'webhook' &&
!!data.ref &&
!!data.onDeleteTrigger
!data.missing && data.kind !== 'webhook' && !!data.ref && !!data.onDeleteTrigger
)
let menuItems: Item[] = $derived(
@@ -221,14 +225,15 @@
if (!canEdit || !data.ref || !data.runnable_path || !data.onEditTrigger) return
data.onEditTrigger(data.kind as NativeTriggerKind, data.ref, data.runnable_path)
}
function handleWebhookClick() {
if (!canOpenWebhook || !data.runnable_path || !data.onOpenWebhook) return
data.onOpenWebhook(data.runnable_path)
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
<div class="relative" onmouseenter={() => (hover = true)} onmouseleave={() => (hover = false)}>
{#if canCreate}
<!-- Same affordance as the asset's downstream + button: render the
whole node as a button so the cursor + hover + click is obvious
@@ -280,14 +285,39 @@
</span>
</div>
</button>
{:else if canOpenWebhook}
<!-- Webhook endpoint: implicit (no trigger row), so the node opens a
drawer with the URLs + webhook-specific token creation instead of
an editor. Styled like an attached trigger, never the red
"missing" state. -->
<button
type="button"
onclick={handleWebhookClick}
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1 w-full text-left',
style.bg,
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={`Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}
</span>
<span class="text-2xs font-mono truncate text-emphasis"> URLs & token </span>
</div>
</button>
{:else}
<div
class={twMerge(
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1',
data.missing
displayMissing
? 'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500'
: style.bg,
data.missing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
displayMissing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={missingTitle ??
@@ -295,24 +325,24 @@
>
<Icon
size={14}
class={`shrink-0 ml-2 mr-2 ${data.missing ? 'text-red-600 dark:text-red-400' : style.iconText}`}
class={`shrink-0 ml-2 mr-2 ${displayMissing ? 'text-red-600 dark:text-red-400' : style.iconText}`}
/>
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span
class={twMerge(
'text-3xs uppercase tracking-wide truncate',
data.missing ? 'text-red-700 dark:text-red-400' : 'text-tertiary'
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-tertiary'
)}
>
{style.label}{data.missing ? ' · missing' : data.unsaved ? ' · unsaved' : ''}
{style.label}{displayMissing ? ' · missing' : data.unsaved ? ' · unsaved' : ''}
</span>
<span
class={twMerge(
'text-2xs font-mono truncate',
data.missing ? 'text-red-700 dark:text-red-400' : 'text-emphasis'
displayMissing ? 'text-red-700 dark:text-red-400' : 'text-emphasis'
)}
>
{data.missing ? 'no trigger row' : data.ref}
{displayMissing ? 'no trigger row' : data.ref}
</span>
</div>
</div>
@@ -0,0 +1,66 @@
<script lang="ts">
import { setContext, tick } from 'svelte'
import { writable } from 'svelte/store'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import WebhooksPanel from './WebhooksPanel.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import type { TriggersCount } from '$lib/gen'
// WebhooksPanel → TriggerTokens reads the 'TriggerContext' to publish the
// webhook token count. The pipeline canvas isn't a trigger editor, so no
// ancestor provides it — supply a standalone context here. Nothing
// consumes the count in this view, but the store must exist.
setContext<TriggerContext>('TriggerContext', {
triggersCount: writable<TriggersCount | undefined>(undefined),
simplifiedPoll: writable(false),
showCaptureHint: writable(undefined),
triggersState: new Triggers()
})
let open = $state(false)
let drawer = $state<Drawer | undefined>(undefined)
let scriptPath = $state('')
let isFlow = $state(false)
let runnableVersion = $state<string | undefined>(undefined)
let args = $state<Record<string, any>>({})
// Webhooks have no trigger row to create — every deployed runnable gets an
// implicit endpoint. The drawer just surfaces the URLs + the
// webhook-specific token creation flow, so there's no edit/new split.
export async function openDrawer(
path: string,
is_flow = false,
version?: string,
runnableArgs: Record<string, any> = {}
) {
scriptPath = path
isFlow = is_flow
runnableVersion = version
args = runnableArgs
open = true
await tick()
drawer?.openDrawer()
}
let scopes = $derived(
isFlow ? [`jobs:run:flows:${scriptPath}`] : [`jobs:run:scripts:${scriptPath}`]
)
</script>
{#if open}
<Drawer size="700px" bind:this={drawer} on:close={() => (open = false)}>
<DrawerContent title={`Webhooks for ${scriptPath}`} on:close={() => drawer?.closeDrawer()}>
<WebhooksPanel
{isFlow}
path={scriptPath}
{runnableVersion}
token=""
{args}
{scopes}
newItem={false}
/>
</DrawerContent>
</Drawer>
{/if}
@@ -77,6 +77,7 @@
import GcpTriggerEditor from '$lib/components/triggers/gcp/GcpTriggerEditor.svelte'
import EmailTriggerEditor from '$lib/components/triggers/email/EmailTriggerEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import WebhookEditor from '$lib/components/triggers/webhook/WebhookEditor.svelte'
// Variables and resources are declarative config, not pipeline assets —
// they're hub-shaped (referenced by most runnables) and would swamp the
@@ -798,7 +799,8 @@
// Non-fatal: surface a warning so the user
// knows to clean up manually. The fresh deploy
// at the new path still landed.
const msg = (archiveErr as any)?.body ?? (archiveErr as any)?.message ?? String(archiveErr)
const msg =
(archiveErr as any)?.body ?? (archiveErr as any)?.message ?? String(archiveErr)
sendUserToast(
`Renamed to "${path}" but couldn't archive the old "${prev}": ${msg}`,
true
@@ -1051,6 +1053,22 @@
let gcpEditor: GcpTriggerEditor | undefined = $state()
let emailEditor: EmailTriggerEditor | undefined = $state()
let scheduleEditor: ScheduleEditor | undefined = $state()
let webhookEditor: WebhookEditor | undefined = $state()
// Webhooks have no trigger row to create — clicking the node opens a
// drawer with the endpoint URLs + the webhook-specific token creation
// flow. Drafts have no deployed endpoint yet, so nudge the user to save
// first (mirrors openMissingTriggerDrawer).
function openWebhookDrawer(scriptPath: string) {
if (drafts.has(scriptPath)) {
sendUserToast(
`Save the script "${scriptPath}" first — webhooks only trigger the deployed version.`,
true
)
return
}
webhookEditor?.openDrawer(scriptPath, false)
}
function openMissingTriggerDrawer(kind: NativeTriggerKind, scriptPath: string) {
// A native trigger row stores `script_path` as a hard reference —
@@ -1095,9 +1113,7 @@
// Delete-trigger confirmation state. Kebab → Delete opens the standard
// ConfirmationModal; the actual delete is dispatched from onConfirmed
// so the dialog stays consistent with the rest of the app.
let triggerDeleteTarget = $state<
{ kind: NativeTriggerKind; path: string } | undefined
>(undefined)
let triggerDeleteTarget = $state<{ kind: NativeTriggerKind; path: string } | undefined>(undefined)
let triggerDeleteLoading = $state(false)
let triggerDeleteOpen = $derived(triggerDeleteTarget != undefined)
@@ -1152,11 +1168,7 @@
}
}
function openEditTriggerDrawer(
kind: NativeTriggerKind,
triggerPath: string,
scriptPath: string
) {
function openEditTriggerDrawer(kind: NativeTriggerKind, triggerPath: string, scriptPath: string) {
switch (kind) {
case 'schedule':
return scheduleEditor?.openEdit(triggerPath, false, scriptPath)
@@ -1461,6 +1473,7 @@
onCreateMissingTrigger={openMissingTriggerDrawer}
onEditTrigger={openEditTriggerDrawer}
onDeleteTrigger={deleteAttachedTrigger}
onOpenWebhook={openWebhookDrawer}
onselect={(s) => {
// Clicking a draft runnable node re-opens it in the pane;
// clicking anything else selects it normally and detaches
@@ -1754,9 +1767,8 @@
{#if triggerDeleteTarget}
<p>
Delete <code class="font-mono">{triggerDeleteTarget.path}</code>? The
<code class="font-mono">// on {triggerDeleteTarget.kind}</code> annotation on the script
stays — the trigger will read as missing on the canvas until you recreate it or remove
the annotation.
<code class="font-mono">// on {triggerDeleteTarget.kind}</code> annotation on the script stays
— the trigger will read as missing on the canvas until you recreate it or remove the annotation.
</p>
{/if}
</ConfirmationModal>
@@ -1773,6 +1785,7 @@
<GcpTriggerEditor bind:this={gcpEditor} onUpdate={() => graphRes.refetch()} />
<EmailTriggerEditor bind:this={emailEditor} onUpdate={() => graphRes.refetch()} />
<ScheduleEditor bind:this={scheduleEditor} onUpdate={() => graphRes.refetch()} />
<WebhookEditor bind:this={webhookEditor} />
{#if leaveModalOpen}
<!-- Three-button leave guard. Built inline rather than reusing