From ea74f584b1eccff1532cab43c723dad57f079186 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 21 May 2025 23:45:19 +0200 Subject: [PATCH] refactor(frontend): capture refactor UI (#5591) * migrate postgresEditorInner to svelte 5 syntaxe * Add postgress * add postgres capture section * migrate kafka trigger config to svelte 5 * use snippets in kafka config * feat: Update KafkaTriggerEditorInner with editMode, hideTarget and other props from WebsocketTriggerEditorInner * feat: Create KafkaTriggerPanelV2 following WebsocketTriggersPanelV2 model * feat: Integrate KafkaTriggerPanelV2 into TriggersEditorV2 * feat: add kafka capture panel * use svelte 5 syntaxe for nats config * use snippet in nats config * feat: add editMode for nats trigger config * fix import * create NatsTriggerPanelV2.svelte for NATS trigger * integrate NatsTriggerPanelV2 into TriggersEditorV2.svelte * Create NatsCapture.svelte and update CaptureWrapperV2.svelte to use it * migrate MqttTriggerEditorInner to svelte 5 * add mqtt trigger to new trigger panel * migrate SqsTriggerEditorInner to svelte 5 syntaxe * feat: add sqs triggers panel * Add badge to MQTT config section * remove unnecessary binding * add gcp to the trigger list * add prop size to GCP icon * add gcp trigger utils * migrate GcpTriggerEditorInner to svelte5 syntaxe * add gcp trigger panel * Revert "add gcp trigger utils" This reverts commit 58f0df998536cb6857a2d473593a6ced2478b37b. * fix type error * add gcp to trigger bar * add capture gcp panel * add schedule poll panel * feat: add trigger table horizontal bar * add http trigger label * make triggerbar vertical * nit * add trigger delete button for drafts * enable edit from table for all triggers * fix automatic edit mode for draft trigger * fix proxy dispatching issue * fix primary star positionning * fix apply args to preprocessor * automatically load bigpayloads * clean capture wrapper code * nit * fix capture config not passed * use Url component * remove unused prop captureTable * use new schedule panel for scheduled poll * update triggersWrapperV2 * improve webhook capture panel * remove debug log * save draft with flow draft * add path in draft label when available * remove flowcard title * use trigger label for trigger badge dropdown * break from table to sidebar at 1000px * remove triggers badge allways show sidebar behavior * fix multiple primary addition * improve primary schedule deploy logic * remove debug logs * gather imports * add path for draft primary schedule * handle redirection * Add confirmation modal to deploy triggers * remove debug log * Save draft route trigger on deploy * Allow draft for deployed triggers * add component trigger editor toolbar * improve update trigger utility function * Add reset button for deployed draft triggers * allow draft trigger for kafka triggers * allow draft trigger for websocket triggers * allow draft trigger for postgres triggers * Add toggle to trigger toolbar * allow draft for nats triggers * allow draft for mqtt triggers * allow draft sqs triggers * allow draft for gcp triggers * revert changes on the schedule editor * update all triggers to use the same toolbar * remove debug logs * remove PrimarySchedule Panel and merge primary schedule logic with all triggers * load schema from draft when editing a draft schedule * prevent overflow in trigger table * fix path saving for draft schedule * fix trigger badge dropdown display * get draft status from trigger * remove unused event * nit * Add deploy badge to trigger * replace checkbox with toggle for the modal * fix selection of triggers with draft * fix nit * fix nit * add missing config for loading schedule * save schedule from config * save http route from cfg * save websocket trigger from cfg * save postgres trigger from cfg * save kafka trigger from cfg * save sqs trigger from cfg * save nats trigger from cfg * save mqtt triggers from cfg * save gcp trigger from config * nit * fix path update when editing a trigger * remove unused saveCb * fix trigger node navigation * remove debug log * add min size for trigger editor capture panel * Integrate new triggers Editor to script builder * remove previous triggersEdior page * Remove former triggers Editor * Migrate detail page to new triggers editor UI * Remove unused dev TriggersBadge * migrate triggers to V2 * Migrate capture components to V2 * improve triggers badge UI * fix trigger panel with scheduled poll * fix auto select all draft on deploy * simplify permission check * Add custom label to every trigger editor * fix draft not deleting when saving triggers and add option to deploy now * remove unused utility function * fix triggers loading from draft * make advanced route section collapsible * handle subflow triggers display * center triggers badge * fix panel redirection when adding a new triggers from node * Remove unnecessary alerts * Fix proxy issue in schedule editor inner * Remove unused schedule pages * nit * nit cli panel * Migrate selectedTrigger store to V2 * handle extra header button for trigger editor panel * prevent reload on toggle schedule enable * remove unused utility function * remove primaryScheduleStore * handle triggerCount with drafts * fix editing in Schedule Poll Mode * Remove triggers default value context store * fix triggers label max width * Remove dbg logs * improve no trigger selected message * nit * Do not show enable toggle for http triggers * Adapt ui to dark mode * nit * remove captureOn context value * improve dark mode * fix nit * fix merge issue * nit * nit * fix schedule permission initialisation * nit * Disable deploy now if needed * Improve utilitu function for handling with triggers * improve trigger badge look * fix kafka * nit * fix schedule display * fix toggling edit mode * fix edit/cancel edit * nit * prevent capture config update in editor * migrate webhook panel to svelte 5 * fix postgres trigger creation * fix bind issue in nats triggers * fix modal triggers message flow/script * fix apply args for script * fix gcp triggers update when creating * fix gcp capture * nits * add loading state for deploying all triggers * remove default mqtt * update base_endpoint for gcp * nit * fix * fix * remove base endpoint * add catch in case of error while creating a trigger * Add animation is capture panels * show preprocessor extra when there is a preprocessor * Add loading state for captures * add missing fields for capture * migrate URL to svelte 5 * show icon by default in confirmation modal * do not show draft triggers in detail badge list * migrate to new captures * revert unwanted change * migrate to preprocesor v2 * nit * fix email capture * nits and fix * nits * update repo ref * Feat: auto save draft instad of save button * use triggerIndex as selection * Add modified tag to triggers * Update trigger modal * fix trigger node select * fix schedule page * migrate TriggersEditor to svelte 5 * remove unused props * fix schedule editor init * fix store reactivity issue * use class instead of store for triggers context * nit * nit * fix schedule init * Add postgres error message * nit * fix deploy new script * load schema in shedule editor * improve transitions * Improve capture indication * revert unwanted changes * improve update * nit * migrate captures section to svelte 5 * only show trash on hover triggers row * Add token count * improve trigger display * Add relevent naming for triggers * Use wrapper div insted of width binding in flow card * add trigger sorting * nit * handle reload from URL for draft triggers * dirty script and flow draft with draft triggers * handle cloud hosted triggers * nit * nit * fix nats reactivity * remove debug log * handle deployed draft triggers in the flow editor * Add warning for event stream capture --------- Co-authored-by: dieriba Co-authored-by: Ruben Fiszel --- backend/windmill-api/src/utils.rs | 13 + .../src/lib/components/Description.svelte | 4 +- frontend/src/lib/components/Dev.svelte | 14 +- .../src/lib/components/DisplayResult.svelte | 10 +- .../components/DisplayResultControlBar.svelte | 2 +- frontend/src/lib/components/DropdownV2.svelte | 19 +- .../src/lib/components/DropdownV2Inner.svelte | 18 +- .../src/lib/components/FlowBuilder.svelte | 325 ++-- frontend/src/lib/components/Label.svelte | 6 +- frontend/src/lib/components/Path.svelte | 46 +- .../src/lib/components/PrimarySchedule.svelte | 86 - .../lib/components/RunPageSchedules.svelte | 245 --- .../src/lib/components/SchedulePanel.svelte | 53 + .../src/lib/components/ScriptBuilder.svelte | 301 ++-- .../src/lib/components/ScriptPicker.svelte | 2 +- frontend/src/lib/components/Section.svelte | 4 +- frontend/src/lib/components/Toggle.svelte | 18 +- .../src/lib/components/TooltipInner.svelte | 29 + frontend/src/lib/components/common/Url.svelte | 22 + .../components/common/button/Button.svelte | 9 +- .../src/lib/components/common/button/model.ts | 1 + .../ConfirmationModal.svelte | 19 +- .../DraftTriggersConfirmationModal.svelte | 178 ++ frontend/src/lib/components/common/index.ts | 1 + .../components/common/table/FlowRow.svelte | 2 +- .../components/common/table/ScriptRow.svelte | 2 +- .../toggleButton-v2/ToggleButton.svelte | 1 + .../toggleButton-v2/ToggleButtonGroup.svelte | 2 +- .../components/details/ClipboardPanel.svelte | 5 +- .../details/CopyableCodeBlock.svelte | 10 +- .../details/DetailPageDetailPanel.svelte | 33 +- .../details/DetailPageHeader.svelte | 16 +- .../details/DetailPageLayout.svelte | 76 +- .../details/EmailTriggerCaptures.svelte | 70 + .../details/EmailTriggerConfigSection.svelte | 35 - .../details/EmailTriggerPanel.svelte | 34 +- .../lib/components/flows/FlowEditor.svelte | 4 + .../components/flows/common/FlowCard.svelte | 4 +- .../flows/content/FlowEditorPanel.svelte | 17 +- .../flows/content/FlowModuleWrapper.svelte | 33 +- .../flows/content/FlowPathViewer.svelte | 14 +- .../flows/map/FlowModuleSchemaMap.svelte | 4 +- .../flows/map/InsertModuleButton.svelte | 153 +- .../flows/map/InsertModuleInner.svelte | 167 ++ .../src/lib/components/flows/scheduleUtils.ts | 64 +- .../graph/renderers/nodes/TriggersNode.svelte | 53 +- .../renderers/triggers/TriggersBadge.svelte | 381 ++++- .../renderers/triggers/TriggersWrapper.svelte | 133 +- .../components/icons/GoogleCloudIcon.svelte | 7 +- .../meltComponents/MeltButton.svelte | 13 +- .../lib/components/meltComponents/Menu.svelte | 9 +- .../components/meltComponents/Tooltip.svelte | 31 +- .../src/lib/components/runs/RunRow.svelte | 2 +- .../schema/AddPropertyFormV2.svelte | 6 +- .../src/lib/components/table/DataTable.svelte | 6 +- frontend/src/lib/components/triggers.ts | 42 +- .../triggers/AddTriggersButton.svelte | 133 ++ .../components/triggers/CaptureSection.svelte | 510 +++++- .../components/triggers/CaptureTable.svelte | 6 +- .../components/triggers/CaptureWrapper.svelte | 297 ++-- .../triggers/TriggerEditorToolbar.svelte | 137 ++ .../components/triggers/TriggerLabel.svelte | 43 + .../components/triggers/TriggersEditor.svelte | 577 ++++--- .../triggers/TriggersEditorSection.svelte | 135 -- .../components/triggers/TriggersTable.svelte | 139 ++ .../triggers/TriggersWrapper.svelte | 280 +-- .../components/triggers/gcp/GcpCapture.svelte | 71 + .../triggers/gcp/GcpTriggerEditor.svelte | 11 +- .../gcp/GcpTriggerEditorConfigSection.svelte | 104 +- .../triggers/gcp/GcpTriggerEditorInner.svelte | 459 +++-- .../triggers/gcp/GcpTriggerPanel.svelte | 163 +- .../src/lib/components/triggers/gcp/utils.ts | 52 + .../http/RouteBodyTransformerOption.svelte | 44 +- .../triggers/http/RouteCapture.svelte | 87 + .../triggers/http/RouteEditor.svelte | 16 +- .../http/RouteEditorConfigSection.svelte | 115 +- .../triggers/http/RouteEditorInner.svelte | 800 +++++---- .../triggers/http/RoutesPanel.svelte | 158 +- .../src/lib/components/triggers/http/utils.ts | 51 + .../triggers/kafka/KafkaCapture.svelte | 58 + .../triggers/kafka/KafkaTriggerEditor.svelte | 8 +- .../kafka/KafkaTriggerEditorInner.svelte | 449 +++-- .../kafka/KafkaTriggersConfigSection.svelte | 290 +--- .../triggers/kafka/KafkaTriggersPanel.svelte | 168 +- .../lib/components/triggers/kafka/utils.ts | 43 + .../triggers/mqtt/MqttCapture.svelte | 58 + .../mqtt/MqttEditorConfigSection.svelte | 25 +- .../triggers/mqtt/MqttTriggerEditor.svelte | 8 +- .../mqtt/MqttTriggerEditorInner.svelte | 451 +++-- .../triggers/mqtt/MqttTriggersPanel.svelte | 168 +- .../src/lib/components/triggers/mqtt/utils.ts | 48 + .../triggers/nats/NatsCapture.svelte | 58 + .../triggers/nats/NatsTriggerEditor.svelte | 8 +- .../nats/NatsTriggerEditorInner.svelte | 439 +++-- .../nats/NatsTriggersConfigSection.svelte | 318 +--- .../triggers/nats/NatsTriggersPanel.svelte | 170 +- .../src/lib/components/triggers/nats/utils.ts | 48 + .../triggers/postgres/PostgresCapture.svelte | 58 + .../PostgresEditorConfigSection.svelte | 141 -- .../postgres/PostgresTriggerEditor.svelte | 14 +- .../PostgresTriggerEditorInner.svelte | 955 ++++++----- .../postgres/PostgresTriggersPanel.svelte | 170 +- .../postgres/PublicationPicker.svelte | 12 +- .../triggers/postgres/RelationPicker.svelte | 17 +- .../triggers/postgres/SlotPicker.svelte | 5 +- .../lib/components/triggers/postgres/utils.ts | 59 +- .../triggers/schedules/ScheduleEditor.svelte | 10 +- .../schedules/ScheduleEditorInner.svelte | 1511 +++++++++-------- .../components/triggers/sqs/SqsCapture.svelte | 52 + .../triggers/sqs/SqsTriggerEditor.svelte | 8 +- .../sqs/SqsTriggerEditorConfigSection.svelte | 35 +- .../triggers/sqs/SqsTriggerEditorInner.svelte | 428 +++-- .../triggers/sqs/SqsTriggerPanel.svelte | 166 +- .../src/lib/components/triggers/sqs/utils.ts | 46 + .../components/triggers/testingBadge.svelte | 9 + .../components/triggers/triggers.svelte.ts | 456 +++++ frontend/src/lib/components/triggers/utils.ts | 529 ++++++ .../triggers/webhook/WebhooksCapture.svelte | 80 + .../webhook/WebhooksConfigSection.svelte | 336 ++-- .../triggers/webhook/WebhooksPanel.svelte | 60 +- .../websocket/WebsocketCapture.svelte | 55 + .../WebsocketEditorConfigSection.svelte | 37 +- .../websocket/WebsocketTriggerEditor.svelte | 27 +- .../WebsocketTriggerEditorInner.svelte | 883 ++++++---- .../websocket/WebsocketTriggersPanel.svelte | 168 +- .../components/triggers/websocket/utils.ts | 46 + frontend/src/lib/utils.ts | 11 + .../(root)/(logged)/flows/add/+page.svelte | 15 +- .../flows/edit/[...path]/+page.svelte | 29 +- .../(logged)/flows/get/[...path]/+page.svelte | 156 +- .../(root)/(logged)/gcp_triggers/+page.svelte | 2 +- .../(logged)/kafka_triggers/+page.svelte | 2 +- .../(logged)/mqtt_triggers/+page.svelte | 2 +- .../(logged)/nats_triggers/+page.svelte | 2 +- .../(logged)/postgres_triggers/+page.svelte | 2 +- .../(root)/(logged)/routes/+page.svelte | 2 +- .../(root)/(logged)/schedules/+page.svelte | 2 +- .../(root)/(logged)/scripts/add/+page.svelte | 7 +- .../scripts/edit/[...path]/+page.svelte | 11 +- .../scripts/get/[...hash]/+page.svelte | 167 +- .../(root)/(logged)/sqs_triggers/+page.svelte | 2 +- .../(logged)/websocket_triggers/+page.svelte | 2 +- frontend/src/routes/flows/dev/+page.svelte | 14 +- 143 files changed, 9986 insertions(+), 6872 deletions(-) delete mode 100644 frontend/src/lib/components/PrimarySchedule.svelte delete mode 100644 frontend/src/lib/components/RunPageSchedules.svelte create mode 100644 frontend/src/lib/components/SchedulePanel.svelte create mode 100644 frontend/src/lib/components/TooltipInner.svelte create mode 100644 frontend/src/lib/components/common/Url.svelte create mode 100644 frontend/src/lib/components/common/confirmationModal/DraftTriggersConfirmationModal.svelte create mode 100644 frontend/src/lib/components/details/EmailTriggerCaptures.svelte create mode 100644 frontend/src/lib/components/flows/map/InsertModuleInner.svelte create mode 100644 frontend/src/lib/components/triggers/AddTriggersButton.svelte create mode 100644 frontend/src/lib/components/triggers/TriggerEditorToolbar.svelte create mode 100644 frontend/src/lib/components/triggers/TriggerLabel.svelte delete mode 100644 frontend/src/lib/components/triggers/TriggersEditorSection.svelte create mode 100644 frontend/src/lib/components/triggers/TriggersTable.svelte create mode 100644 frontend/src/lib/components/triggers/gcp/GcpCapture.svelte create mode 100644 frontend/src/lib/components/triggers/gcp/utils.ts create mode 100644 frontend/src/lib/components/triggers/http/RouteCapture.svelte create mode 100644 frontend/src/lib/components/triggers/kafka/KafkaCapture.svelte create mode 100644 frontend/src/lib/components/triggers/kafka/utils.ts create mode 100644 frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte create mode 100644 frontend/src/lib/components/triggers/mqtt/utils.ts create mode 100644 frontend/src/lib/components/triggers/nats/NatsCapture.svelte create mode 100644 frontend/src/lib/components/triggers/nats/utils.ts create mode 100644 frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte delete mode 100644 frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte create mode 100644 frontend/src/lib/components/triggers/sqs/SqsCapture.svelte create mode 100644 frontend/src/lib/components/triggers/sqs/utils.ts create mode 100644 frontend/src/lib/components/triggers/testingBadge.svelte create mode 100644 frontend/src/lib/components/triggers/triggers.svelte.ts create mode 100644 frontend/src/lib/components/triggers/utils.ts create mode 100644 frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte create mode 100644 frontend/src/lib/components/triggers/websocket/WebsocketCapture.svelte create mode 100644 frontend/src/lib/components/triggers/websocket/utils.ts diff --git a/backend/windmill-api/src/utils.rs b/backend/windmill-api/src/utils.rs index b59d5d4d7f..5eef389138 100644 --- a/backend/windmill-api/src/utils.rs +++ b/backend/windmill-api/src/utils.rs @@ -5,6 +5,9 @@ * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ + +use std::fmt::Display; + use axum::{body::Body, response::Response}; use regex::Regex; use serde::{Deserialize, Deserializer}; @@ -35,6 +38,16 @@ pub enum RunnableKind { Flow, } +impl Display for RunnableKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let runnable_kind = match self { + RunnableKind::Script => "script", + RunnableKind::Flow => "flow" + }; + write!(f, "{}", runnable_kind) + } +} + pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> { let is_admin = is_super_admin_email(db, email).await?; diff --git a/frontend/src/lib/components/Description.svelte b/frontend/src/lib/components/Description.svelte index 59e233aaa8..0074928f53 100644 --- a/frontend/src/lib/components/Description.svelte +++ b/frontend/src/lib/components/Description.svelte @@ -1,8 +1,10 @@ -
+
{#if link} Learn more diff --git a/frontend/src/lib/components/Dev.svelte b/frontend/src/lib/components/Dev.svelte index 9e72afec65..7a91c3b34e 100644 --- a/frontend/src/lib/components/Dev.svelte +++ b/frontend/src/lib/components/Dev.svelte @@ -47,10 +47,11 @@ parseTypescriptDeps } from '$lib/relative_imports' import Tooltip from './Tooltip.svelte' - import type { ScheduleTrigger, TriggerContext } from './triggers' + import type { TriggerContext } from './triggers' import { workspaceAIClients } from './copilot/lib' import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker' import type { PickableProperties } from './flows/previousResults' + import { Triggers } from './triggers/triggers.svelte' $: token = $page.url.searchParams.get('wm_token') ?? undefined $: workspace = $page.url.searchParams.get('workspace') ?? undefined $: themeDarkRaw = $page.url.searchParams.get('activeColorTheme') @@ -493,20 +494,13 @@ const testStepStore = writable>({}) const selectedIdStore = writable('settings-metadata') - const selectedTriggerStore = writable< - 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll' - >('webhooks') - const primaryScheduleStore = writable(undefined) const triggersCount = writable(undefined) setContext('TriggerContext', { - primarySchedule: primaryScheduleStore, - selectedTrigger: selectedTriggerStore, triggersCount: triggersCount, simplifiedPoll: writable(false), - defaultValues: writable(undefined), - captureOn: writable(undefined), - showCaptureHint: writable(undefined) + showCaptureHint: writable(undefined), + triggersState: new Triggers() }) setContext('FlowEditorContext', { selectedId: selectedIdStore, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index aa39489723..c03cfbf597 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -35,6 +35,7 @@ import PdfViewer from './display/PdfViewer.svelte' import type { DisplayResultUi } from './custom_ui' import { getContext, hasContext, createEventDispatcher, onDestroy } from 'svelte' + import { toJsonStr } from '$lib/utils' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' export let result: any @@ -269,15 +270,6 @@ let jsonViewer: Drawer let s3FileViewer: S3FilePicker - function toJsonStr(result: any) { - try { - // console.log(result) - return JSON.stringify(result ?? null, null, 4) ?? 'null' - } catch (e) { - return 'error stringifying object: ' + e.toString() - } - } - function checkIfHasBigInt(result: any) { if (typeof result === 'number' && Number.isInteger(result) && !Number.isSafeInteger(result)) { return true diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index ea5920357d..bfcdf24c85 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -26,7 +26,7 @@ } -
+
{#if customUi?.disableDownload !== true} {#if open && !hidePopup} -
-
- -
+
+ {#if customMenu} + + {:else} +
+ +
+ {/if}
{/if} diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 1566e146d0..3c657d7678 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -4,10 +4,15 @@ import { twMerge } from 'tailwind-merge' import type { MenubarMenuElements } from '@melt-ui/svelte' import type { Item } from '$lib/utils' - export let items: Item[] | (() => Item[]) | (() => Promise) = [] - export let meltItem: MenubarMenuElements['item'] - let computedItems: Item[] | undefined = undefined + interface Props { + items?: Item[] | (() => Item[]) | (() => Promise) + meltItem: MenubarMenuElements['item'] + } + + let { items = [], meltItem }: Props = $props() + + let computedItems: Item[] | undefined = $state(undefined) async function computeItems() { if (typeof items === 'function') { computedItems = ((await items()) ?? []).filter((item) => !item.hide) @@ -38,9 +43,12 @@ item={meltItem} > {#if item.icon} - + {/if} - {item.displayName} +

+ {item.displayName} +

+ {@render item.extra?.()} {/each}
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8854901c0f..b28eb4bcec 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -1,7 +1,6 @@ @@ -1263,6 +1248,16 @@ currentValue={$flowStore} /> + t.draftConfig)} + isFlow={true} + on:canceled={() => { + draftTriggersModalOpen = false + }} + on:confirmed={handleDraftTriggersConfirmed} +/> + {#key renderCount} {#if !$userStore?.operator} @@ -1332,7 +1327,9 @@
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte index 0b287d040e..83f4720736 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggersConfigSection.svelte @@ -1,162 +1,31 @@
- {#if showCapture && captureInfo} - - {/if} -
+
+ {#snippet header()} + {#if showTestingBadge} + + {/if} + {/snippet}
- - {#if !staticInputDisabled} - { - if (ev.detail === 'static') { - delete args.kafka_resource_path - args.brokers = [''] - args.security = { - label: 'PLAINTEXT' - } - } else { - delete args.brokers - delete args.security - } - }} - let:item - > - - - - {/if} - - - {#if selected === 'resource'} - - {:else} - args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={isStaticConnectionValid} - lightHeader={true} - /> - {/if} - {#if isConnectionValid} + + {#if !!kafkaResourcePath} {/if} @@ -287,18 +90,11 @@
args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={otherArgsValid} + schema={kafkaConfigSchema} + bind:args={kafkaCfg} + bind:isValid={kafkaCfgValid} lightHeader={true} + disabled={!can_write} />
diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte index 364a976921..d11fe49768 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggersPanel.svelte @@ -1,137 +1,69 @@ - { - loadTriggers() - }} - bind:this={kafkaTriggerEditor} -/> - {#if !$enterpriseLicense} Kafka triggers are an enterprise only feature. -{:else if isCloudHosted()} - - Kafka triggers are disabled in the multi-tenant cloud. - {:else}
- - Kafka triggers execute scripts and flows in response to messages published to Kafka topics. - - {#if !newItem && kafkaTriggers && kafkaTriggers.length > 0} -
-
- {#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)} -
-
{kafkaTrigger.path}
-
- {kafkaTrigger.kafka_resource_path} -
-
- -
-
- {/each} -
-
- {/if} - { - saveTrigger(path, e.detail.config) - }} - on:applyArgs - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="kafka" - {isFlow} - {data} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + + {#snippet description()} + {#if cloudDisabled} + + Kafka triggers are disabled in the multi-tenant cloud. + + {:else} + + Kafka triggers execute scripts and flows in response to messages published to Kafka + topics. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/kafka/utils.ts b/frontend/src/lib/components/triggers/kafka/utils.ts new file mode 100644 index 0000000000..d87a730d1b --- /dev/null +++ b/frontend/src/lib/components/triggers/kafka/utils.ts @@ -0,0 +1,43 @@ +import { KafkaTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveKafkaTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + kafka_resource_path: cfg.kafka_resource_path, + group_id: cfg.group_id, + topics: cfg.topics + } + try { + if (edit) { + await KafkaTriggerService.updateKafkaTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`Kafka trigger ${cfg.path} updated`) + } else { + await KafkaTriggerService.createKafkaTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`Kafka trigger ${cfg.path} created`) + } + if (!get(usedTriggerKinds).includes('kafka')) { + usedTriggerKinds.update((t) => [...t, 'kafka']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte b/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte new file mode 100644 index 0000000000..e31e8edd6f --- /dev/null +++ b/frontend/src/lib/components/triggers/mqtt/MqttCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to MQTT messages... +

+ {:else} +

+ Connecting to mqtt... +

+ {/if} + {:else} +

+ Start capturing to listen to MQTT messages. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte b/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte index b2c11571ae..ae78ff3143 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttEditorConfigSection.svelte @@ -1,7 +1,5 @@
- {#if showCapture && captureInfo} - - {/if}
+ + {#if showTestingBadge} + + {/if} +
diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte index 2bf129ae4d..ac74859255 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import MqttTriggerEditorInner from './MqttTriggerEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: MqttTriggerEditorInner + let drawer: MqttTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 3a94f63a9d..0f4b31465e 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -7,11 +7,9 @@ import ScriptPicker from '$lib/components/ScriptPicker.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import { MqttTriggerService, type MqttClientVersion, @@ -20,33 +18,92 @@ type MqttSubscribeTopic } from '$lib/gen' import MqttEditorConfigSection from './MqttEditorConfigSection.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveMqttTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let mqtt_resource_path: string = '' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let subscribe_topics: MqttSubscribeTopic[] = [] - let v3_config: MqttV3Config | undefined - let v5_config: MqttV5Config | undefined - let client_version: MqttClientVersion | undefined - let client_id: string - let isValid: boolean - const dispatch = createEventDispatcher() + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + customLabel?: Snippet + isDeployed?: boolean + cloudDisabled?: boolean + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - $: is_flow = itemKind === 'flow' + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + customLabel = undefined, + isDeployed = false, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined, + cloudDisabled = false + }: Props = $props() - export async function openEdit(ePath: string, isFlow: boolean) { + let mqtt_resource_path: string = $state('') + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let subscribe_topics: MqttSubscribeTopic[] = $state([]) + let v3_config: MqttV3Config | undefined = $state() + let v5_config: MqttV5Config | undefined = $state() + let client_version: MqttClientVersion | undefined = $state() + let client_id: string | undefined = $state(undefined) + let isValid: boolean | undefined = $state(undefined) + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const mqttConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !can_write || !isValid + ) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -54,11 +111,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load mqtt trigger: ${err.body}`, true) } finally { + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -67,9 +129,12 @@ fixedScriptPath_?: string, defaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) drawerLoading = true try { - mqtt_resource_path = '' + mqtt_resource_path = defaultValues?.mqtt_resource_path ?? '' drawer?.openDrawer() is_flow = nis_flow itemKind = nis_flow ? 'flow' : 'script' @@ -77,149 +142,212 @@ fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath subscribe_topics = defaultValues?.topics ?? [] - path = '' + path = defaultValues?.path ?? '' initialPath = '' edit = false dirtyPath = false client_version = defaultValues?.client_version ?? 'v5' client_id = defaultValues?.client_id ?? '' + enabled = defaultValues?.enabled ?? false } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { + async function loadTriggerConfig(cfg?: Record): Promise { try { - const s = await MqttTriggerService.getMqttTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - mqtt_resource_path = s.mqtt_resource_path - subscribe_topics = s.subscribe_topics - script_path = s.script_path - initialScriptPath = s.script_path - is_flow = s.is_flow - path = s.path - enabled = s.enabled - client_version = s.client_version - v3_config = s.v3_config - v5_config = s.v5_config - client_id = s.client_id ?? '' - can_write = canWrite(s.path, s.extra_perms, $userStore) + mqtt_resource_path = cfg?.mqtt_resource_path + subscribe_topics = cfg?.subscribe_topics + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + enabled = cfg?.enabled + client_version = cfg?.client_version + v3_config = cfg?.v3_config + v5_config = cfg?.v5_config + client_id = cfg?.client_id ?? '' + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } catch (error) { + sendUserToast(`Could not load mqtt trigger config: ${error.body}`, true) + } + } + + async function loadTrigger(defaultConfig?: Record): Promise { + try { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await MqttTriggerService.getMqttTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } } catch (error) { sendUserToast(`Could not load mqtt trigger: ${error.body}`, true) } } - async function updateTrigger(): Promise { - if (edit) { - await MqttTriggerService.updateMqttTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - client_id, - client_version, - v3_config, - v5_config, - mqtt_resource_path, - subscribe_topics, - path, - script_path, - enabled, - is_flow - } - }) - sendUserToast(`MQTT trigger ${path} updated`) - } else { - await MqttTriggerService.createMqttTrigger({ - workspace: $workspaceStore!, - requestBody: { - client_id, - client_version, - v3_config, - v5_config, - mqtt_resource_path, - subscribe_topics, - enabled: true, - path, - script_path, - is_flow - } - }) - sendUserToast(`MQTT trigger ${path} created`) + function getSaveCfg(): Record { + return { + client_id, + client_version, + v3_config, + v5_config, + mqtt_resource_path, + subscribe_topics, + path, + script_path, + enabled, + is_flow } - - if (!$usedTriggerKinds.includes('mqtt')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'mqtt'] - } - dispatch('update') - drawer.closeDrawer() } + + function getCaptureConfig(): Record { + return { + mqtt_resource_path, + subscribe_topics, + client_version, + v3_config, + v5_config, + client_id, + path + } + } + + async function updateTrigger(): Promise { + deploymentLoading = true + const cfg = getSaveCfg() + const isSaved = await saveMqttTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false + } + + async function handleToggleEnabled(newEnabled: boolean) { + enabled = newEnabled + if (!isDraftOnly && !hasDraft) { + await MqttTriggerService.setMqttTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: newEnabled } + }) + sendUserToast(`${newEnabled ? 'enabled' : 'disabled'} MQTT trigger ${initialPath}`) + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid ?? false) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(mqttConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} mqtt trigger ${initialPath}`) - }} - /> -
- {/if} - +{#if useDrawer} + + + + {@render actions()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} -
- + + {@render actions()} + + {@render config()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} + + {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + {#if edit} Changes can take up to 30 seconds to take effect. {:else} New MQTT triggers can take up to 30 seconds to start listening. {/if} + {/if} +
+
+
+
-
-
- -
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -232,32 +360,37 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyString(script_path)} + Create from template + {/if}

+ {/if} - -
- {/if} - - + +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte index 834b6c16bb..7b918726f2 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggersPanel.svelte @@ -1,126 +1,62 @@ - { - loadTriggers() - }} - bind:this={mqttTriggerEditor} -/> - -{#if isCloudHosted()} - - MQTT triggers are disabled in the multi-tenant cloud. - -{:else} -
- - Windmill can connect to an MQTT broker and subscribes to specific topics thus allowing the - execution of script/flows based on the event triggered by those subscribed topics - - - {#if !newItem && mqttTriggers && mqttTriggers.length > 0} -
-
-
- {#each mqttTriggers as mqttTriggers (mqttTriggers.path)} -
-
{mqttTriggers.path}
- -
- -
-
- {/each} -
-
-
- {/if} - - { - mqttTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="mqtt" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + MQTT triggers are disabled in the multi-tenant cloud. + + {:else} + + MQTT triggers allow you to execute scripts and flows in response to MQTT messages. They + can be configured to subscribe to specific topics with different QoS levels. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/mqtt/utils.ts b/frontend/src/lib/components/triggers/mqtt/utils.ts new file mode 100644 index 0000000000..14e264a088 --- /dev/null +++ b/frontend/src/lib/components/triggers/mqtt/utils.ts @@ -0,0 +1,48 @@ +import { MqttTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveMqttTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + client_id: cfg.client_id, + client_version: cfg.client_version, + v3_config: cfg.v3_config, + v5_config: cfg.v5_config, + mqtt_resource_path: cfg.mqtt_resource_path, + subscribe_topics: cfg.subscribe_topics, + path: cfg.path, + script_path: cfg.script_path, + enabled: cfg.enabled, + is_flow: cfg.is_flow + } + try { + if (edit) { + await MqttTriggerService.updateMqttTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`MQTT trigger ${cfg.path} updated`) + } else { + await MqttTriggerService.createMqttTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`MQTT trigger ${cfg.path} created`) + } + + if (!get(usedTriggerKinds).includes('mqtt')) { + usedTriggerKinds.update((t) => [...t, 'mqtt']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/nats/NatsCapture.svelte b/frontend/src/lib/components/triggers/nats/NatsCapture.svelte new file mode 100644 index 0000000000..c44fb8b5d7 --- /dev/null +++ b/frontend/src/lib/components/triggers/nats/NatsCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to NATS messages... +

+ {:else} +

+ Connecting to nats... +

+ {/if} + {:else} +

+ Start capturing to listen to NATS messages. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte index e4b74437d9..ef01c1e51d 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import NatsTriggerEditorInner from './NatsTriggerEditorInner.svelte' - let open = false + let { onUpdate } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: NatsTriggerEditorInner + let drawer: NatsTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index 6d3cab175f..d1a0b87bee 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -8,35 +8,97 @@ import { NatsTriggerService } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' import NatsTriggersConfigSection from './NatsTriggersConfigSection.svelte' - import Toggle from '$lib/components/Toggle.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveNatsTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let defaultValues: Record | undefined = undefined - let args: Record = {} + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + useEditButton?: boolean + isEditor?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isDeployed?: boolean + cloudDisabled?: boolean + customLabel?: Snippet + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - const dispatch = createEventDispatcher() + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + isEditor = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isDeployed = false, + cloudDisabled = false, + customLabel = undefined, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() - $: is_flow = itemKind === 'flow' + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let defaultValues: Record | undefined = $state(undefined) + let natsResourcePath = $state('') + let subjects = $state(['']) + let useJetstream = $state(false) + let streamName = $state('') + let consumerName = $state('') + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + let isValid = $state(false) - export async function openEdit(ePath: string, isFlow: boolean) { + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !can_write || !isValid + ) + const natsConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -44,11 +106,16 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load nats trigger: ${err}`, true) } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } } } @@ -57,92 +124,95 @@ fixedScriptPath_?: string, nDefaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) drawerLoading = true try { drawer?.openDrawer() is_flow = nis_flow edit = false itemKind = nis_flow ? 'flow' : 'script' - args.nats_resource_path = nDefaultValues?.nats_resource_path ?? '' - args.subjects = nDefaultValues?.subjects ?? [''] - args.use_jetstream = nDefaultValues?.use_jetstream ?? false - args.stream_name = args.use_jetstream ? (nDefaultValues?.stream_name ?? '') : undefined - args.consumer_name = args.use_jetstream ? (nDefaultValues?.consumer_name ?? '') : undefined + natsResourcePath = nDefaultValues?.nats_resource_path ?? '' + subjects = nDefaultValues?.subjects ?? [''] + useJetstream = nDefaultValues?.use_jetstream ?? false + streamName = useJetstream ? (nDefaultValues?.stream_name ?? '') : undefined + consumerName = useJetstream ? (nDefaultValues?.consumer_name ?? '') : undefined initialScriptPath = '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath - path = '' + path = nDefaultValues?.path ?? '' initialPath = '' dirtyPath = false defaultValues = nDefaultValues + enabled = nDefaultValues?.enabled ?? false } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { - const s = await NatsTriggerService.getNatsTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path + async function loadTriggerConfig(cfg?: Record): Promise { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + natsResourcePath = cfg?.nats_resource_path + streamName = cfg?.stream_name + consumerName = cfg?.consumer_name + subjects = cfg?.subjects || [''] + useJetstream = cfg?.use_jetstream || false + enabled = cfg?.enabled + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } - is_flow = s.is_flow - path = s.path - args.nats_resource_path = s.nats_resource_path - args.stream_name = s.stream_name - args.consumer_name = s.consumer_name - args.subjects = s.subjects - args.use_jetstream = s.use_jetstream - enabled = s.enabled + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await NatsTriggerService.getNatsTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } + } - can_write = canWrite(s.path, s.extra_perms, $userStore) + function getSaveCfg() { + return { + path, + script_path, + is_flow, + enabled, + nats_resource_path: natsResourcePath, + stream_name: streamName, + consumer_name: consumerName, + subjects, + use_jetstream: useJetstream + } } async function updateTrigger(): Promise { - if (edit) { - await NatsTriggerService.updateNatsTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - nats_resource_path: args.nats_resource_path, - stream_name: args.stream_name, - consumer_name: args.consumer_name, - subjects: args.subjects, - use_jetstream: args.use_jetstream - } - }) - sendUserToast(`Nats trigger ${path} updated`) - } else { - await NatsTriggerService.createNatsTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - enabled: true, - nats_resource_path: args.nats_resource_path, - stream_name: args.stream_name, - consumer_name: args.consumer_name, - subjects: args.subjects, - use_jetstream: args.use_jetstream - } - }) - sendUserToast(`Nats trigger ${path} created`) + deploymentLoading = true + const cfg = natsConfig + const isSaved = await saveNatsTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() } - if (!$usedTriggerKinds.includes('nats')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'nats'] - } - dispatch('update') - drawer.closeDrawer() + deploymentLoading = false } function useDefaultValues() { - if (args.nats_resource_path && args.nats_resource_path != '') { + if (natsResourcePath && natsResourcePath != '') { return false } if (!defaultValues) { @@ -155,73 +225,120 @@ ) } - let isValid = false + async function handleToggleEnabled(toggleEnabled: boolean) { + enabled = toggleEnabled + if (!isDraftOnly && !hasDraft) { + await NatsTriggerService.setNatsTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: toggleEnabled } + }) + sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} NATS trigger ${initialPath}`) + } + } + + function getCaptureConfig() { + const { nats_resource_path, subjects, stream_name, consumer_name, use_jetstream } = natsConfig + return { nats_resource_path, subjects, stream_name, consumer_name, use_jetstream } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + !drawerLoading && + handleConfigChange(natsConfig, initialConfig, saveDisabled, edit, onConfigChange) + }) - - - - {#if !drawerLoading} - {#if edit} -
- { - await NatsTriggerService.setNatsTriggerEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} NATS trigger ${initialPath}`) - }} - /> -
- {/if} - {#if can_write} - - {/if} +{#if useDrawer} + + + + {@render actions()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} + + {@render actions()} + + {@render config()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} - {:else} - - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - NATS consumers can take up to 30 seconds to start. - {/if} - -
-
- -
+ {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + NATS consumers can take up to 30 seconds to start. + {/if} + + {/if} +
+
+
+ +
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -243,19 +360,29 @@ color="dark" size="xs" href={itemKind === 'flow' ? '/flows/add?hub=66' : '/scripts/add?hub=hub%2F19663'} - target="_blank">Create from template + Create from template + {/if}

+ {/if} - -
- {/if} - - + { + isValid = detail + }} + defaultValues={useDefaultValues() ? defaultValues : undefined} + {can_write} + showTestingBadge={isEditor} + /> +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte index 0a00b449a1..d925552ff0 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggersConfigSection.svelte @@ -1,135 +1,49 @@
- {#if showCapture && captureInfo} - - {/if}
+ {#snippet badge()} + {#if showTestingBadge} + + {/if} + {/snippet}
- - {#if !staticInputDisabled} - { - if (ev.detail === 'static') { - delete args.nats_resource_path - args.require_tls = false - args.servers = [''] - args.auth = { - label: 'NO_AUTH' - } - } else { - delete args.servers - delete args.auth - delete args.require_tls - } - }} - let:item - > - - - - {/if} - - - {#if selected === 'resource'} - - {:else} - args, - (v) => { - args = { - ...args, - ...v - } - } - } - bind:isValid={isStaticConnectionValid} - lightHeader={true} - /> - {/if} + {#if isConnectionValid} - + {/if}
@@ -291,17 +160,10 @@ args, - (v) => { - args = { - ...args, - ...v - } - } - } + bind:args={getNatsArgsCfg, (args) => setNewArgs(args)} bind:isValid={otherArgsValid} lightHeader={true} + disabled={!can_write} /> {globalError} diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte index 12cd385e3a..20bfe8d5dc 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggersPanel.svelte @@ -1,137 +1,69 @@ - { - loadTriggers() - }} - bind:this={natsTriggerEditor} -/> - {#if !$enterpriseLicense} - Nats triggers are an enterprise only feature. - -{:else if isCloudHosted()} - - Nats triggers are disabled in the multi-tenant cloud. + NATS triggers are an enterprise only feature. {:else}
- - NATS triggers execute scripts and flows in response to messages published to NATS subjects. - - - {#if !newItem && natsTriggers && natsTriggers.length > 0} -
-
- {#each natsTriggers as natsTrigger (natsTrigger.path)} -
-
{natsTrigger.path}
-
- {natsTrigger.nats_resource_path} -
-
- -
-
- {/each} -
-
- {/if} - - { - saveTrigger(path, e.detail.config) - }} - on:applyArgs - on:addPreprocessor - cloudDisabled={false} - triggerType="nats" - {isFlow} - {data} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + + {#snippet description()} + {#if cloudDisabled} + + NATS triggers are disabled in the multi-tenant cloud. + + {:else} + + NATS triggers allow you to execute scripts and flows in response to NATS messages. They + can be configured to listen to specific subjects and to use JetStream or not. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/nats/utils.ts b/frontend/src/lib/components/triggers/nats/utils.ts new file mode 100644 index 0000000000..926956314c --- /dev/null +++ b/frontend/src/lib/components/triggers/nats/utils.ts @@ -0,0 +1,48 @@ +import { NatsTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveNatsTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + nats_resource_path: cfg.nats_resource_path, + stream_name: cfg.stream_name, + consumer_name: cfg.consumer_name, + subjects: cfg.subjects, + use_jetstream: cfg.use_jetstream + } + try { + if (edit) { + await NatsTriggerService.updateNatsTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`Nats trigger ${cfg.path} updated`) + } else { + await NatsTriggerService.createNatsTrigger({ + workspace, + requestBody: { + ...requestBody, + enabled: true + } + }) + sendUserToast(`Nats trigger ${cfg.path} created`) + } + if (!get(usedTriggerKinds).includes('nats')) { + usedTriggerKinds.update((t) => [...t, 'nats']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte b/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte new file mode 100644 index 0000000000..6e1238d7df --- /dev/null +++ b/frontend/src/lib/components/triggers/postgres/PostgresCapture.svelte @@ -0,0 +1,58 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} + {#if captureInfo.connectionInfo?.connected} +

+ Listening to Postgres database transactions... +

+ {:else} +

+ Connecting to Postgres database... +

+ {/if} + {:else} +

+ Start capturing to listen to Postgres database transactions. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte b/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte deleted file mode 100644 index 00018eca3c..0000000000 --- a/frontend/src/lib/components/triggers/postgres/PostgresEditorConfigSection.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
- {#if showCapture && captureInfo} - - {/if} -
-
-
-

- Pick a database to connect to -

- { - if (emptyString(postgres_resource_path)) { - publication = { ...DEFAULT_PUBLICATION } - } - }} - /> - {#if postgres_resource_path} - - - {/if} -
- {#if postgres_resource_path} - - - {/if} -
-
-
diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte index 9b46ac2b0b..ac36f4ffc2 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditor.svelte @@ -2,22 +2,28 @@ import { tick } from 'svelte' import PostgresTriggerEditorInner from './PostgresTriggerEditorInner.svelte' - let open = false + let { onUpdate } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() drawer?.openEdit(ePath, isFlow) } - export async function openNew(is_flow: boolean, initial_script_path?: string, defaultValues?: Record) { + export async function openNew( + is_flow: boolean, + initial_script_path?: string, + defaultValues?: Record + ) { open = true await tick() drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: PostgresTriggerEditorInner + let drawer: PostgresTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 988152f00c..3c5253aa72 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -8,11 +8,9 @@ import { PostgresTriggerService, type Language, type Relations } from '$lib/gen' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, emptyStringTrimmed, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' - import { Loader2, Save, X } from 'lucide-svelte' + import { Loader2, X } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import ResourcePicker from '$lib/components/ResourcePicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -24,38 +22,124 @@ import Tabs from '$lib/components/common/tabs/Tabs.svelte' import Tab from '$lib/components/common/tabs/Tab.svelte' import RelationPicker from './RelationPicker.svelte' - import { invalidRelations } from './utils' + import { invalidRelations, savePostgresTriggerFromCfg } from './utils' import CheckPostgresRequirement from './CheckPostgresRequirement.svelte' import { base } from '$lib/base' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import TestingBadge from '../testingBadge.svelte' + import { handleConfigChange } from '../utils' + import { fade } from 'svelte/transition' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let postgres_resource_path = '' - let publication_name: string = '' - let replication_slot_name: string = '' - let relations: Relations[] | undefined = [] - let transaction_to_track: string[] = [] + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + isEditor?: boolean + hideTooltips?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isDeployed?: boolean + cloudDisabled?: boolean + customLabel?: Snippet + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } + + let { + useDrawer = true, + description = undefined, + hideTarget = false, + isEditor = false, + hideTooltips = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isDeployed = false, + cloudDisabled = false, + customLabel = undefined, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() + + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let postgres_resource_path = $state('') + let publication_name: string = $state('') + let replication_slot_name: string = $state('') + let relations: Relations[] | undefined = $state([]) + let transaction_to_track: string[] = $state([]) let language: Language = 'Typescript' - let loading = false + let loading = $state(false) type actions = 'create' | 'get' - let selectedPublicationAction: actions - let selectedSlotAction: actions - let publicationItems: string[] = [] + let selectedPublicationAction: actions | undefined = $state(undefined) + let selectedSlotAction: actions | undefined = $state(undefined) + let publicationItems: string[] = $state([]) let transactionType: string[] = ['Insert', 'Update', 'Delete'] - let tab: 'advanced' | 'basic' = 'basic' - let isLoading = false + let tab: 'advanced' | 'basic' = $state('basic') + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const errorMessage = $derived.by(() => { + if (relations && relations.length > 0) { + return invalidRelations(relations, { + showError: true, + trackSchemaTableError: true + }) + } + return '' + }) + + const isValid = $derived( + !emptyString(postgres_resource_path) && + !emptyString(script_path) && + !emptyString(publication_name) && + !emptyString(replication_slot_name) && + !errorMessage + ) + const postgresConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({})) + + function isAdvancedTab(t: 'advanced' | 'basic'): boolean { + return t === 'advanced' + } + + function isBasicTab(t: 'advanced' | 'basic'): boolean { + return t === 'basic' + } + + let saveDisabled = $derived( + pathError !== '' || + emptyString(postgres_resource_path) || + emptyString(script_path) || + (isAdvancedTab(tab) && emptyString(replication_slot_name)) || + emptyString(publication_name) || + (relations && isBasicTab(tab) && relations.length === 0) || + transaction_to_track.length === 0 || + drawerLoading || + !can_write + ) + async function createPublication() { try { const message = await PostgresTriggerService.createPostgresPublication({ @@ -89,11 +173,18 @@ } } - const dispatch = createEventDispatcher() + $effect(() => { + is_flow = itemKind === 'flow' + }) - $: is_flow = itemKind === 'flow' - - export async function openEdit(ePath: string, isFlow: boolean) { + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -108,19 +199,28 @@ relations = [] transaction_to_track = [] tab = 'basic' - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load postgres trigger: ${err.body}`, true) } finally { + if (!defaultConfig) { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } export async function openNew( nis_flow: boolean, fixedScriptPath_?: string, - defaultValues?: Record + defaultValues?: Record, + newDraft?: boolean ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { selectedPublicationAction = 'create' @@ -130,120 +230,127 @@ drawer?.openDrawer() is_flow = nis_flow itemKind = nis_flow ? 'flow' : 'script' - initialScriptPath = '' + initialScriptPath = defaultValues?.script_path ?? '' fixedScriptPath = fixedScriptPath_ ?? '' script_path = fixedScriptPath - path = '' + path = defaultValues?.path ?? '' initialPath = '' postgres_resource_path = defaultValues?.postgres_resource_path ?? '' edit = false dirtyPath = false - publication_name = `windmill_publication_${random_adj()}` - replication_slot_name = `windmill_replication_${random_adj()}` - transaction_to_track = defaultValues?.publication.transaction_to_track || [ + publication_name = defaultValues?.publication_name ?? `windmill_publication_${random_adj()}` + replication_slot_name = + defaultValues?.replication_slot_name ?? `windmill_replication_${random_adj()}` + transaction_to_track = defaultValues?.publication?.transaction_to_track || [ 'Insert', 'Update', 'Delete' ] - relations = defaultValues?.publication.table_to_track || [ + relations = defaultValues?.publication?.table_to_track || [ { schema_name: 'public', table_to_track: [] } ] } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { - const s = await PostgresTriggerService.getPostgresTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path + function getSaveCfg(): Record { + const cfg = { + script_path: script_path, + initialScriptPath: initialScriptPath, + is_flow: is_flow, + path: path, + postgres_resource_path: postgres_resource_path, + publication_name: edit || tab === 'advanced' ? publication_name : undefined, + replication_slot_name: edit || tab === 'advanced' ? replication_slot_name : undefined, + publication: + !edit || tab === 'basic' + ? { + transaction_to_track: transaction_to_track, + table_to_track: relations + } + : undefined + } + return cfg + } - is_flow = s.is_flow - path = s.path - enabled = s.enabled - postgres_resource_path = s.postgres_resource_path - publication_name = s.publication_name - replication_slot_name = s.replication_slot_name + async function loadTriggerConfig(cfg?: Record): Promise { + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + is_flow = cfg?.is_flow + path = cfg?.path + enabled = cfg?.enabled + postgres_resource_path = cfg?.postgres_resource_path + publication_name = cfg?.publication_name + replication_slot_name = cfg?.replication_slot_name + can_write = canWrite(path, cfg?.extra_perms, $userStore) + transaction_to_track = [...cfg?.publication?.transaction_to_track] + relations = cfg?.publication?.table_to_track ?? [] + } - const publication_data = await PostgresTriggerService.getPostgresPublication({ - path: postgres_resource_path, - workspace: $workspaceStore!, - publication: publication_name - }) - transaction_to_track = [...publication_data.transaction_to_track] - relations = publication_data.table_to_track ?? [] - can_write = canWrite(s.path, s.extra_perms, $userStore) + async function loadTrigger(defaultConfig?: Record): Promise { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + if (defaultConfig?.publication) { + transaction_to_track = [...defaultConfig.publication.transaction_to_track] + relations = defaultConfig.publication.table_to_track ?? [] + } + return + } else { + const s = await PostgresTriggerService.getPostgresTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + + const publication_data = await PostgresTriggerService.getPostgresPublication({ + path: s.postgres_resource_path, + workspace: $workspaceStore!, + publication: s.publication_name + }) + + loadTriggerConfig({ ...s, publication: publication_data }) + } + } + + function getCaptureConfig() { + return { + postgres_resource_path, + publication: + !edit || tab === 'basic' + ? { + transaction_to_track: transaction_to_track, + table_to_track: relations + } + : undefined, + publication_name: edit || tab !== 'basic' ? publication_name : undefined, + replication_slot_name: edit || tab !== 'basic' ? replication_slot_name : undefined, + path + } } async function updateTrigger(): Promise { - if ( - relations && - invalidRelations(relations, { - showError: true, - trackSchemaTableError: true - }) === true - ) { + const cfg = postgresConfig + if (!cfg) { return } - isLoading = true - try { - if (edit) { - await PostgresTriggerService.updatePostgresTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - is_flow, - postgres_resource_path, - enabled, - replication_slot_name, - publication_name, - publication: - tab === 'basic' - ? { - transaction_to_track, - table_to_track: relations - } - : undefined - } - }) - sendUserToast(`PostgresTrigger ${path} updated`) - } else { - await PostgresTriggerService.createPostgresTrigger({ - workspace: $workspaceStore!, - requestBody: { - path, - script_path, - is_flow, - enabled: true, - postgres_resource_path, - replication_slot_name: tab === 'basic' ? undefined : replication_slot_name, - publication_name: tab === 'basic' ? undefined : publication_name, - publication: { - transaction_to_track, - table_to_track: relations - } - } - }) - sendUserToast(`PostgresTrigger ${path} created`) - } - isLoading = false - if (!$usedTriggerKinds.includes('postgres')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'postgres'] - } - dispatch('update') - drawer.closeDrawer() - } catch (error) { - isLoading = false - sendUserToast(error.body || error.message, true) + deploymentLoading = true + const isSaved = await savePostgresTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(path) + drawer?.closeDrawer() } + deploymentLoading = false } const getTemplateScript = async () => { @@ -268,81 +375,115 @@ sendUserToast(error.body, true) } } + + async function handleToggleEnabled(toggleEnabled: boolean) { + enabled = toggleEnabled + if (!isDraftOnly && !hasDraft) { + await PostgresTriggerService.setPostgresTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: toggleEnabled } + }) + sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} postgres trigger ${initialPath}`) + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(postgresConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - await PostgresTriggerService.setPostgresTriggerEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - sendUserToast( - `${e.detail ? 'enabled' : 'disabled'} postgres trigger ${initialPath}` - ) - }} - /> -
- {/if} - +{#if useDrawer} + + + {@render actions()} + {@render content()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} - - {#if edit} - Changes can take up to 30 seconds to take effect. - {:else} - New postgres triggers can take up to 30 seconds to start listening. - {/if} - -
- + + {@render actions()} + + {@render content()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet content()} + {#if drawerLoading} + {#if showLoading} + +

Loading...

+ {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + + {#if edit} + Changes can take up to 30 seconds to take effect. + {:else} + New postgres triggers can take up to 30 seconds to start listening. + {/if} + + {/if} +
+
+ + {#if !hideTarget}

Pick a script or flow to be triggered @@ -355,7 +496,8 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyStringTrimmed(script_path) && is_flow === false} @@ -380,218 +522,239 @@ {/if}

-
-

- Pick a database to connect to -

-
-
- - -
+ {/if} +
+ + {#if isEditor} + + {/if} + +

+ Pick a database to connect to +

+
+
+ + +
- {#if postgres_resource_path} -
-
-
- {/if} - - + +
+ {:else} + + {/if} + +
+
+ + + + + + {/if} + + + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte index cd707bb5d3..0a007d6db0 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggersPanel.svelte @@ -1,125 +1,67 @@ - { - loadTriggers() - }} - bind:this={postgresTriggerEditor} -/> -{#if isCloudHosted()} - - Postgres triggers are disabled in the multi-tenant cloud. - -{:else} -
- - Windmill can connect to a Postgres database and trigger runnables (scripts, flows) in response - to database transactions (INSERT, UPDATE, DELETE) on specified tables, schemas, or the entire - database. Listening is done using Postgres's logical replication streaming protocol, ensuring - efficient and low-latency triggering. - - - {#if !newItem && postgresTriggers && postgresTriggers.length > 0} -
-
-
- {#each postgresTriggers as postgresTriggers (postgresTriggers.path)} -
-
{postgresTriggers.path}
-
- -
-
- {/each} -
-
-
- {/if} - { - postgresTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="postgres" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + Postgres triggers are disabled in the multi-tenant cloud. + + {:else} + + Windmill can connect to a Postgres database and trigger runnables (scripts, flows) in + response to database transactions (INSERT, UPDATE, DELETE) on specified tables, schemas, + or the entire database. Listening is done using Postgres's logical replication streaming + protocol, ensuring efficient and low-latency triggering. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte b/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte index 77b168a0f8..840d9a6856 100644 --- a/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte +++ b/frontend/src/lib/components/triggers/postgres/PublicationPicker.svelte @@ -16,6 +16,7 @@ export let postgres_resource_path: string = '' export let relations: Relations[] | undefined = undefined export let transaction_to_track: string[] = [] + export let disabled: boolean = false async function listDatabasePublication() { try { @@ -84,18 +85,17 @@ listDatabasePublication() let darkMode = false -
+
{#each v.table_to_track as table_to_track, j} @@ -90,6 +92,7 @@ type="text" bind:value={table_to_track.table_name} class="!bg-surface mt-1" + {disabled} /> @@ -128,6 +131,7 @@ selected={table_to_track.columns_name ?? []} placeholder="Select columns" --sms-options-margin="4px" + {disabled} onchange={(e) => { const option = e.option?.toString() if (e.type === 'add') { @@ -181,6 +185,7 @@ type="text" bind:value={table_to_track.where_clause} class="!bg-surface mt-1" + {disabled} />
{/each} @@ -202,12 +208,14 @@ on:add={({ detail }) => { addTable(detail.name, i) }} + {disabled} >
- @@ -258,12 +266,13 @@ invalidRelations(appendedRelations, { showError: true, trackSchemaTableError: false - }) === false + }) === '' ) { relations = appendedRelations } } }} + {disabled} > diff --git a/frontend/src/lib/components/triggers/postgres/utils.ts b/frontend/src/lib/components/triggers/postgres/utils.ts index 6b4e3e91b5..8122da726d 100644 --- a/frontend/src/lib/components/triggers/postgres/utils.ts +++ b/frontend/src/lib/components/triggers/postgres/utils.ts @@ -1,6 +1,8 @@ -import type { Relations } from '$lib/gen' +import { PostgresTriggerService, type Relations } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { emptyString } from '$lib/utils' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' type RelationError = { schemaIndex: number @@ -18,7 +20,9 @@ export function invalidRelations( trackSchemaTableError?: boolean showError?: boolean } -): boolean { +): string { + let errorMessage: string = '' + let error: RelationError = { schemaIndex: -1, tableIndex: -1, @@ -82,8 +86,6 @@ export function invalidRelations( error.trackAllTablesInSchema && error.trackSpecificColumnsInTable) if ((options?.showError ?? false) && errorFound) { - let errorMessage: string = '' - if (error.schemaError) { errorMessage = `Schema Error: Please enter a name for schema number ${error.schemaIndex}` } else if (error.tableError) { @@ -95,8 +97,53 @@ export function invalidRelations( errorMessage = 'Configuration Error: Schema-level tracking and specific table tracking with column selection cannot be used together. Refer to the documentation for valid configurations.' } - sendUserToast(errorMessage, true) } - return errorFound + return errorMessage +} + +export async function savePostgresTriggerFromCfg( + initialPath: string, + config: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + try { + const requestBody = { + path: config.path, + script_path: config.script_path, + is_flow: config.is_flow, + postgres_resource_path: config.postgres_resource_path, + replication_slot_name: config.replication_slot_name, + publication_name: config.publication_name, + publication: config.publication, + enabled: config.enabled + } + if (edit) { + await PostgresTriggerService.updatePostgresTrigger({ + workspace: workspace, + path: initialPath, + requestBody + }) + sendUserToast(`PostgresTrigger ${config.path} updated`) + } else { + await PostgresTriggerService.createPostgresTrigger({ + workspace: workspace, + requestBody: { + ...requestBody, + enabled: true + } + }) + sendUserToast(`PostgresTrigger ${config.path} created`) + } + + if (!get(usedTriggerKinds).includes('postgres')) { + usedTriggerKinds.update((t) => [...t, 'postgres']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } } diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte index f70e3a48cb..c75e215e79 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import ScheduleEditorInner from './ScheduleEditorInner.svelte' - let open = false + let { onUpdate }: { onUpdate?: (path?: string) => void } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -16,12 +18,12 @@ ) { open = true await tick() - drawer?.openNew(is_flow, initial_script_path, schedule_path) + drawer?.openNew(is_flow, initial_script_path, undefined, schedule_path) } - let drawer: ScheduleEditorInner + let drawer: ScheduleEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 69fe6c77d4..91dc02c2b0 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -23,77 +23,122 @@ import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils' import { base } from '$lib/base' - import { createEventDispatcher } from 'svelte' import Section from '$lib/components/Section.svelte' import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte' import autosize from '$lib/autosize' + import TriggerEditorToolbar from '$lib/components/triggers/TriggerEditorToolbar.svelte' + import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' import DateTimeInput from '$lib/components/DateTimeInput.svelte' import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte' import Label from '$lib/components/Label.svelte' import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte' import { runScheduleNow } from '../scheduled/utils' + import { handleConfigChange } from '../utils' + + let { + useDrawer = true, + hideTarget = false, + docDescription = undefined, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + primary = false, + draftSchema = undefined, + customLabel = undefined, + isDeployed = false, + onUpdate = undefined, + onConfigChange = undefined, + onDelete = undefined, + onReset = undefined + } = $props() let optionTabSelected: 'error_handler' | 'recovery_handler' | 'success_handler' | 'retries' = - 'error_handler' - - let is_flow: boolean = false - let initialPath = '' - let edit = true - let schedule: string = '0 0 12 * *' - let cronVersion: string = 'v2' - let isLatestCron = true - let initialCronVersion: string = 'v2' + $state('error_handler') + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let schedule: string = $state('0 0 12 * *') + let cronVersion: string = $state('v2') + let isLatestCron = $state(true) + let initialCronVersion: string = $state('v2') let initialSchedule: string - let timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone - let paused_until: string | undefined = undefined + let timezone: string = $state(Intl.DateTimeFormat().resolvedOptions().timeZone) + let paused_until: string | undefined = $state(undefined) + let itemKind: 'flow' | 'script' = $state('script') + let errorHandleritemKind: 'flow' | 'script' = $state('script') + let wsErrorHandlerMuted: boolean = $state(false) + let errorHandlerPath: string | undefined = $state(undefined) + let errorHandlerCustomInitialPath: string | undefined = $state(undefined) + let errorHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let errorHandlerExtraArgs: Record = $state({}) + let recoveryHandlerPath: string | undefined = $state(undefined) + let recoveryHandlerCustomInitialPath: string | undefined = $state(undefined) + let recoveryHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let recoveryHandlerItemKind: 'flow' | 'script' = $state('script') + let recoveryHandlerExtraArgs: Record = $state({}) + let successHandlerPath: string | undefined = $state(undefined) + let successHandlerCustomInitialPath: string | undefined = $state(undefined) + let successHandlerSelected: 'custom' | 'slack' | 'teams' = $state('slack') + let successHandlerItemKind: 'flow' | 'script' = $state('script') + let successHandlerExtraArgs: Record = $state({}) + let failedTimes = $state(1) + let failedExact = $state(false) + let recoveredTimes = $state(1) + let retry: Retry | undefined = $state(undefined) + let script_path = $state('') + let initialScriptPath = $state('') + let runnable: Script | Flow | undefined = $state() + let args: Record = $state({}) + let loading = $state(false) + let drawerLoading = $state(true) + let showLoading = $state(false) + let initialConfig: Record | undefined = undefined + let extraPerms: Record = $state({}) + let can_write = $state(true) + let initNewPath = $state(false) + let path: string = $state('') + let enabled: boolean = $state(false) + let pathError = $state('') + let summary = $state('') + let description = $state('') + let no_flow_overlap = $state(false) + let tag: string | undefined = $state(undefined) + let validCRON = $state(true) + let isValid = $state(true) + let allowSchedule = $derived(isValid && validCRON && script_path != '') + let deploymentLoading = $state(false) - let itemKind: 'flow' | 'script' = 'script' - let errorHandleritemKind: 'flow' | 'script' = 'script' - let wsErrorHandlerMuted: boolean = false - let errorHandlerPath: string | undefined = undefined - let errorHandlerCustomInitialPath: string | undefined = undefined - let errorHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let errorHandlerExtraArgs: Record = {} - let recoveryHandlerPath: string | undefined = undefined - let recoveryHandlerCustomInitialPath: string | undefined = undefined - let recoveryHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let recoveryHandlerItemKind: 'flow' | 'script' = 'script' - let recoveryHandlerExtraArgs: Record = {} - let successHandlerPath: string | undefined = undefined - let successHandlerCustomInitialPath: string | undefined = undefined - let successHandlerSelected: 'custom' | 'slack' | 'teams' = 'slack' - let successHandlerItemKind: 'flow' | 'script' = 'script' - let successHandlerExtraArgs: Record = {} - let failedTimes = 1 - let failedExact = false - let recoveredTimes = 1 - let duplicate = false - let retry: Retry | undefined = undefined + const saveDisabled = $derived( + !allowSchedule || + pathError != '' || + emptyString(script_path) || + (errorHandlerSelected == 'slack' && + !emptyString(errorHandlerPath) && + emptyString(errorHandlerExtraArgs['channel'])) || + !can_write + ) + const scheduleCfg = $derived.by(getScheduleCfg) - let script_path = '' - let initialScriptPath = '' - - let runnable: Script | Flow | undefined - let args: Record = {} - - let loading = false - - let drawerLoading = true - export function openEdit(ePath: string, isFlow: boolean) { + export async function openEdit(ePath: string, isFlow: boolean, defaultCfg?: Record) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() is_flow = isFlow initialPath = ePath itemKind = is_flow ? 'flow' : 'script' - if (path == ePath) { - loadSchedule() - } else { - path = ePath - } + path = defaultCfg?.path ?? ePath + await loadSchedule(defaultCfg) edit = true } finally { + if (!defaultCfg) { + initialConfig = structuredClone($state.snapshot(getScheduleCfg())) + } + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -147,8 +192,7 @@ successHandlerSelected = 'slack' successHandlerExtraArgs = {} } - } - else { + } else { let defaultErrorHandlerMaybe = undefined let defaultRecoveryHandlerMaybe = undefined let defaultSuccessHandlerMaybe = undefined @@ -221,8 +265,12 @@ export async function openNew( nis_flow: boolean, initial_script_path?: string, + defaultValues?: Schedule, schedule_path?: string ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { let s: Schedule | undefined @@ -231,7 +279,9 @@ workspace: $workspaceStore!, path: schedule_path }) - duplicate = true + initNewPath = true + } else if (defaultValues) { + s = defaultValues } drawer?.openDrawer() runnable = undefined @@ -239,7 +289,7 @@ edit = false itemKind = is_flow ? 'flow' : 'script' initialScriptPath = initial_script_path ?? '' - path = duplicate === true ? '' : initialScriptPath + path = initNewPath ? '' : (defaultValues?.path ?? initialScriptPath) initialPath = path cronVersion = s?.cron_version ?? 'v2' @@ -264,7 +314,9 @@ await setScheduleHandler(s) } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -274,28 +326,18 @@ } } - $: (is_flow = itemKind == 'flow') && resetRetries() - - let isValid = true - - let path: string = '' - let enabled: boolean = false - let pathError = '' - let summary = '' - let description = '' - let no_flow_overlap = false - let tag: string | undefined = undefined - - let validCRON = true - $: allowSchedule = isValid && validCRON && script_path != '' + $effect(() => { + ;(is_flow = itemKind == 'flow') && resetRetries() + }) // set isValid to true when a script/flow without any properties is selected - $: runnable?.schema && - runnable.schema.properties && - Object.keys(runnable.schema.properties).length === 0 && - (isValid = true) + $effect(() => { + setDefaultValid(draftSchema ?? runnable?.schema) + }) - const dispatch = createEventDispatcher() + function setDefaultValid(schema: Record | undefined) { + isValid = schema?.properties && Object.keys(schema.properties).length === 0 + } async function loadScript(p: string | undefined): Promise { if (p) { @@ -395,129 +437,108 @@ } } - let can_write = true - async function loadSchedule(): Promise { - loading = true - try { - const s = await ScheduleService.getSchedule({ - workspace: $workspaceStore!, - path: initialPath - }) - is_flow = s.is_flow - cronVersion = s.cron_version ?? 'v2' - initialCronVersion = cronVersion - isLatestCron = cronVersion == 'v2' - enabled = s.enabled - schedule = s.schedule - initialSchedule = schedule - timezone = s.timezone - paused_until = s.paused_until - showPauseUntil = paused_until !== undefined - summary = s.summary ?? '' - description = s.description ?? '' - script_path = s.script_path ?? '' - args = s.args ?? {} - can_write = canWrite(s.path, s.extra_perms, $userStore) - tag = s.tag - - await loadScript(script_path) - - no_flow_overlap = s.no_flow_overlap ?? false - wsErrorHandlerMuted = s.ws_error_handler_muted ?? false - retry = s.retry - await setScheduleHandler(s) - } catch (err) { - sendUserToast(`Could not load schedule: ${err}`, true) + async function loadSchedule(defaultCfg?: Record): Promise { + if (!defaultCfg) { + try { + const s = await ScheduleService.getSchedule({ + workspace: $workspaceStore!, + path: initialPath + }) + await loadScheduleCfg(s) + } catch (err) { + sendUserToast(`Could not load schedule: ${err}`, true) + } + } else { + await loadScheduleCfg(defaultCfg) } + } + + async function loadScheduleCfg(cfg: Record): Promise { + loading = true + + cronVersion = cfg.cron_version ?? 'v2' + initialCronVersion = cronVersion + isLatestCron = cronVersion == 'v2' + enabled = cfg.enabled + schedule = cfg.schedule + initialSchedule = schedule + timezone = cfg.timezone + paused_until = cfg.paused_until + showPauseUntil = paused_until !== undefined + summary = cfg.summary ?? '' + description = cfg.description ?? '' + script_path = cfg.script_path ?? '' + await loadScript(script_path) + + is_flow = cfg.is_flow + no_flow_overlap = cfg.no_flow_overlap ?? false + wsErrorHandlerMuted = cfg.ws_error_handler_muted ?? false + retry = cfg.retry + if (cfg.on_failure) { + let splitted = cfg.on_failure.split('/') + errorHandleritemKind = splitted[0] as 'flow' | 'script' + errorHandlerPath = splitted.slice(1)?.join('/') + errorHandlerCustomInitialPath = errorHandlerPath + failedTimes = cfg.on_failure_times ?? 1 + failedExact = cfg.on_failure_exact ?? false + errorHandlerExtraArgs = cfg.on_failure_extra_args ?? {} + errorHandlerSelected = getHandlerType('error', errorHandlerPath ?? '') + } else { + errorHandlerPath = undefined + errorHandleritemKind = 'script' + errorHandlerCustomInitialPath = undefined + errorHandlerExtraArgs = {} + failedExact = false + failedTimes = 1 + errorHandlerSelected = 'slack' + } + if (cfg.on_recovery) { + let splitted = cfg.on_recovery.split('/') + recoveryHandlerItemKind = splitted[0] as 'flow' | 'script' + recoveryHandlerPath = splitted.slice(1)?.join('/') + recoveryHandlerCustomInitialPath = recoveryHandlerPath + recoveredTimes = cfg.on_recovery_times ?? 1 + recoveryHandlerExtraArgs = cfg.on_recovery_extra_args ?? {} + recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath ?? '') + } else { + recoveryHandlerPath = undefined + recoveryHandlerItemKind = 'script' + recoveryHandlerCustomInitialPath = undefined + recoveredTimes = 1 + recoveryHandlerSelected = 'slack' + recoveryHandlerExtraArgs = {} + } + if (cfg.on_success) { + let splitted = cfg.on_success.split('/') + successHandlerItemKind = splitted[0] as 'flow' | 'script' + successHandlerPath = splitted.slice(1)?.join('/') + successHandlerCustomInitialPath = successHandlerPath + successHandlerExtraArgs = cfg.on_success_extra_args ?? {} + successHandlerSelected = getHandlerType('success', successHandlerPath ?? '') + } else { + successHandlerPath = undefined + successHandlerItemKind = 'script' + successHandlerCustomInitialPath = undefined + successHandlerSelected = 'slack' + successHandlerExtraArgs = {} + } + args = cfg.args ?? {} + extraPerms = cfg.extra_perms ?? {} + can_write = canWrite(cfg.path, cfg.extra_perms, $userStore) + tag = cfg.tag + loading = false } async function scheduleScript(): Promise { - if (errorHandlerPath !== undefined && isSlackHandler('error', errorHandlerPath)) { - errorHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - errorHandlerExtraArgs['slack'] = undefined + const scheduleCfg = getScheduleCfg() + deploymentLoading = true + const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, $workspaceStore!) + if (isSaved) { + onUpdate?.(scheduleCfg.path) + drawer?.closeDrawer() } - if (recoveryHandlerPath !== undefined && isSlackHandler('recovery', recoveryHandlerPath)) { - recoveryHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - recoveryHandlerExtraArgs['slack'] = undefined - } - if (successHandlerPath !== undefined && isSlackHandler('success', successHandlerPath)) { - successHandlerExtraArgs['slack'] = '$res:f/slack_bot/bot_token' - } else { - successHandlerExtraArgs['slack'] = undefined - } - if (edit) { - await ScheduleService.updateSchedule({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - schedule: formatCron(schedule), - timezone, - args, - on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, - on_failure_times: failedTimes, - on_failure_exact: failedExact, - on_failure_extra_args: errorHandlerPath ? errorHandlerExtraArgs : undefined, - on_recovery: recoveryHandlerPath - ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` - : undefined, - on_recovery_times: recoveredTimes, - on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgs : {}, - on_success: successHandlerPath - ? `${successHandlerItemKind}/${successHandlerPath}` - : undefined, - on_success_extra_args: successHandlerPath ? successHandlerExtraArgs : {}, - ws_error_handler_muted: wsErrorHandlerMuted, - retry: retry, - summary: summary != '' ? summary : undefined, - description: description, - no_flow_overlap: no_flow_overlap, - tag: tag, - paused_until: paused_until, - cron_version: cronVersion - } - }) - sendUserToast(`Schedule ${path} updated`) - } else { - await ScheduleService.createSchedule({ - workspace: $workspaceStore!, - requestBody: { - path, - schedule: formatCron(schedule), - timezone, - script_path, - is_flow, - args, - enabled: true, - on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, - on_failure_times: failedTimes, - on_failure_exact: failedExact, - on_failure_extra_args: errorHandlerPath ? errorHandlerExtraArgs : undefined, - on_recovery: recoveryHandlerPath - ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` - : undefined, - on_recovery_times: recoveredTimes, - on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgs : {}, - on_success: successHandlerPath - ? `${successHandlerItemKind}/${successHandlerPath}` - : undefined, - on_success_extra_args: successHandlerPath ? successHandlerExtraArgs : {}, - ws_error_handler_muted: wsErrorHandlerMuted, - retry: retry, - summary: summary != '' ? summary : undefined, - description: description, - no_flow_overlap: no_flow_overlap, - tag: tag, - paused_until: paused_until, - cron_version: cronVersion - } - }) - sendUserToast(`Schedule ${path} created`) - } - dispatch('update') - drawer.closeDrawer() + deploymentLoading = false } function getHandlerType( @@ -562,21 +583,15 @@ } } - $: { - if ($workspaceStore) { - if (edit && path != '') { - loadSchedule() - } - } - } + let drawer: Drawer | undefined = $state() - let drawer: Drawer + let pathC: Path | undefined = $state() + let dirtyPath = $state(false) - let pathC: Path - let dirtyPath = false - - let showPauseUntil = false - $: !showPauseUntil && (paused_until = undefined) + let showPauseUntil = $state(false) + $effect(() => { + !showPauseUntil && (paused_until = undefined) + }) function onVersionChange() { cronVersion = isLatestCron ? 'v2' : 'v1' @@ -592,20 +607,99 @@ schedule = initialSchedule } } + + function getScheduleCfg(): Record { + let errorHadlerExtraArgsDerived = structuredClone($state.snapshot(errorHandlerExtraArgs)) + if (errorHandlerPath !== undefined && isSlackHandler('error', errorHandlerPath)) { + errorHadlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + errorHadlerExtraArgsDerived['slack'] = undefined + } + + let recoveryHandlerExtraArgsDerived = structuredClone($state.snapshot(recoveryHandlerExtraArgs)) + if (recoveryHandlerPath !== undefined && isSlackHandler('recovery', recoveryHandlerPath)) { + recoveryHandlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + recoveryHandlerExtraArgsDerived['slack'] = undefined + } + + let successHandlerExtraArgsDerived = structuredClone($state.snapshot(successHandlerExtraArgs)) + if (successHandlerPath !== undefined && isSlackHandler('success', successHandlerPath)) { + successHandlerExtraArgsDerived['slack'] = '$res:f/slack_bot/bot_token' + } else { + successHandlerExtraArgsDerived['slack'] = undefined + } + return { + path: path, + schedule: formatCron(schedule), + timezone: timezone, + script_path: script_path, + is_flow: is_flow, + args: args, + enabled: enabled, + on_failure: errorHandlerPath ? `${errorHandleritemKind}/${errorHandlerPath}` : undefined, + on_failure_times: failedTimes, + on_failure_exact: failedExact, + on_failure_extra_args: errorHandlerPath ? errorHadlerExtraArgsDerived : undefined, + on_recovery: recoveryHandlerPath + ? `${recoveryHandlerItemKind}/${recoveryHandlerPath}` + : undefined, + on_recovery_times: recoveredTimes, + on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgsDerived : {}, + on_success: successHandlerPath + ? `${successHandlerItemKind}/${successHandlerPath}` + : undefined, + on_success_extra_args: successHandlerPath ? successHandlerExtraArgsDerived : {}, + ws_error_handler_muted: wsErrorHandlerMuted, + retry: retry, + summary: summary != '' ? summary : undefined, + description: description, + no_flow_overlap: no_flow_overlap, + tag: tag, + paused_until: paused_until, + cron_version: cronVersion, + extra_perms: extraPerms + } + } + + async function handleToggleEnabled(nEnabled: boolean) { + enabled = nEnabled + if (!isDraftOnly && !hasDraft) { + await ScheduleService.setScheduleEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: nEnabled } + }) + sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} schedule ${initialPath}`) + } + } + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(scheduleCfg, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading} - {#if edit} +{#snippet saveButton()} + {#if !drawerLoading} + + {#snippet extra()} + {#if !drawerLoading && edit}
- {#if can_write} -
- { - await ScheduleService.setScheduleEnabled({ - path: initialPath, - workspace: $workspaceStore ?? '', - requestBody: { enabled: e.detail } - }) - dispatch('update') - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${initialPath}`) - }} - /> -
- {/if} {/if} - - {/if} -
- {#if drawerLoading} - - {:else} -
-
-
-

Metadata

- -
- + {/snippet} + + {/if} +{/snippet} -
+
+
+

Metadata

+
- -
- - {#if cronVersion === 'v1'} - Schedules use CRON syntax. Seconds are mandatory. - {:else} - Schedules use extended CRON syntax. + {#if !edit && !primary} + + {:else} +
+ - {/if} - - {#if initialCronVersion !== 'v2'} -
- - + { + currentTarget.select() }} - size="xs" - bind:checked={isLatestCron} - on:change={onVersionChange} - disabled={!can_write} /> +
{/if} - + + +
+ +
+ {#snippet header()} + {#if cronVersion === 'v1'} + Schedules use CRON syntax. Seconds are mandatory. + {:else} + Schedules use extended CRON syntax. {/if} -
-
+ {/snippet} + {#if initialCronVersion !== 'v2'} +
+ + +
+ {/if} + + + {#if showPauseUntil} + + {/if} +
+ +
+ {#if !hideTarget} {#if !edit}

Pick a script or flow to be triggered by the schedule

{/if} -
- {#if !loading} - {#if runnable} - {#if runnable?.schema && runnable.schema.properties && Object.keys(runnable.schema.properties).length > 0} - {#await import('$lib/components/SchemaForm.svelte')} - - {:then Module} - - {/await} - {:else} -
- This {is_flow ? 'flow' : 'script'} takes no argument -
- {/if} - {:else if script_path != ''} -
- You cannot see the the {is_flow ? 'flow' : 'script'} input form as you do not have - access to it. -
- {:else} -
- Pick a {is_flow ? 'flow' : 'script'} and fill its argument here -
- {/if} - {:else} - - {/if} -
- - -
+ {/if} +
{#if !loading} - - Error Handler - Recovery Handler - Success Handler - {#if itemKind === 'script'} - Retries - Custom tag - {/if} - -
- {#if optionTabSelected === 'error_handler'} -
- -
- {#if !$enterpriseLicense}(ee only){/if} -
-
- -
- defaults - saveAsDefaultErrorHandler(false) - }, - { - displayName: 'Override all existing', - type: 'delete', - action: () => saveAsDefaultErrorHandler(true) - } - ]} - > - - - Set as default - - -
-
-
- 0} + {#await import('$lib/components/SchemaForm.svelte')} + + {:then Module} + + {/await} + {:else} +
+ This {is_flow ? 'flow' : 'script'} takes no argument
- - - - -
-
The following args will be passed to the error handler: -
    -
  • path: The path of the script or flow that failed.
  • -
  • is_flow: Whether the runnable is a flow.
  • -
  • schedule_path: The path of the schedule.
  • -
  • error: The error details.
  • -
  • failed_times: Minimum number of times the schedule failed - before calling the error handler.
  • -
  • started_at: The start datetime of the latest job that failed.
  • -
-
-
-
-
-
-
-
-

- Triggered when schedule failed

- - -

time{failedTimes > 1 ? 's in a row' : ''}

-
-
-
- {:else if optionTabSelected === 'recovery_handler'} - {@const disabled = !can_write || emptyString($enterpriseLicense)} -
- -
- {#if !$enterpriseLicense}(ee only){/if} -
-
- -
- defaults - saveAsDefaultRecoveryHandler(false) - }, - { - displayName: 'Override all existing', - type: 'delete', - action: () => saveAsDefaultRecoveryHandler(true) - } - ]} - > - - - Set as default - - -
-
- - - -
-
The following args will be passed to the recovery handler: -
    -
  • path: The path of the script or flow that recovered.
  • -
  • is_flow: Whether the runnable is a flow.
  • -
  • schedule_path: The path of the schedule.
  • -
  • error: The error of the last job that errored
  • -
  • error_started_at: The start datetime of the last job that - errored
  • -
  • success_times: The number of times the schedule succeeded - before calling the recovery handler.
  • -
  • success_result: The result of the latest successful job
  • -
  • success_started_at: The start datetime of the latest - successful job
  • -
-
-
-
-
-
-
-
-

Triggered when schedule recovered

- -

time{recoveredTimes > 1 ? 's in a row' : ''}

-
-
-
- {:else if optionTabSelected === 'success_handler'} - {@const disabled = !can_write || emptyString($enterpriseLicense)} -
- -
- {#if !$enterpriseLicense}(ee only){/if} -
-
- -
- defaults - saveAsDefaultSuccessHandler(false) - }, - { - displayName: 'Override all existing', - type: 'delete', - action: () => saveAsDefaultSuccessHandler(true) - } - ]} - > - - - Set as default - - -
-
- - - -
-
The following args will be passed to the success handler: -
    -
  • path: The path of the script or flow that succeeded.
  • -
  • is_flow: Whether the runnable is a flow.
  • -
  • schedule_path: The path of the schedule.
  • -
  • success_result: The result of the successful job
  • -
  • success_started_at: The start datetime of the successful job
  • -
-
-
-
-
-
-
- {:else if optionTabSelected === 'retries'} - {@const disabled = !can_write || emptyString($enterpriseLicense)} -
- -
- {#if !$enterpriseLicense}(ee only){/if} -
- - If defined, upon error this schedule will be retried with a delay and a maximum - number of attempts as defined below. -
- This is only available for individual script. For flows, retries can be set on each - flow step in the flow editor. -
-
- -
- {:else if optionTabSelected === 'tag'} -
- -
+ {/if} + {:else if script_path != ''} +
+ You cannot see the the {is_flow ? 'flow' : 'script'} input form as you do not have access + to it. +
+ {:else} +
+ Pick a {is_flow ? 'flow' : 'script'} and fill its argument here +
{/if} {:else} {/if}
-
+ + +
+ {@render errorHandler()} +
+
+ {/if} +{/snippet} + +{#snippet errorHandler()} +
+ {#if !loading} + + Error Handler + Recovery Handler + Success Handler + {#if itemKind === 'script'} + Retries + Custom tag + {/if} + +
+ {#if optionTabSelected === 'error_handler'} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ defaults + saveAsDefaultErrorHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultErrorHandler(true) + } + ]} + > + {#snippet children()} + + Set as default + {/snippet} + +
+ {/snippet} +
+ +
+ + + + + +
+
The following args will be passed to the error handler: +
    +
  • path: The path of the script or flow that failed.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • error: The error details.
  • +
  • failed_times: Minimum number of times the schedule failed before + calling the error handler.
  • +
  • started_at: The start datetime of the latest job that failed.
  • +
+
+
+
+
+
+
+
+

+ Triggered when schedule failed

+ + +

time{failedTimes > 1 ? 's in a row' : ''}

+
+
+
+ {:else if optionTabSelected === 'recovery_handler'} + {@const disabled = !can_write || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ defaults + saveAsDefaultRecoveryHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultRecoveryHandler(true) + } + ]} + > + {#snippet children()} + + Set as default + {/snippet} + +
+ {/snippet} + + + + +
+
The following args will be passed to the recovery handler: +
    +
  • path: The path of the script or flow that recovered.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • error: The error of the last job that errored
  • +
  • error_started_at: The start datetime of the last job that errored
  • +
  • success_times: The number of times the schedule succeeded before + calling the recovery handler.
  • +
  • success_result: The result of the latest successful job
  • +
  • success_started_at: The start datetime of the latest successful job
  • +
+
+
+
+
+
+
+
+

Triggered when schedule recovered

+ +

time{recoveredTimes > 1 ? 's in a row' : ''}

+
+
+
+ {:else if optionTabSelected === 'success_handler'} + {@const disabled = !can_write || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ {/snippet} + {#snippet action()} +
+ defaults + saveAsDefaultSuccessHandler(false) + }, + { + displayName: 'Override all existing', + type: 'delete', + action: () => saveAsDefaultSuccessHandler(true) + } + ]} + > + {#snippet children()} + + Set as default + {/snippet} + +
+ {/snippet} + + + + +
+
The following args will be passed to the success handler: +
    +
  • path: The path of the script or flow that succeeded.
  • +
  • is_flow: Whether the runnable is a flow.
  • +
  • schedule_path: The path of the schedule.
  • +
  • success_result: The result of the successful job
  • +
  • success_started_at: The start datetime of the successful job
  • +
+
+
+
+
+
+
+ {:else if optionTabSelected === 'retries'} + {@const disabled = !can_write || emptyString($enterpriseLicense)} +
+ {#snippet header()} +
+ {#if !$enterpriseLicense}(ee only){/if} +
+ + If defined, upon error this schedule will be retried with a delay and a maximum number + of attempts as defined below. +
+ This is only available for individual script. For flows, retries can be set on each flow + step in the flow editor. +
+ {/snippet} + +
+ {:else if optionTabSelected === 'tag'} +
+ +
+ {/if} + {:else} + {/if} - - +
+{/snippet} + +{#if useDrawer} + + + +
+ {@render saveButton()} +
+
+ {@render content()} +
+
+{:else} +
+ + {#if customLabel} + {@render customLabel()} + {/if} + + +
+ {@render saveButton()} +
+
+ {#if docDescription} + {@render docDescription()} + {/if} + {@render content()} +
+{/if} diff --git a/frontend/src/lib/components/triggers/sqs/SqsCapture.svelte b/frontend/src/lib/components/triggers/sqs/SqsCapture.svelte new file mode 100644 index 0000000000..d61e663d0c --- /dev/null +++ b/frontend/src/lib/components/triggers/sqs/SqsCapture.svelte @@ -0,0 +1,52 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Listening to SQS messages... +

+ {:else} +

+ Start capturing to listen to SQS messages. +

+ {/if} + {/snippet} +
+{/if} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditor.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditor.svelte index 2de3e17567..2e1cc47727 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditor.svelte @@ -2,7 +2,9 @@ import { tick } from 'svelte' import SqsTriggerEditorInner from './SqsTriggerEditorInner.svelte' - let open = false + let { onUpdate } = $props() + + let open = $state(false) export async function openEdit(ePath: string, isFlow: boolean) { open = true await tick() @@ -19,9 +21,9 @@ drawer?.openNew(is_flow, initial_script_path, defaultValues) } - let drawer: SqsTriggerEditorInner + let drawer: SqsTriggerEditorInner | undefined = $state() {#if open} - + {/if} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorConfigSection.svelte index 3de41919fb..55282e8762 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorConfigSection.svelte @@ -1,7 +1,5 @@
- {#if showCapture && captureInfo} - - {/if}
+ + {#if showTestingBadge} + + {/if} +
@@ -64,9 +53,13 @@ Select an AWS resource to authenticate your account.

- { - aws_resource_path = '' - }} let:item> + { + aws_resource_path = '' + }} + let:item + > diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index eee25de840..c23a683b2c 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -5,40 +5,96 @@ import Path from '$lib/components/Path.svelte' import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' import { canWrite, emptyString, sendUserToast } from '$lib/utils' - import { createEventDispatcher } from 'svelte' - import { Loader2, Save } from 'lucide-svelte' + import { Loader2 } from 'lucide-svelte' import Label from '$lib/components/Label.svelte' - import Toggle from '$lib/components/Toggle.svelte' import { SqsTriggerService, type AwsAuthResourceType } from '$lib/gen' import SqsTriggerEditorConfigSection from './SqsTriggerEditorConfigSection.svelte' import Section from '$lib/components/Section.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Required from '$lib/components/Required.svelte' + import type { Snippet } from 'svelte' + import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte' + import { saveSqsTriggerFromCfg } from './utils' + import { handleConfigChange } from '../utils' - let drawer: Drawer - let is_flow: boolean = false - let initialPath = '' - let edit = true - let itemKind: 'flow' | 'script' = 'script' - let script_path = '' - let initialScriptPath = '' - let fixedScriptPath = '' - let path: string = '' - let pathError = '' - let enabled = false - let dirtyPath = false - let can_write = true - let drawerLoading = true - let aws_resource_path: string = '' - let queue_url = '' - let message_attributes: string[] = [] - let aws_auth_resource_type: AwsAuthResourceType = 'credentials' - let isValid = false - const dispatch = createEventDispatcher() + interface Props { + useDrawer?: boolean + description?: Snippet | undefined + hideTarget?: boolean + hideTooltips?: boolean + allowDraft?: boolean + hasDraft?: boolean + isDraftOnly?: boolean + isEditor?: boolean + customLabel?: Snippet + isDeployed?: boolean + cloudDisabled?: boolean + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void + onCaptureConfigChange?: (cfg: Record, isValid: boolean) => void + onUpdate?: (path?: string) => void + onDelete?: () => void + onReset?: () => void + } - $: is_flow = itemKind === 'flow' + let { + useDrawer = true, + description = undefined, + hideTarget = false, + hideTooltips = false, + allowDraft = false, + hasDraft = false, + isDraftOnly = false, + isEditor = false, + customLabel = undefined, + isDeployed = false, + cloudDisabled = false, + onConfigChange = undefined, + onCaptureConfigChange = undefined, + onUpdate = undefined, + onDelete = undefined, + onReset = undefined + }: Props = $props() - export async function openEdit(ePath: string, isFlow: boolean) { + let drawer: Drawer | undefined = $state(undefined) + let is_flow: boolean = $state(false) + let initialPath = $state('') + let edit = $state(true) + let itemKind: 'flow' | 'script' = $state('script') + let script_path = $state('') + let initialScriptPath = $state('') + let fixedScriptPath = $state('') + let path: string = $state('') + let pathError = $state('') + let enabled = $state(false) + let dirtyPath = $state(false) + let can_write = $state(true) + let drawerLoading = $state(true) + let showLoading = $state(false) + let aws_resource_path: string = $state('') + let queue_url = $state('') + let message_attributes: string[] = $state([]) + let aws_auth_resource_type: AwsAuthResourceType = $state('credentials') + let isValid = $state(false) + let initialConfig: Record | undefined = undefined + let deploymentLoading = $state(false) + + const sqsConfig = $derived.by(getSaveCfg) + const captureConfig = $derived.by(getCaptureConfig) + const saveDisabled = $derived( + pathError != '' || emptyString(script_path) || !isValid || !can_write + ) + $effect(() => { + is_flow = itemKind === 'flow' + }) + + export async function openEdit( + ePath: string, + isFlow: boolean, + defaultConfig?: Record + ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) // Do not show loading spinner for the first 100ms drawerLoading = true try { drawer?.openDrawer() @@ -46,11 +102,13 @@ itemKind = isFlow ? 'flow' : 'script' edit = true dirtyPath = false - await loadTrigger() + await loadTrigger(defaultConfig) } catch (err) { sendUserToast(`Could not load sqs trigger: ${err.body}`, true) } finally { + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } @@ -59,6 +117,9 @@ fixedScriptPath_?: string, defaultValues?: Record ) { + let loadingTimeout = setTimeout(() => { + showLoading = true + }, 100) drawerLoading = true try { drawer?.openDrawer() @@ -69,143 +130,207 @@ script_path = fixedScriptPath aws_resource_path = defaultValues?.aws_resource_path ?? '' queue_url = defaultValues?.queue_url ?? '' - path = '' + path = defaultValues?.path ?? '' message_attributes = defaultValues?.message_attributes ?? [] aws_auth_resource_type = defaultValues?.aws_auth_resource_type ?? 'credentials' initialPath = '' edit = false dirtyPath = false + enabled = defaultValues?.enabled ?? false } finally { + initialConfig = structuredClone($state.snapshot(getSaveCfg())) + clearTimeout(loadingTimeout) drawerLoading = false + showLoading = false } } - async function loadTrigger(): Promise { + async function loadTriggerConfig(cfg?: Record): Promise { try { - const s = await SqsTriggerService.getSqsTrigger({ - workspace: $workspaceStore!, - path: initialPath - }) - script_path = s.script_path - initialScriptPath = s.script_path - aws_resource_path = s.aws_resource_path - queue_url = s.queue_url - is_flow = s.is_flow - message_attributes = s.message_attributes ?? [] - path = s.path - enabled = s.enabled - aws_auth_resource_type = s.aws_auth_resource_type - can_write = canWrite(s.path, s.extra_perms, $userStore) + script_path = cfg?.script_path + initialScriptPath = cfg?.script_path + aws_resource_path = cfg?.aws_resource_path + queue_url = cfg?.queue_url + is_flow = cfg?.is_flow + message_attributes = cfg?.message_attributes ?? [] + path = cfg?.path + enabled = cfg?.enabled + aws_auth_resource_type = cfg?.aws_auth_resource_type + can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore) + } catch (error) { + sendUserToast(`Could not load SQS trigger config: ${error.body}`, true) + } + } + + async function loadTrigger(defaultConfig?: Record): Promise { + try { + if (defaultConfig) { + loadTriggerConfig(defaultConfig) + return + } else { + const s = await SqsTriggerService.getSqsTrigger({ + workspace: $workspaceStore!, + path: initialPath + }) + loadTriggerConfig(s) + } } catch (error) { sendUserToast(`Could not load SQS trigger: ${error.body}`, true) } } - async function updateTrigger(): Promise { - if (edit) { - await SqsTriggerService.updateSqsTrigger({ - workspace: $workspaceStore!, - path: initialPath, - requestBody: { - path, - script_path, - aws_auth_resource_type, - enabled, - is_flow, - queue_url, - aws_resource_path, - message_attributes - } - }) - sendUserToast(`SQS trigger ${path} updated`) - } else { - await SqsTriggerService.createSqsTrigger({ - workspace: $workspaceStore!, - requestBody: { - enabled: true, - aws_resource_path, - queue_url, - aws_auth_resource_type, - path, - script_path, - is_flow, - message_attributes - } - }) - sendUserToast(`SQS trigger ${path} created`) + function getSaveCfg(): Record { + return { + script_path, + is_flow, + path, + aws_resource_path, + queue_url, + message_attributes, + aws_auth_resource_type, + enabled } - - if (!$usedTriggerKinds.includes('sqs')) { - $usedTriggerKinds = [...$usedTriggerKinds, 'sqs'] - } - dispatch('update') - drawer.closeDrawer() } + + async function handleToggleEnabled(nEnabled: boolean) { + enabled = nEnabled + if (!isDraftOnly && !hasDraft) { + await SqsTriggerService.setSqsTriggerEnabled({ + path: initialPath, + workspace: $workspaceStore ?? '', + requestBody: { enabled: nEnabled } + }) + sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} SQS trigger ${initialPath}`) + } + } + + async function updateTrigger(): Promise { + deploymentLoading = true + const cfg = getSaveCfg() + const isSaved = await saveSqsTriggerFromCfg( + initialPath, + cfg, + edit, + $workspaceStore!, + usedTriggerKinds + ) + if (isSaved) { + onUpdate?.(cfg.path) + drawer?.closeDrawer() + } + deploymentLoading = false + } + + function getCaptureConfig(): Record { + return { + aws_resource_path, + queue_url, + message_attributes, + aws_auth_resource_type, + path + } + } + + $effect(() => { + onCaptureConfigChange?.(captureConfig, isValid) + }) + + $effect(() => { + if (!drawerLoading) { + handleConfigChange(sqsConfig, initialConfig, saveDisabled, edit, onConfigChange) + } + }) - - - - {#if !drawerLoading && can_write} - {#if edit} -
- { - sendUserToast(`${e.detail ? 'enabled' : 'disabled'} sqs trigger ${initialPath}`) - }} - /> -
- {/if} - +{#if useDrawer} + + + + {@render actions()} + + {@render config()} + + +{:else} +
+ + {#if customLabel} + {@render customLabel()} {/if} - {#if drawerLoading} -
- -

Loading...

-
- {:else} -
- + + {@render actions()} + + {@render config()} +
+{/if} + +{#snippet actions()} + {#if !drawerLoading} + + {/if} +{/snippet} + +{#snippet config()} + {#if drawerLoading} + {#if showLoading} + + {/if} + {:else} +
+ {#if description} + {@render description()} + {/if} + {#if !hideTooltips} + {#if edit} Changes can take up to 30 seconds to take effect. {:else} - New postgres triggers can take up to 30 seconds to start listening. + New SQS triggers can take up to 30 seconds to start listening. {/if} + {/if} +
+
+
+
-
-
- -
+ {#if !hideTarget}

Pick a script or flow to be triggered @@ -218,30 +343,35 @@ allowFlow={true} bind:itemKind bind:scriptPath={script_path} - allowRefresh + allowRefresh={can_write} + allowEdit={!$userStore?.operator} /> {#if emptyString(script_path)} + Create from template + {/if}

+ {/if} - -
- {/if} - - + +
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte index e981d7dedf..24bd1137f0 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerPanel.svelte @@ -1,135 +1,69 @@ - { - loadTriggers() - }} - bind:this={sqsTriggerEditor} -/> + onMount(() => { + sqsTriggerEditor && openSqsTriggerEditor(isFlow, selectedTrigger.isDraft ?? false) + }) + + const cloudDisabled = $derived(isCloudHosted()) + {#if !$enterpriseLicense} SQS triggers are an enterprise only feature. -{:else if isCloudHosted()} - - SQS triggers are disabled in the multi-tenant cloud. - {:else}
- - SQS triggers allow your scripts/flows to process messages from Amazon Simple Queue Service - (SQS) in real time. Each trigger listens to an SQS queue and executes a script or a flow when - new messages arrive. - - - {#if !newItem && sqsTriggers && sqsTriggers.length > 0} -
-
-
- {#each sqsTriggers as sqsTriggers (sqsTriggers.path)} -
-
{sqsTriggers.path}
-
- {sqsTriggers.queue_url} -
-
- -
-
- {/each} -
-
-
- {/if} - - { - sqsTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="sqs" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - bind:showCapture={dontCloseOnLoad} - /> + {#snippet description()} + {#if cloudDisabled} + + SQS triggers are disabled in the multi-tenant cloud. + + {:else} + + SQS triggers allow you to execute scripts and flows in response to messages in an AWS + SQS queue. They can be configured to filter messages based on message attributes. + + {/if} + {/snippet} +
{/if} diff --git a/frontend/src/lib/components/triggers/sqs/utils.ts b/frontend/src/lib/components/triggers/sqs/utils.ts new file mode 100644 index 0000000000..7ef1afa60b --- /dev/null +++ b/frontend/src/lib/components/triggers/sqs/utils.ts @@ -0,0 +1,46 @@ +import { SqsTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import { get, type Writable } from 'svelte/store' + +export async function saveSqsTriggerFromCfg( + initialPath: string, + cfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: cfg.path, + script_path: cfg.script_path, + is_flow: cfg.is_flow, + aws_resource_path: cfg.aws_resource_path, + queue_url: cfg.queue_url, + message_attributes: cfg.message_attributes, + aws_auth_resource_type: cfg.aws_auth_resource_type, + enabled: cfg.enabled + } + try { + if (edit) { + await SqsTriggerService.updateSqsTrigger({ + workspace, + path: initialPath, + requestBody + }) + sendUserToast(`SQS trigger ${cfg.path} updated`) + } else { + await SqsTriggerService.createSqsTrigger({ + workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`SQS trigger ${cfg.path} created`) + } + + if (!get(usedTriggerKinds).includes('sqs')) { + usedTriggerKinds.update((t) => [...t, 'sqs']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/components/triggers/testingBadge.svelte b/frontend/src/lib/components/triggers/testingBadge.svelte new file mode 100644 index 0000000000..eb8d288fbf --- /dev/null +++ b/frontend/src/lib/components/triggers/testingBadge.svelte @@ -0,0 +1,9 @@ + + + + + Config used for creating a testing endpoint + diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts new file mode 100644 index 0000000000..5f40fb6126 --- /dev/null +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -0,0 +1,456 @@ +import { + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ScheduleService, + SqsTriggerService, + WebsocketTriggerService, + type GcpTrigger, + type KafkaTrigger, + type PostgresTrigger, + type Schedule, + type TriggersCount, + type HttpTrigger, + HttpTriggerService, + GcpTriggerService +} from '$lib/gen' +import { getLightConfig, sortTriggers, updateTriggersCount, type Trigger } from './utils' +import type { Writable } from 'svelte/store' +import type { TriggerType } from './utils' +import type { UserExt } from '$lib/stores' +import type { ScheduleTrigger } from '../triggers' +import { canWrite, formatCron } from '$lib/utils' + +export class Triggers { + #triggers = $state([]) + #selectedTriggerIndex = $state(undefined) + #selectedTrigger = $derived( + this.#selectedTriggerIndex !== undefined + ? this.#triggers[this.#selectedTriggerIndex] + : undefined + ) + #updateDraftCallback: (() => void) | undefined = undefined + + constructor( + triggers: Trigger[] = [], + selectedIndex?: number, + updateDraftCallback?: (() => void) | undefined + ) { + this.#triggers = triggers + this.#selectedTriggerIndex = selectedIndex + this.#updateDraftCallback = updateDraftCallback + } + + get selectedTrigger(): Trigger | undefined { + return this.#selectedTrigger + } + + get selectedTriggerIndex(): number | undefined { + return this.#selectedTriggerIndex + } + + set selectedTriggerIndex(index: number | undefined) { + if (index === undefined || index < 0 || index >= this.#triggers.length) { + this.#selectedTriggerIndex = undefined + } else { + this.#selectedTriggerIndex = index + } + this.#updateDraftCallback?.() + } + + get triggers(): Trigger[] { + return this.#triggers + } + + setTriggers(triggers: Trigger[]) { + this.#triggers = triggers + this.#updateDraftCallback?.() + } + + setDraftConfig(triggerIndex: number, draftConfig: Record | undefined) { + if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { + return + } + this.#triggers[triggerIndex].draftConfig = draftConfig + this.#updateDraftCallback?.() + } + + getDraftTriggersSnapshot(): Trigger[] | undefined { + const draftTriggers = this.#triggers.filter((t) => t.draftConfig) + return draftTriggers.length > 0 ? $state.snapshot(draftTriggers) : undefined + } + + getSelectedTriggerSnapshot(): number | undefined { + return $state.snapshot(this.#selectedTriggerIndex) + } + + addDraftTrigger( + triggersCountStore: Writable, + type: TriggerType, + path?: string, + draftCfg?: Record + ): number { + const primaryScheduleExists = this.#triggers.some((t) => t.type === 'schedule' && t.isPrimary) + + // Create the new draft trigger + const draftId = crypto.randomUUID() + const isPrimary = type === 'schedule' && !primaryScheduleExists + const newTrigger = { + id: draftId, + type, + path, + isPrimary, + isDraft: true, + draftConfig: draftCfg + } + + this.#triggers.push(newTrigger) + this.#updateDraftCallback?.() + + updateTriggersCount(triggersCountStore, type, 'add', newTrigger.draftConfig) + + return this.#triggers.length - 1 + } + + deleteTrigger( + triggersCountStore: Writable, + triggerIndex: number + ): void { + if (triggerIndex === undefined || triggerIndex < 0 || triggerIndex >= this.#triggers.length) { + return + } + const { type } = this.#triggers[triggerIndex] + + this.#triggers = this.#triggers.filter((_, index) => index !== triggerIndex) + + updateTriggersCount(triggersCountStore, type, 'remove') + this.#updateDraftCallback?.() + } + + updateTriggers( + remoteTriggers: any[], + type: TriggerType, + user: UserExt | undefined = undefined + ): number { + const currentTriggers = this.#triggers + // Identify triggers with draftConfig to preserve + const configuredTriggers = currentTriggers.filter( + (t) => t.type === type && !t.isDraft && t.draftConfig + ) + + const configMap = new Map }>() + + configuredTriggers.forEach((t) => { + configMap.set(t.path ?? '', { draftConfig: t.draftConfig! }) + }) + + const backendTriggers = remoteTriggers.map((trigger) => { + const { draftConfig } = configMap.get(trigger.path) ?? {} + return { + type: type as TriggerType, + path: trigger.path, + isPrimary: type === 'schedule' && trigger.path === trigger.script_path, + isDraft: false, + canWrite: canWrite(trigger.path, trigger.extra_perms, user), + draftConfig: draftConfig, + lightConfig: getLightConfig(type, trigger) + } + }) + + const filteredTriggers = currentTriggers.filter((t) => t.type !== type || t.isDraft) + const newTriggers = sortTriggers([...filteredTriggers, ...backendTriggers]) + this.#triggers = newTriggers + + this.#updateDraftCallback?.() + return newTriggers.filter((t) => t.type === type).length + } + + async fetchSchedules( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + primarySchedule?: ScheduleTrigger | undefined | false, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + //First update the store with legacy primary schedule + if (primarySchedule && !this.#triggers.some((s) => s.isPrimary)) { + const primary = { + type: 'schedule' as TriggerType, + path, + isPrimary: true, + isDraft: false, + draftConfig: { + schedule: primarySchedule.cron ? formatCron(primarySchedule.cron) : undefined, + args: primarySchedule.args, + timezone: primarySchedule.timezone, + summary: primarySchedule.summary, + description: primarySchedule.description, + enabled: primarySchedule.enabled + } + } + this.#triggers = [...this.#triggers, primary] + } + + const allDeployedSchedules: Schedule[] = await ScheduleService.listSchedules({ + workspace: workspaceId, + path, + isFlow + }) + + const scheduleCount = this.updateTriggers(allDeployedSchedules, 'schedule', user) + const updatedPrimarySchedule = this.#triggers.find((s) => s.isPrimary) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + schedule_count: scheduleCount, + primary_schedule: { + schedule: + updatedPrimarySchedule?.draftConfig?.schedule ?? + updatedPrimarySchedule?.lightConfig?.schedule + } + } + }) + + return + } catch (error) { + console.error('Failed to fetch schedules:', error) + return + } + } + + async fetchWebsocketTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const wsTriggers = await WebsocketTriggerService.listWebsocketTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const wsCount = this.updateTriggers(wsTriggers, 'websocket', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + websocket_count: wsCount + } + }) + } catch (error) { + console.error('Failed to fetch Websocket triggers:', error) + } + } + + async fetchPostgresTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const pgTriggers: PostgresTrigger[] = await PostgresTriggerService.listPostgresTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const pgCount = this.updateTriggers(pgTriggers, 'postgres', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + postgres_count: pgCount + } + }) + } catch (error) { + console.error('Failed to fetch Postgres triggers:', error) + } + } + + async fetchKafkaTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const kafkaTriggers: KafkaTrigger[] = await KafkaTriggerService.listKafkaTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const kafkaCount = this.updateTriggers(kafkaTriggers, 'kafka', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + kafka_count: kafkaCount + } + }) + } catch (error) { + console.error('Failed to fetch Kafka triggers:', error) + } + } + + async fetchNatsTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const natsTriggers = await NatsTriggerService.listNatsTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const natsCount = this.updateTriggers(natsTriggers, 'nats', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + nats_count: natsCount + } + }) + } catch (error) { + console.error('Failed to fetch NATS triggers:', error) + } + } + + async fetchMqttTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const mqttTriggers = await MqttTriggerService.listMqttTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const mqttCount = this.updateTriggers(mqttTriggers, 'mqtt', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + mqtt_count: mqttCount + } + }) + } catch (error) { + console.error('Failed to fetch MQTT triggers:', error) + } + } + + async fetchSqsTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const sqsTriggers = await SqsTriggerService.listSqsTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const sqsCount = this.updateTriggers(sqsTriggers, 'sqs', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + sqs_count: sqsCount + } + }) + } catch (error) { + console.error('Failed to fetch SQS triggers:', error) + } + } + + async fetchGcpTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const gcpTriggers: GcpTrigger[] = await GcpTriggerService.listGcpTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const gcpCount = this.updateTriggers(gcpTriggers, 'gcp', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + gcp_count: gcpCount + } + }) + } catch (error) { + console.error('Failed to fetch GCP Pub/Sub triggers:', error) + } + } + + async fetchHttpTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + try { + const httpTriggers: HttpTrigger[] = await HttpTriggerService.listHttpTriggers({ + workspace: workspaceId, + path, + isFlow + }) + const httpCount = this.updateTriggers(httpTriggers, 'http', user) + triggersCountStore.update((triggersCount) => { + return { + ...(triggersCount ?? {}), + http_routes_count: httpCount + } + }) + } catch (error) { + console.error('Failed to fetch HTTP triggers:', error) + } + } + + async fetchTriggers( + triggersCountStore: Writable, + workspaceId: string | undefined, + path: string, + isFlow: boolean, + primarySchedule: ScheduleTrigger | undefined | false = undefined, + user: UserExt | undefined = undefined + ): Promise { + if (!workspaceId) return + + // Fetch each type of trigger + await Promise.all([ + this.fetchSchedules(triggersCountStore, workspaceId, path, isFlow, primarySchedule, user), + this.fetchHttpTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchWebsocketTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchPostgresTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchKafkaTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchNatsTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchMqttTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchSqsTriggers(triggersCountStore, workspaceId, path, isFlow, user), + this.fetchGcpTriggers(triggersCountStore, workspaceId, path, isFlow, user) + ]) + } +} diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts new file mode 100644 index 0000000000..7daf829366 --- /dev/null +++ b/frontend/src/lib/components/triggers/utils.ts @@ -0,0 +1,529 @@ +import { Webhook, Mail, Calendar, Route, Unplug, Database, Terminal } from 'lucide-svelte' +import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' +import NatsIcon from '$lib/components/icons/NatsIcon.svelte' +import MqttIcon from '$lib/components/icons/MqttIcon.svelte' +import AwsIcon from '$lib/components/icons/AwsIcon.svelte' +import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' +import type { CaptureTriggerKind, Flow, NewScript, TriggersCount } from '$lib/gen/types.gen' +import type { Writable } from 'svelte/store' +import SchedulePollIcon from '../icons/SchedulePollIcon.svelte' +import { type TriggerKind } from '$lib/components/triggers' +import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { saveHttpRouteFromCfg } from './http/utils' +import { saveWebsocketTriggerFromCfg } from './websocket/utils' +import { savePostgresTriggerFromCfg } from './postgres/utils' +import { saveKafkaTriggerFromCfg } from './kafka/utils' +import { saveSqsTriggerFromCfg } from './sqs/utils' +import { saveNatsTriggerFromCfg } from './nats/utils' +import { saveMqttTriggerFromCfg } from './mqtt/utils' +import { saveGcpTriggerFromCfg } from './gcp/utils' +import type { Triggers } from './triggers.svelte' +import { emptyString } from '$lib/utils' + +export const CLOUD_DISABLED_TRIGGER_TYPES = [ + 'nats', + 'kafka', + 'sqs', + 'mqtt', + 'gcp', + 'websocket', + 'postgres' +] + +export type TriggerType = + | 'webhook' + | 'email' + | 'schedule' + | 'http' + | 'websocket' + | 'postgres' + | 'kafka' + | 'nats' + | 'mqtt' + | 'sqs' + | 'gcp' + | 'poll' + | 'cli' + +export type Trigger = { + type: TriggerType + path?: string + isDraft?: boolean + isPrimary?: boolean + canWrite?: boolean + id?: string + draftConfig?: Record + captureConfig?: Record + extra?: Record + lightConfig?: Record +} + +// Map of trigger kinds to icons +export const triggerIconMap = { + webhook: Webhook, + email: Mail, + schedule: Calendar, + http: Route, + websocket: Unplug, + postgres: Database, + kafka: KafkaIcon, + nats: NatsIcon, + mqtt: MqttIcon, + sqs: AwsIcon, + gcp: GoogleCloudIcon, + primary_schedule: Calendar, + poll: SchedulePollIcon, + cli: Terminal +} + +/** + * Converts a TriggerType to a CaptureTriggerKind when a mapping exists + * @param triggerType The trigger type to convert + * @returns The corresponding CaptureTriggerKind or undefined if no mapping exists + */ +export function triggerTypeToCaptureKind(triggerType: TriggerType): CaptureTriggerKind | undefined { + // Define types that can be mapped to CaptureTriggerKind + const capturableTriggerTypes: TriggerType[] = [ + 'webhook', + 'email', + 'http', + 'websocket', + 'postgres', + 'kafka', + 'nats', + 'mqtt', + 'sqs', + 'gcp', + 'cli' + ] + + if (capturableTriggerTypes.includes(triggerType)) { + return triggerType as CaptureTriggerKind + } + + return undefined +} + +export function updateTriggersCount( + triggersCountStore: Writable, + type: TriggerType, + action: 'add' | 'remove', + primaryCfg?: Record, + isPrimary?: boolean +) { + // Map trigger types to their corresponding count property names + const countPropertyMap: Record = { + webhook: undefined, + email: undefined, + schedule: 'schedule_count', + http: 'http_routes_count', + websocket: 'websocket_count', + postgres: 'postgres_count', + kafka: 'kafka_count', + nats: 'nats_count', + mqtt: 'mqtt_count', + sqs: 'sqs_count', + gcp: 'gcp_count', + poll: undefined, + cli: undefined + } + + const countProperty = countPropertyMap[type] + + triggersCountStore.update((triggersCount) => { + // Handle special case for schedule + if (type === 'schedule') { + if (action === 'add' && primaryCfg) { + return { + ...(triggersCount ?? {}), + schedule_count: (triggersCount?.schedule_count ?? 0) + 1, + primary_schedule: primaryCfg?.schedule + } + } else if (action === 'remove') { + return { + ...(triggersCount ?? {}), + schedule_count: (triggersCount?.schedule_count ?? 1) - 1, + primary_schedule: isPrimary ? undefined : triggersCount?.primary_schedule + } + } + } + + // Handle standard count updates + if (countProperty && action === 'add') { + return { + ...(triggersCount ?? {}), + [countProperty]: (triggersCount?.[countProperty] ?? 0) + 1 + } + } else if (countProperty && action === 'remove') { + return { + ...(triggersCount ?? {}), + [countProperty]: (triggersCount?.[countProperty] ?? 1) - 1 + } + } + + return triggersCount + }) +} + +// TODO: Remove this once we've migrated all the trigger kinds to the new TriggerType enum +export function triggerKindToTriggerType(kind: TriggerKind): TriggerType | undefined { + switch (kind) { + case 'webhooks': + return 'webhook' + case 'emails': + return 'email' + case 'schedules': + return 'schedule' + case 'routes': + return 'http' + case 'websockets': + return 'websocket' + case 'postgres': + return 'postgres' + case 'kafka': + return 'kafka' + case 'nats': + return 'nats' + case 'mqtt': + return 'mqtt' + case 'sqs': + return 'sqs' + case 'gcp': + return 'gcp' + case 'scheduledPoll': + return 'poll' + default: + throw new Error(`Unknown TriggerKind: ${kind}`) + } +} + +export async function deployTriggers( + triggersToDeploy: Trigger[], + workspaceId: string | undefined, + isAdmin: boolean, + usedTriggerKinds: Writable, + initialPath?: string, + isNew?: boolean +) { + if (!workspaceId) return + + if (isNew && initialPath) { + triggersToDeploy.forEach((trigger) => { + trigger.draftConfig = { + ...trigger.draftConfig, + script_path: initialPath + } + }) + } + + // Map of trigger types to their save functions + const triggerSaveFunctions: Record = { + webhook: undefined, + email: undefined, + schedule: (trigger: Trigger) => { + if (trigger.isPrimary && initialPath) { + trigger.draftConfig = { + ...trigger.draftConfig, + path: initialPath, + script_path: initialPath + } + } + return saveScheduleFromCfg(trigger.draftConfig ?? {}, !trigger.isDraft, workspaceId) + }, + http: (trigger: Trigger) => + saveHttpRouteFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + isAdmin, + usedTriggerKinds + ), + websocket: (trigger: Trigger) => + saveWebsocketTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + postgres: (trigger: Trigger) => + savePostgresTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + kafka: (trigger: Trigger) => + saveKafkaTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + nats: (trigger: Trigger) => + saveNatsTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + mqtt: (trigger: Trigger) => + saveMqttTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + sqs: (trigger: Trigger) => + saveSqsTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + gcp: (trigger: Trigger) => + saveGcpTriggerFromCfg( + trigger.path ?? trigger.draftConfig?.path ?? '', + trigger.draftConfig ?? {}, + !trigger.isDraft, + workspaceId, + usedTriggerKinds + ), + poll: undefined, + cli: undefined + } + + await Promise.all( + triggersToDeploy.map(async (trigger) => { + const saveFunction = triggerSaveFunctions[trigger.type] + if (saveFunction) { + await saveFunction(trigger) + } else { + console.warn(`No save function defined for trigger type: ${trigger.type}`) + } + }) + ) +} + +export function handleSelectTriggerFromKind( + triggersState: Triggers, + triggersCountStore: Writable, + initialPath: string | undefined, + triggerKind: TriggerKind +) { + const triggerType = triggerKindToTriggerType(triggerKind) + + if (!triggerType) { + return + } + + const existingTriggerIndex = triggersState.triggers.findIndex( + (trigger) => trigger.type === triggerType + ) + + if (existingTriggerIndex !== -1) { + triggersState.selectedTriggerIndex = existingTriggerIndex + } else { + const newTrigger = triggersState.addDraftTrigger( + triggersCountStore, + triggerType, + triggerType === 'schedule' ? initialPath : undefined + ) + triggersState.selectedTriggerIndex = newTrigger + } +} + +export function handleConfigChange( + nCfg: Record, + initialConfig: Record | undefined, + saveDisabled: boolean, + edit: boolean, + onConfigChange?: (cfg: Record, saveDisabled: boolean, updated: boolean) => void +) { + let updated = false + if (!edit || !initialConfig) { + updated = true + } else { + // We ignore changes to enabled + let newCfg = { ...nCfg } + if ('enabled' in newCfg) { + delete newCfg.enabled + } + let initialCfg = { ...initialConfig } + if ('enabled' in initialCfg) { + delete initialCfg.enabled + } + if (JSON.stringify(newCfg) !== JSON.stringify(initialCfg)) { + updated = true + } + } + + onConfigChange?.(nCfg, saveDisabled, updated) +} + +export function getLightConfig( + triggerType: TriggerType, + trigger: Record +): Record | undefined { + if (triggerType === 'schedule') { + return { schedule: trigger.schedule, enable: trigger.enable, summary: trigger.summary } + } else if (triggerType === 'http') { + return { route_path: trigger.route_path, http_method: trigger.http_method } + } else if (triggerType === 'websocket') { + return { url: trigger.url } + } else if (triggerType === 'postgres') { + return { postgres_resource_path: trigger.postgres_resource_path } + } else if (triggerType === 'kafka') { + return { kafka_resource_path: trigger.kafka_resource_path, topics: trigger.topics } + } else if (triggerType === 'nats') { + return { nats_resource_path: trigger.nats_resource_path, subjects: trigger.subjects } + } else if (triggerType === 'mqtt') { + return { + mqtt_resource_path: trigger.mqtt_resource_path, + subscribe_topics: trigger.subscribe_topics + } + } else if (triggerType === 'sqs') { + return { queue_url: trigger.queue_url } + } else if (triggerType === 'gcp') { + return { gcp_resource_path: trigger.gcp_resource_path, topic: trigger.topic } + } else { + return undefined + } +} + +export function getTriggerLabel(trigger: Trigger): string { + const { type, isDraft, draftConfig, lightConfig, path } = trigger + const config = draftConfig ?? lightConfig + + if (type === 'webhook') { + return 'Webhook' + } else if (type === 'email') { + return 'Email' + } else if (type === 'cli') { + return 'CLI' + } else if (type === 'http' && !emptyString(config?.route_path)) { + return `${(draftConfig?.http_method ?? lightConfig?.http_method ?? 'post').toUpperCase()} ${draftConfig?.route_path ?? lightConfig?.route_path}` + } else if (type === 'schedule' && config?.summary) { + return `${config?.summary}` + } else if (type === 'kafka' && config?.topics && config?.kafka_resource_path) { + return `${config?.kafka_resource_path} - ${config?.topics.join(', ')}` + } else if (type === 'nats' && config?.subjects && config?.nats_resource_path) { + return `${config?.nats_resource_path} - ${config?.subjects.join(', ')}` + } else if (type === 'mqtt' && config?.subscribe_topics && config?.mqtt_resource_path) { + const topics = config?.subscribe_topics.map((topic: any) => topic.topic).join(', ') + return `${config?.mqtt_resource_path} - ${topics}` + } else if (type === 'sqs' && config?.queue_url) { + return `${config?.queue_url}` + } else if (type === 'gcp' && config?.gcp_resource_path && config?.topic) { + return `${config?.gcp_resource_path} - ${config?.topic}` + } else if (type === 'websocket' && config?.url) { + return `${config?.url}` + } else if (isDraft && draftConfig?.path) { + return `${draftConfig?.path}` + } else if (isDraft) { + return `New ${type.replace(/s$/, '')} trigger` + } else { + return path ?? '' + } +} + +export function sortTriggers(triggers: Trigger[]): Trigger[] { + const triggerTypeOrder = [ + 'webhook', + 'cli', + 'email', + 'poll', + 'schedule', + 'http', + 'websocket', + 'postgres', + 'kafka', + 'nats', + 'mqtt', + 'sqs', + 'gcp' + ] + + return triggers.sort((a, b) => { + // Draft triggers always come last + if (a.isDraft && !b.isDraft) return 1 + if (!a.isDraft && b.isDraft) return -1 + + // If both are drafts or both are not drafts, sort by type order + if (a.isDraft === b.isDraft) { + const aIndex = triggerTypeOrder.indexOf(a.type) + const bIndex = triggerTypeOrder.indexOf(b.type) + + // If both types are in the order array, sort by their position + if (aIndex >= 0 && bIndex >= 0) { + return aIndex - bIndex + } + + // If only one type is in the order array, it comes first + if (aIndex >= 0) return -1 + if (bIndex >= 0) return 1 + + // If neither type is in the order array, maintain original order + return 0 + } + + return 0 + }) +} + +export type FlowWithDraftAndDraftTriggers = Flow & { + draft?: Flow & { + draft_triggers?: Trigger[] + } +} + +export type NewScriptWithDraftAndDraftTriggers = NewScript & { + draft?: NewScript & { draft_triggers?: Trigger[] } + hash: string +} + +// Get rid of deployed triggers from the saved flow in the case there is a match with a deployed trigger +export function filterDraftTriggers( + savedValue: FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers, + triggersState: Triggers +): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers { + const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft) + let newSavedValue = savedValue + + const filterMatchingTriggers = (savedTriggers: Trigger[], deployedTriggers: Trigger[]) => { + return savedTriggers.filter( + (savedTrigger) => + !deployedTriggers.some( + (deployedTrigger) => + deployedTrigger.path === savedTrigger.draftConfig?.path && + deployedTrigger.type === savedTrigger.type + ) + ) + } + + const savedDraftTriggersFiltered = filterMatchingTriggers( + newSavedValue?.draft?.draft_triggers ?? [], + deployedTriggers + ) + if (newSavedValue?.draft?.draft_triggers) { + newSavedValue = { + ...newSavedValue, + draft: { + ...newSavedValue.draft, + draft_triggers: + savedDraftTriggersFiltered.length > 0 ? savedDraftTriggersFiltered : undefined + } + } as typeof newSavedValue + } + triggersState.setTriggers([ + ...triggersState.triggers.filter((t) => !t.draftConfig), + ...savedDraftTriggersFiltered + ]) + return newSavedValue +} diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte new file mode 100644 index 0000000000..b6392bf41b --- /dev/null +++ b/frontend/src/lib/components/triggers/webhook/WebhooksCapture.svelte @@ -0,0 +1,80 @@ + + +{#if captureInfo} + + {#snippet description()} + {#if captureInfo.active} +

+ Send a POST request to the URL below to simulate a webhook event. +

+ {:else} +

+ Start capturing to listen to webhook events on this test URL. +

+ {/if} + {/snippet} + + + +
+{/if} diff --git a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte index bd8bb7d044..1ee21df607 100644 --- a/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte +++ b/frontend/src/lib/components/triggers/webhook/WebhooksConfigSection.svelte @@ -21,9 +21,6 @@ import { workspaceStore, userStore } from '$lib/stores' import UserSettings from '../../UserSettings.svelte' import { generateRandomString } from '$lib/utils' - import CopyableCodeBlock from '../../details/CopyableCodeBlock.svelte' - import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte' - import CaptureTable from '../CaptureTable.svelte' export let isFlow: boolean = false export let path: string = '' @@ -32,9 +29,6 @@ export let runnableArgs: any export let triggerTokens: TriggerTokens | undefined = undefined export let scopes: string[] = [] - export let showCapture: boolean = false - export let captureTable: CaptureTable | undefined = undefined - export let captureInfo: CaptureInfo | undefined = undefined let webhooks: { async: { @@ -198,17 +192,6 @@ function waitForJobCompletion(UUID) { return `${mainFunction}\n\n${triggerJobFunction}\n\n${waitForJobCompletionFunction}` } - let captureUrl = `${location.origin}/api/w/${$workspaceStore}/capture_u/webhook/${ - isFlow ? 'flow' : 'script' - }/${path}` - - function captureCurlCode() { - return `curl \\ --X POST ${captureUrl} \\ --H 'Content-Type: application/json' \\ --d '${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)}'` - } - function curlCode() { return `TOKEN='${token}' ${requestType !== 'get_path' ? `BODY='${JSON.stringify(cleanedRunnableArgs ?? {})}'` : ''} @@ -263,195 +246,162 @@ done` {scopes} /> -
- {#if showCapture && captureInfo} - - - -
+ {/if} +{/snippet} diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte index 12142f8343..269d69bb94 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggersPanel.svelte @@ -1,127 +1,61 @@ - { - loadTriggers() - }} - bind:this={wsTriggerEditor} -/> - -{#if isCloudHosted()} - - WebSocket triggers are disabled in the multi-tenant cloud. - -{:else} -
- - WebSocket triggers allow real-time bidirectional communication between your scripts/flows and - external systems. Each trigger creates a unique WebSocket endpoint. - - - {#if !newItem && wsTriggers && wsTriggers.length > 0} -
-
-
- {#each wsTriggers as wsTriggers (wsTriggers.path)} -
-
{wsTriggers.path}
-
- {wsTriggers.url} -
-
- -
-
- {/each} -
-
-
- {/if} - - { - wsTriggerEditor?.openNew(isFlow, path, e.detail.config) - }} - on:addPreprocessor - on:updateSchema - on:testWithArgs - cloudDisabled={false} - triggerType="websocket" - {isFlow} - {path} - {isEditor} - {canHavePreprocessor} - {hasPreprocessor} - {newItem} - {openForm} - /> -
-{/if} +
+ + {#snippet description()} + {#if cloudDisabled} + + WebSocket triggers are disabled in the multi-tenant cloud. + + {:else} + + WebSocket triggers allow real-time bidirectional communication between your scripts/flows + and external systems. Each trigger creates a unique WebSocket endpoint. + + {/if} + {/snippet} + +
diff --git a/frontend/src/lib/components/triggers/websocket/utils.ts b/frontend/src/lib/components/triggers/websocket/utils.ts new file mode 100644 index 0000000000..c112cb0f1a --- /dev/null +++ b/frontend/src/lib/components/triggers/websocket/utils.ts @@ -0,0 +1,46 @@ +import { WebsocketTriggerService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import type { Writable } from 'svelte/store' +import { get } from 'svelte/store' + +export async function saveWebsocketTriggerFromCfg( + initialPath: string, + triggerCfg: Record, + edit: boolean, + workspace: string, + usedTriggerKinds: Writable +): Promise { + const requestBody = { + path: triggerCfg.path, + script_path: triggerCfg.script_path, + is_flow: triggerCfg.is_flow, + url: triggerCfg.url, + filters: triggerCfg.filters, + initial_messages: triggerCfg.initial_messages, + url_runnable_args: triggerCfg.url_runnable_args, + can_return_message: triggerCfg.can_return_message + } + try { + if (edit) { + await WebsocketTriggerService.updateWebsocketTrigger({ + workspace: workspace, + path: initialPath, + requestBody: requestBody + }) + sendUserToast(`Websocket trigger ${triggerCfg.path} updated`) + } else { + await WebsocketTriggerService.createWebsocketTrigger({ + workspace: workspace, + requestBody: { ...requestBody, enabled: true } + }) + sendUserToast(`Websocket trigger ${triggerCfg.path} created`) + } + if (!get(usedTriggerKinds).includes('ws')) { + usedTriggerKinds.update((t) => [...t, 'ws']) + } + return true + } catch (error) { + sendUserToast(error.body || error.message, true) + return false + } +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 2609eba53d..6c2bde6431 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1161,6 +1161,7 @@ export type Item = { disabled?: boolean type?: 'action' | 'delete' hide?: boolean | undefined + extra?: Snippet } export function isObjectTooBig(obj: any): boolean { @@ -1245,6 +1246,15 @@ export function formatDateShort(dateString: string | undefined): string { }).format(date) } +export function toJsonStr(result: any) { + try { + // console.log(result) + return JSON.stringify(result ?? null, null, 4) ?? 'null' + } catch (e) { + return 'error stringifying object: ' + e.toString() + } +} + export function getOS() { const userAgent = window.navigator.userAgent const platform = window.navigator.platform @@ -1268,6 +1278,7 @@ export function getOS() { import { type ClassValue, clsx } from 'clsx' import { twMerge } from 'tailwind-merge' +import type { Snippet } from 'svelte' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index e291917235..056424922b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -13,9 +13,9 @@ import { decodeState, emptySchema } from '$lib/utils' import { tick } from 'svelte' import { writable } from 'svelte/store' - import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' + import type { Trigger } from '$lib/components/triggers/utils' let nodraft = $page.url.searchParams.get('nodraft') @@ -43,6 +43,7 @@ initialArgs = $initialArgsStore $initialArgsStore = undefined } + let flowBuilder: FlowBuilder | undefined = undefined export const flowStore = writable({ summary: '', @@ -56,7 +57,8 @@ }) const flowStateStore = writable({}) - let savedPrimarySchedule: ScheduleTrigger | undefined = undefined + let draftTriggersFromUrl: Trigger[] | undefined = undefined + let selectedTriggerIndexFromUrl: number | undefined = undefined async function loadFlow() { loading = true let flow: Flow = { @@ -101,7 +103,10 @@ flow = state.flow pathStoreInit = state.path - savedPrimarySchedule = state.primarySchedule + draftTriggersFromUrl = state.draft_triggers + selectedTriggerIndexFromUrl = state.selected_trigger + flowBuilder?.setDraftTriggers(draftTriggersFromUrl) + flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl) state?.selectedId && (selectedId = state?.selectedId) } else { if (templatePath) { @@ -153,7 +158,6 @@ loadFlow() let getSelectedId: (() => string) | undefined = undefined - let flowBuilder: FlowBuilder | undefined = undefined let getInitialAndModifiedValues: GetInitialAndModifiedValues | undefined = undefined @@ -181,7 +185,8 @@ {flowStateStore} {selectedId} {loading} - {savedPrimarySchedule} + {draftTriggersFromUrl} + {selectedTriggerIndexFromUrl} > diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 51e0e9d6e3..8001ee0281 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -15,6 +15,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import type { Trigger } from '$lib/components/triggers/utils' let version: undefined | number = undefined let nodraft = $page.url.searchParams.get('nodraft') @@ -60,6 +61,9 @@ let savedPrimarySchedule: ScheduleTrigger | undefined = stateLoadedFromUrl?.primarySchedule + let draftTriggersFromUrl: Trigger[] | undefined = undefined + let selectedTriggerIndexFromUrl: number | undefined = undefined + let flowBuilder: FlowBuilder | undefined = undefined async function loadFlow(): Promise { @@ -82,12 +86,19 @@ }) const draftOrDeployed = cleanValueProperties(savedFlow?.draft || savedFlow) - const urlScript = cleanValueProperties(stateLoadedFromUrl.flow) + const urlScript = cleanValueProperties({ + ...stateLoadedFromUrl.flow, + draft_triggers: stateLoadedFromUrl.draft_triggers + }) flow = stateLoadedFromUrl.flow - savedPrimarySchedule = stateLoadedFromUrl.primarySchedule + draftTriggersFromUrl = stateLoadedFromUrl.draft_triggers + selectedTriggerIndexFromUrl = stateLoadedFromUrl.selected_trigger + flowBuilder?.setDraftTriggers(draftTriggersFromUrl) + flowBuilder?.setSelectedTriggerIndex(selectedTriggerIndexFromUrl) + const selectedId = stateLoadedFromUrl?.selectedId ?? 'settings-metadata' const reloadAction = () => { stateLoadedFromUrl = undefined - goto(`/flows/edit/${statePath}`) + goto(`/flows/edit/${statePath}?selected=${selectedId}`) loadFlow() } if (orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(urlScript)) { @@ -133,15 +144,18 @@ ? { ...structuredClone(flowWithDraft.draft), path: flowWithDraft.draft.path ?? flowWithDraft.path // backward compatibility for old drafts missing path - } + } : undefined } as Flow & { - draft?: Flow + draft?: Flow & { + draft_triggers?: Trigger[] + } } if (flowWithDraft.draft != undefined && !nobackenddraft) { flow = flowWithDraft.draft savedPrimarySchedule = flowWithDraft?.draft?.['primary_schedule'] flowBuilder?.setPrimarySchedule(savedPrimarySchedule) + flowBuilder?.setDraftTriggers(flowWithDraft?.draft?.['draft_triggers']) if (!flowWithDraft.draft_only) { const deployed = cleanValueProperties(flowWithDraft) @@ -178,6 +192,7 @@ } } else { flow = flowWithDraft + flowBuilder?.setDraftTriggers(undefined) } } @@ -200,6 +215,7 @@ return } diffDrawer.closeDrawer() + stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.draft.path}`) loadFlow() } @@ -217,6 +233,7 @@ path: savedFlow.path }) } + stateLoadedFromUrl = undefined goto(`/flows/edit/${savedFlow.path}`) loadFlow() } @@ -251,6 +268,8 @@ bind:savedFlow {diffDrawer} {savedPrimarySchedule} + {draftTriggersFromUrl} + {selectedTriggerIndexFromUrl} bind:version bind:getInitialAndModifiedValues > diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index a20cfd427a..55bf1d5d1c 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -41,35 +41,26 @@ } from 'lucide-svelte' import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' - import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte' - import CliHelpBox from '$lib/components/CliHelpBox.svelte' import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' - import RunPageSchedules from '$lib/components/RunPageSchedules.svelte' import { createAppFromFlow } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' import TimeAgo from '$lib/components/TimeAgo.svelte' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' import FlowGraphViewerStep from '$lib/components/FlowGraphViewerStep.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' - import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte' import Star from '$lib/components/Star.svelte' - import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte' import { Highlight } from 'svelte-highlight' import json from 'svelte-highlight/languages/json' import { writable } from 'svelte/store' - import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' - import WebsocketTriggersPanel from '$lib/components/triggers/websocket/WebsocketTriggersPanel.svelte' - import KafkaTriggersPanel from '$lib/components/triggers/kafka/KafkaTriggersPanel.svelte' - import NatsTriggersPanel from '$lib/components/triggers/nats/NatsTriggersPanel.svelte' - import PostgresTriggersPanel from '$lib/components/triggers/postgres/PostgresTriggersPanel.svelte' import Toggle from '$lib/components/Toggle.svelte' - import MqttTriggersPanel from '$lib/components/triggers/mqtt/MqttTriggersPanel.svelte' - import SqsTriggerPanel from '$lib/components/triggers/sqs/SqsTriggerPanel.svelte' - import { onDestroy } from 'svelte' + import { onDestroy, tick } from 'svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import GcpTriggerPanel from '$lib/components/triggers/gcp/GcpTriggerPanel.svelte' + import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' + import type { TriggerContext } from '$lib/components/triggers' + import { setContext } from 'svelte' + import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' + import { Triggers } from '$lib/components/triggers/triggers.svelte' let flow: Flow | undefined let can_write = false @@ -85,9 +76,29 @@ let intervalId: NodeJS.Timeout | undefined = undefined + $: { + const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli') + if (cliTrigger) { + cliTrigger.extra = { + cliCommand: `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'` + } + } + } + const triggersCount = writable(undefined) - $: cliCommand = `wmill flow run ${flow?.path} -d '${JSON.stringify(args)}'` + // Add triggers context store + const triggersState = new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'email', path: '', isDraft: false }, + { type: 'cli', path: '', isDraft: false } + ]) + setContext('TriggerContext', { + triggersCount, + simplifiedPoll: writable(false), + showCaptureHint: writable(undefined), + triggersState + }) let previousPath: string | undefined = undefined $: { @@ -96,6 +107,7 @@ previousPath = path loadFlow() loadTriggersCount() + loadTriggers() } } } @@ -124,6 +136,17 @@ }) } + async function loadTriggers(): Promise { + await triggersState.fetchTriggers( + triggersCount, + $workspaceStore, + path, + true, + undefined, + $userStore + ) + } + async function loadFlow(): Promise { flow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, @@ -371,7 +394,7 @@ } } let stepDetail: FlowModule | string | undefined = undefined - let token = 'TOKEN_TO_CREATE' + let rightPaneSelected = 'saved_inputs' let savedInputsV2: SavedInputsV2 | undefined = undefined let flowHistory: FlowHistory | undefined = undefined @@ -398,7 +421,6 @@ {#if flow} { + onSelect={async (triggerIndex: number) => { rightPaneSelected = 'triggers' + await tick() + triggersState.selectedTriggerIndex = triggerIndex }} + small={false} /> {#if $workspaceStore} @@ -590,89 +616,23 @@
- -
- -
-
- -
- -
-
- -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- -
- -
-
- - -
- -
-
- - -
- -
-
- - -
- -
-
- -
- -
-
- -
- - -
-
- {#if stepDetail} {/if} + + + {/if} diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 9fcaa0f202..2fd36a1cc1 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -263,7 +263,7 @@ - + - + - + - + - + - + - + { @@ -122,7 +120,6 @@ searchParams={$page.url.searchParams} {script} {showMeta} - {savedPrimarySchedule} replaceStateFn={(path) => replaceState(path, $page.state)} > diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index fbe85f83c0..803b29bce8 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -12,6 +12,7 @@ import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' import type { ScheduleTrigger } from '$lib/components/triggers' import type { GetInitialAndModifiedValues } from '$lib/components/common/confirmationModal/unsavedTypes' + import type { Trigger } from '$lib/components/triggers/utils' let initialState = window.location.hash != '' ? window.location.hash.slice(1) : undefined let initialArgs = {} @@ -26,7 +27,8 @@ let scriptLoadedFromUrl = initialState != undefined ? decodeState(initialState) : undefined - let script: NewScript | undefined = undefined + + let script: (NewScript & { draft_triggers?: Trigger[] }) | undefined = undefined let initialPath: string = '' @@ -37,7 +39,7 @@ let savedScript: NewScriptWithDraft | undefined = undefined let fullyLoaded = false - let savedPrimarySchedule: ScheduleTrigger | undefined = scriptLoadedFromUrl?.primarySchedule + let savedPrimarySchedule: ScheduleTrigger | undefined = undefined async function loadScript(): Promise { fullyLoaded = false @@ -98,10 +100,12 @@ savedScript = structuredClone(scriptWithDraft) if (scriptWithDraft.draft != undefined) { script = scriptWithDraft.draft + scriptBuilder?.setDraftTriggers(script.draft_triggers) if (script['primary_schedule']) { savedPrimarySchedule = script['primary_schedule'] scriptBuilder?.setPrimarySchedule(savedPrimarySchedule) } + if (!scriptWithDraft.draft_only) { reloadAction = async () => { scriptLoadedFromUrl = undefined @@ -153,6 +157,7 @@ if (script) { initialPath = script.path + scriptBuilder?.setDraftTriggers(script.draft_triggers) scriptBuilder?.setCode(script.content) if (topHash) { script.parent_hash = topHash @@ -176,6 +181,7 @@ } diffDrawer.closeDrawer() goto(`/scripts/edit/${savedScript.draft.path}`) + scriptLoadedFromUrl = undefined loadScript() } @@ -193,6 +199,7 @@ }) } goto(`/scripts/edit/${savedScript.path}`) + scriptLoadedFromUrl = undefined loadScript() } diff --git a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte index 0a7d41a4dc..dd4f7fa15f 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/get/[...hash]/+page.svelte @@ -21,7 +21,7 @@ import { enterpriseLicense, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' import { isDeployable, ALL_DEPLOYABLE } from '$lib/utils_deployable' - import { onDestroy } from 'svelte' + import { onDestroy, setContext, tick } from 'svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' import { Tabs, @@ -42,10 +42,8 @@ import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte' - import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte' import DetailPageLayout from '$lib/components/details/DetailPageLayout.svelte' import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' - import CliHelpBox from '$lib/components/CliHelpBox.svelte' import { Activity, Archive, @@ -68,30 +66,22 @@ import { scriptToHubUrl } from '$lib/hub' import SharedBadge from '$lib/components/SharedBadge.svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' - import RunPageSchedules from '$lib/components/RunPageSchedules.svelte' import { createAppFromScript } from '$lib/components/details/createAppFromScript' import { importStore } from '$lib/components/apps/store' import TimeAgo from '$lib/components/TimeAgo.svelte' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' import PersistentScriptDrawer from '$lib/components/PersistentScriptDrawer.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' - import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte' import Star from '$lib/components/Star.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte' import { Highlight } from 'svelte-highlight' import json from 'svelte-highlight/languages/json' import { writable } from 'svelte/store' - import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' - import WebsocketTriggersPanel from '$lib/components/triggers/websocket/WebsocketTriggersPanel.svelte' - import KafkaTriggersPanel from '$lib/components/triggers/kafka/KafkaTriggersPanel.svelte' - import NatsTriggersPanel from '$lib/components/triggers/nats/NatsTriggersPanel.svelte' - import PostgresTriggersPanel from '$lib/components/triggers/postgres/PostgresTriggersPanel.svelte' import Toggle from '$lib/components/Toggle.svelte' import InputSelectedBadge from '$lib/components/schema/InputSelectedBadge.svelte' - import MqttTriggersPanel from '$lib/components/triggers/mqtt/MqttTriggersPanel.svelte' - import SqsTriggerPanel from '$lib/components/triggers/sqs/SqsTriggerPanel.svelte' - import GcpTriggerPanel from '$lib/components/triggers/gcp/GcpTriggerPanel.svelte' + import type { TriggerContext } from '$lib/components/triggers' + import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' + import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' + import { Triggers } from '$lib/components/triggers/triggers.svelte' let script: Script | undefined let topHash: string | undefined @@ -107,7 +97,14 @@ let inputSelected: 'saved' | 'history' | undefined = undefined let jsonView = false - $: cliCommand = `wmill script run ${script?.path} -d '${JSON.stringify(args)}'` + $: { + const cliTrigger = triggersState.triggers.find((t) => t.type === 'cli') + if (cliTrigger) { + cliTrigger.extra = { + cliCommand: `wmill script run ${script?.path} -d '${JSON.stringify(args)}'` + } + } + } $: loading = !script @@ -121,6 +118,19 @@ const triggersCount = writable(undefined) + // Add triggers context store + const triggersState = new Triggers([ + { type: 'webhook', path: '', isDraft: false }, + { type: 'email', path: '', isDraft: false }, + { type: 'cli', path: '', isDraft: false } + ]) + setContext('TriggerContext', { + triggersCount, + simplifiedPoll: writable(false), + showCaptureHint: writable(undefined), + triggersState + }) + async function deleteScript(hash: string): Promise { try { await ScriptService.deleteScriptByHash({ workspace: $workspaceStore!, hash }) @@ -167,11 +177,15 @@ } let starred: boolean | undefined = undefined - async function loadTriggersCount(path: string) { - $triggersCount = await ScriptService.getTriggersCountOfScript({ - workspace: $workspaceStore!, - path: path - }) + async function loadTriggers(path: string): Promise { + await triggersState.fetchTriggers( + triggersCount, + $workspaceStore, + path, + false, + undefined, + $userStore + ) } async function loadScript(hash: string): Promise { @@ -194,7 +208,7 @@ can_write = script.workspace_id == $workspaceStore && canWrite(script.path, script.extra_perms!, $userStore) - loadTriggersCount(script.path) + loadTriggers(script.path) if (script.path && script.archived) { const script_by_path = await ScriptService.getScriptByPath({ @@ -497,7 +511,6 @@ } } - let token = 'TOKEN_TO_CREATE' let rightPaneSelected = 'saved_inputs' let savedInputsV2: SavedInputsV2 | undefined = undefined @@ -533,11 +546,7 @@ {#key script.hash} - + { + rightPaneSelected = 'triggers' + }} > { - rightPaneSelected = 'triggers' + selected={rightPaneSelected === 'triggers'} + onSelect={async (triggerIndex: number) => { + if (rightPaneSelected !== 'triggers') { + rightPaneSelected = 'triggers' + } + await tick() + triggersState.selectedTriggerIndex = triggerIndex }} /> @@ -740,76 +757,18 @@ /> {/if} - -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
+ +
@@ -878,12 +837,6 @@ {/if} - -
- - -
-
{/key} {/if} diff --git a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte index b5a5522652..f117702fba 100644 --- a/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sqs_triggers/+page.svelte @@ -220,7 +220,7 @@ - + - + >({}) const selectedIdStore = writable('settings-metadata') - const primaryScheduleStore = writable(undefined) const triggersCount = writable(undefined) - const selectedTriggerStore = writable< - 'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll' - >('webhooks') setContext('TriggerContext', { - primarySchedule: primaryScheduleStore, - selectedTrigger: selectedTriggerStore, triggersCount: triggersCount, simplifiedPoll: writable(false), - defaultValues: writable(undefined), - captureOn: writable(undefined), - showCaptureHint: writable(undefined) + showCaptureHint: writable(undefined), + triggersState: new Triggers() }) setContext('FlowEditorContext', {