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 58f0df9985.

* 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 <dieriba.pro@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Guilhem
2025-05-21 23:45:19 +02:00
committed by GitHub
co-authored by dieriba Ruben Fiszel
parent b7812bfd09
commit ea74f584b1
143 changed files with 9986 additions and 6872 deletions
+13
View File
@@ -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?;
@@ -1,8 +1,10 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
export let link: string | undefined = undefined
</script>
<div class="text-xs text-tertiary font-normal">
<div class={twMerge('text-xs text-tertiary font-normal opacity-80', $$props.class)}>
<slot />
{#if link}
<a href={link} target="_blank">Learn more</a>
+4 -10
View File
@@ -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<Record<string, any>>({})
const selectedIdStore = writable('settings-metadata')
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('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>('FlowEditorContext', {
selectedId: selectedIdStore,
@@ -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
@@ -26,7 +26,7 @@
}
</script>
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1')}>
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1 items-center')}>
{#if customUi?.disableDownload !== true}
<a
download="{filename ?? 'result'}.json"
+13 -6
View File
@@ -25,6 +25,8 @@
export let fixedHeight = true
export let hidePopup = false
export let open = false
export let customWidth: number | undefined = undefined
export let customMenu = false
const {
elements: { menu, item, trigger },
@@ -106,11 +108,16 @@
</button>
{#if open && !hidePopup}
<div use:melt={$menu} data-menu class="z-[6000]">
<div
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
>
<DropdownV2Inner items={computeItems} meltItem={item} />
</div>
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
{#if customMenu}
<slot name="menu" />
{:else}
<div
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
style={customWidth ? `width: ${customWidth}px` : ''}
>
<DropdownV2Inner items={computeItems} meltItem={item} />
</div>
{/if}
</div>
{/if}
@@ -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<Item[]>) = []
export let meltItem: MenubarMenuElements['item']
let computedItems: Item[] | undefined = undefined
interface Props {
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
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}
<svelte:component this={item.icon} size={14} color={item.iconColor} />
<item.icon size={14} color={item.iconColor} />
{/if}
{item.displayName}
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
{item.displayName}
</p>
{@render item.extra?.()}
</MenuItem>
{/each}
</div>
+164 -161
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import {
FlowService,
ScheduleService,
type Flow,
type FlowModule,
DraftService,
@@ -19,12 +18,12 @@
enterpriseLicense,
tutorialsToDo,
userStore,
workspaceStore
workspaceStore,
usedTriggerKinds
} from '$lib/stores'
import {
cleanValueProperties,
encodeState,
formatCron,
generateRandomString,
orderedJsonStringify,
replaceFalseWithUndefined,
@@ -76,6 +75,14 @@
import { type TriggerContext, type ScheduleTrigger } from './triggers'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import DeployButton from './DeployButton.svelte'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import {
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
export let initialPath: string = ''
export let pathStoreInit: string | undefined = undefined
@@ -85,18 +92,16 @@
export let loading = false
export let flowStore: Writable<OpenFlow>
export let flowStateStore: Writable<FlowState>
export let savedFlow:
| (Flow & {
draft?: Flow | undefined
})
| undefined = undefined
export let savedFlow: FlowWithDraftAndDraftTriggers | undefined = undefined
export let diffDrawer: DiffDrawer | undefined = undefined
export let customUi: FlowBuilderWhitelabelCustomUi = {}
export let disableAi: boolean = false
export let disabledFlowInputs = false
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined // used to set the primary schedule in the legacy primaryScheduleStore
export let version: number | undefined = undefined
export let setSavedraftCb: ((cb: () => void) => void) | undefined = undefined
export let draftTriggersFromUrl: Trigger[] | undefined = undefined
export let selectedTriggerIndexFromUrl: number | undefined = undefined
let initialPathStore = writable(initialPath)
$: initialPathStore.set(initialPath)
@@ -116,12 +121,26 @@
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
let open: boolean = false // Is confirmation modal open
// Draft triggers confirmation modal
let draftTriggersModalOpen = false
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
$: setContext('customUi', customUi)
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedFlow,
modifiedValue: $flowStore
modifiedValue: {
...$flowStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
}
}
let onLatest = true
@@ -148,42 +167,25 @@
const dispatch = createEventDispatcher()
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
const triggersCount = writable<TriggersCount | undefined>(undefined)
const simplifiedPoll = writable(false)
// used to set the primary schedule in the legacy primaryScheduleStore
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
async function createSchedule(path: string) {
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: true,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
}
} else {
sendUserToast('The primary schedule could not be created: no schedule data', true)
}
export function setSelectedTriggerIndex(index: number | undefined) {
triggersState.selectedTriggerIndex = index
}
let loadingSave = false
@@ -195,7 +197,12 @@
}
if (savedFlow) {
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
const current = cleanValueProperties({ ...$flowStore, path: $pathStore })
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({
...$flowStore,
path: $pathStore,
draft_triggers: currentDraftTriggers
})
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
@@ -259,7 +266,7 @@
value: {
...flow,
path: $pathStore,
primary_schedule: $primaryScheduleStore
draft_triggers: triggersState.getDraftTriggersSnapshot()
}
}
})
@@ -270,15 +277,14 @@
...structuredClone($flowStore),
path: $pathStore,
draft_only: true
}
}
: savedFlow),
draft: {
...structuredClone($flowStore),
path: $pathStore
path: $pathStore,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
} as Flow & {
draft?: Flow
}
} as FlowWithDraftAndDraftTriggers
let savedAtNewPath = false
if (newFlow) {
@@ -351,7 +357,19 @@
deployedBy = flow.edited_by
}
async function saveFlow(deploymentMsg?: string): Promise<void> {
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await saveFlow(deploymentMsg, triggersToDeploy)
}
return
}
}
loadingSave = true
try {
const flow = cleanInputs($flowStore)
@@ -390,8 +408,15 @@
},
runnableKind: 'flow'
})
if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule($pathStore)
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
$pathStore,
true
)
}
} else {
try {
@@ -400,51 +425,14 @@
console.error('error interacting with local storage', e)
}
const scheduleExists = await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: initialPath
})
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path: initialPath
})
if ($primaryScheduleStore) {
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: initialPath,
requestBody: { enabled }
})
}
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: $pathStore
})
}
} else if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
await createSchedule(initialPath)
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
initialPath
)
}
await FlowService.updateFlow({
@@ -465,10 +453,15 @@
}
})
}
const { draft_triggers: _, ...newSavedFlow } = $flowStore as OpenFlow & {
draft_triggers: Trigger[]
}
savedFlow = {
...structuredClone($flowStore),
...structuredClone(newSavedFlow),
path: $pathStore
} as Flow
triggersState.setTriggers([])
loadingSave = false
dispatch('deploy', $pathStore)
} catch (err) {
@@ -496,7 +489,8 @@
flow: $flowStore,
path: $pathStore,
selectedId: $selectedIdStore,
primarySchedule: $primaryScheduleStore
draft_triggers: triggersState.getDraftTriggersSnapshot(),
selected_trigger: triggersState.getSelectedTriggerSnapshot()
})
)
} catch (err) {
@@ -506,16 +500,6 @@
}
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
const selectedTriggerStore = writable<
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
>('webhooks')
export function getSelectedId() {
return $selectedIdStore
@@ -541,20 +525,6 @@
selectedIdStore.set(selectedId)
}
function selectTrigger(
selectedTrigger:
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
) {
selectedTriggerStore.set(selectedTrigger)
}
let insertButtonOpen = writable<boolean>(false)
setContext<FlowEditorContext>('FlowEditorContext', {
@@ -577,29 +547,42 @@
flowInputEditorState: flowInputEditorStateStore
})
// Add triggers context store
const triggersState = new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
],
selectedTriggerIndexFromUrl,
saveSessionDraft
)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
defaultValues: writable(undefined),
captureOn,
showCaptureHint
showCaptureHint,
triggersState
})
async function loadTriggers() {
export async function loadTriggers() {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path: initialPath
})
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
// Initialize triggers using utility function
await triggersState.fetchTriggers(
triggersCount,
$workspaceStore,
initialPath,
true,
$primaryScheduleStore,
$userStore
)
if (savedFlow && savedFlow.draft) {
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
}
}
@@ -871,16 +854,6 @@
}
}
if (module.type === 'trigger') {
$primaryScheduleStore = {
summary: 'Scheduled poll of flow',
args: {},
cron: '0 */15 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
}
const flowModule: FlowModule & {
value: RawScript | PathScript
} = {
@@ -890,7 +863,7 @@
? {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
skip_if_stopped: true
}
}
: undefined,
value: {
input_transforms: {},
@@ -974,7 +947,7 @@
pastModule?.value.type === 'rawscript' || pastModule?.value.type === 'script'
? (pastModule as FlowModule & {
value: RawScript | PathScript
})
})
: undefined,
isFirstInLoop,
abortController
@@ -1094,8 +1067,8 @@
? isFirstInLoop
? 'flow_input.iter.value'
: pastModule
? 'results.' + pastModule.id
: 'flow_input.' + snakeKey
? 'results.' + pastModule.id
: 'flow_input.' + snakeKey
: 'flow_input.' + snakeKey
}
$shouldUpdatePropertyType[key] = 'javascript'
@@ -1228,7 +1201,7 @@
},
disabled: newFlow
}
]
]
: []),
...(customUi?.topBar?.history != false
? [
@@ -1242,11 +1215,23 @@
icon: FileJson,
action: () => yamlEditorDrawer?.openDrawer()
}
]
]
: [])
]
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved flow to remove the draft trigger that is deployed
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedFlow.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
let flowPreviewButtons: FlowPreviewButtons
</script>
@@ -1263,6 +1248,16 @@
currentValue={$flowStore}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
isFlow={true}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#key renderCount}
{#if !$userStore?.operator}
<FlowCopilotDrawer {getHubCompletions} {genFlow} bind:flowCopilotMode />
@@ -1332,7 +1327,9 @@
</div>
<div class="gap-4 flex-row hidden md:flex w-full max-w-md">
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1341,14 +1338,15 @@
size="xs"
on:click={async () => {
select('triggers')
selectTrigger('schedules')
const selected = primaryScheduleIndex ?? scheduleIndex
if (selected) {
triggersState.selectedTriggerIndex = selected
}
}}
>
{$primaryScheduleStore != undefined
? $primaryScheduleStore
? $primaryScheduleStore?.cron
: ''
: $triggersCount?.primary_schedule?.schedule}
{triggersState.triggers[primaryScheduleIndex]?.draftConfig?.schedule ??
triggersState.triggers[primaryScheduleIndex]?.lightConfig?.schedule ??
''}
</Button>
{/if}
@@ -1409,12 +1407,16 @@
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedFlow,
draft: savedFlow['draft'],
current: { ...$flowStore, path: $pathStore }
draft: savedFlow?.draft,
current: { ...$flowStore, path: $pathStore, draft_triggers: currentDraftTriggers }
})
}}
disabled={!savedFlow}
@@ -1437,7 +1439,7 @@
<FlowPreviewButtons
on:openTriggers={(e) => {
select('triggers')
selectTrigger(e.detail.kind)
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
captureOn.set(true)
showCaptureHint.set(true)
}}
@@ -1489,6 +1491,7 @@
flowPreviewButtons?.openPreview(true)
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
+4 -2
View File
@@ -7,13 +7,15 @@
export let disabled = false
export let headless = false
export let required = false
export let headerClass = ''
</script>
<div class={twMerge(disabled ? 'opacity-60 pointer-events-none' : '', $$props.class)}>
<div class="flex flex-row justify-between items-center w-full">
{#if !headless}
<div class="flex flex-row items-center gap-2">
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6"
<div class={twMerge('flex flex-row items-center gap-2', headerClass)}>
<span
class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6 whitespace-nowrap"
>{label}
{#if required}
<Required required={true} />
+29 -17
View File
@@ -35,6 +35,7 @@
import { random_adj } from './random_positive_adjetive'
import { Eye, Folder, Loader2, Plus, SearchCode, User } from 'lucide-svelte'
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
type PathKind =
| 'resource'
@@ -64,6 +65,7 @@
export let dirty = false
export let kind: PathKind
export let hideUser: boolean = false
export let disableEditing = false
let inputP: HTMLInputElement | undefined = undefined
@@ -300,7 +302,8 @@
}
}
function initPath() {
async function initPath() {
await tick()
if (path != undefined && path != '') {
meta = pathToMeta(path, hideUser)
onMetaChange()
@@ -425,11 +428,12 @@
}
}
}}
disabled={disabled || disableEditing}
let:item
>
<ToggleButton
icon={User}
{disabled}
disabled={disabled || disableEditing}
light
size="xs"
value="user"
@@ -440,7 +444,7 @@
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
<ToggleButton
icon={Folder}
{disabled}
disabled={disabled || disableEditing}
light
size="xs"
value="folder"
@@ -462,14 +466,20 @@
type="text"
bind:value={meta.owner}
placeholder={$userStore?.username ?? ''}
disabled={disabled || !($superadmin || ($userStore?.is_admin ?? false))}
disabled={disabled ||
!($superadmin || ($userStore?.is_admin ?? false)) ||
disableEditing}
on:keydown={setDirty}
/>
</label>
{:else if meta.ownerKind === 'folder'}
<label class="block grow w-48">
<div class="flex flex-row items-center gap-1 w-full">
<select class="grow w-full" {disabled} bind:value={meta.owner}>
<select
class="grow w-full"
disabled={disabled || disableEditing}
bind:value={meta.owner}
>
{#if folders?.length == 0}
<option disabled>No folders</option>
{/if}
@@ -488,17 +498,19 @@
iconOnly
startIcon={{ icon: Eye }}
/>
<Button
title="New folder"
btnClasses="!p-1.5"
variant="border"
color="light"
size="xs"
{disabled}
on:click={newFolder.openDrawer}
iconOnly
startIcon={{ icon: Plus }}
/>
{#if !disableEditing}
<Button
title="New folder"
btnClasses="!p-1.5"
variant="border"
color="light"
size="xs"
{disabled}
on:click={newFolder.openDrawer}
iconOnly
startIcon={{ icon: Plus }}
/>
{/if}
</div>
</label>
{/if}
@@ -507,7 +519,7 @@
<label class="block grow w-full max-w-md">
<!-- svelte-ignore a11y-autofocus -->
<input
{disabled}
disabled={disabled || disableEditing}
type="text"
id="path"
{autofocus}
@@ -1,86 +0,0 @@
<script lang="ts">
import { Badge, Button } from './common'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import Toggle from './Toggle.svelte'
import { ListOrdered, PenBox } from 'lucide-svelte'
import JobArgs from './JobArgs.svelte'
import Tooltip from './Tooltip.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import { type Schedule } from '$lib/gen'
export let schedule: any
export let can_write: boolean
export let path: string
export let isFlow: boolean
export let scheduleEditor: ScheduleEditor
export let setScheduleEnabled: (path: string, enabled: boolean) => void
$: schedule = typeof schedule === 'boolean' ? undefined : (schedule as Schedule | undefined)
</script>
<div class="flex flex-col gap-2 grow w-full">
<div class="grid grid-cols-3 w-full">
<div class="flex justify-start">
<Badge color="indigo" small>
Primary&nbsp;
<Tooltip light>
Share the same path as the script or flow it is attached to and its path get renamed
whenever the source path is renamed
</Tooltip>
</Badge>
</div>
<div class="flex justify-center">
<div class="flex flex-row gap-2 items-center">
<input
size="9"
class="!text-xs !h-6 !text-primary"
type="text"
id="cron-schedule"
name="cron-schedule"
placeholder="*/30 * * * *"
value={schedule?.schedule ?? ''}
disabled={true}
/>
<Toggle
checked={schedule?.enabled ?? false}
on:change={(e) => {
if (can_write) {
setScheduleEnabled(path, e.detail)
} else {
sendUserToast('not enough permission', true)
}
}}
options={{
right: 'On'
}}
size="xs"
textClass="text-primary font-normal text-xs"
/>
</div>
</div>
<div class="flex justify-end">
<div class="flex flex-row gap-2 items-center">
<Button size={'xs'} variant="border" color="light" href={`${base}/runs/${path}`}>
<span>Runs</span>
<ListOrdered size={14} />
</Button>
<Button
size="xs"
color="dark"
on:click={() => scheduleEditor?.openEdit(path ?? '', isFlow)}
>
<PenBox size={14} />
</Button>
</div>
</div>
</div>
{#if Object.keys(schedule?.args ?? {}).length > 0}
<div class="">
<JobArgs args={schedule?.args ?? {}} />
</div>
{:else}
<div class="text-xs text-tertiary"> No arguments </div>
{/if}
</div>
@@ -1,245 +0,0 @@
<script lang="ts">
import { Button } from './common'
import { workspaceStore } from '$lib/stores'
import { ScheduleService, type Schedule } from '$lib/gen'
import { Calendar, Trash, Save } from 'lucide-svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import Label from '$lib/components/Label.svelte'
import { getContext } from 'svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import CronInput from './CronInput.svelte'
import SchemaForm from './SchemaForm.svelte'
import { emptyString, sendUserToast } from '$lib/utils'
import Toggle from './Toggle.svelte'
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
import { type Writable, writable } from 'svelte/store'
import Description from '$lib/components/Description.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
export let schema: any
export let isFlow: boolean
export let path: string
export let can_write: boolean
export let newItem: boolean = false
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
let scheduleEditor: ScheduleEditor
let schedules: Writable<Schedule[] | undefined> = writable(undefined)
let initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined> = writable(undefined)
async function updateSchedules(forceRefresh: boolean) {
const loadPrimarySchedule = true
loadSchedules(
forceRefresh,
path,
isFlow,
schedules,
primarySchedule,
initialPrimarySchedule,
$workspaceStore ?? '',
triggersCount,
loadPrimarySchedule
)
}
$: updateSchedules(false) || path
async function save() {
await saveSchedule(path, newItem, $workspaceStore ?? '', primarySchedule, isFlow)
updateSchedules(true)
}
</script>
<div class="flex flex-col gap-4 w-full">
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
Run scripts and flows automatically on a recurring basis using cron expressions. Each script or
flow can have multiple schedules, with one designated as primary.
</Description>
<ScheduleEditor
on:update={() => {
updateSchedules(true)
}}
bind:this={scheduleEditor}
/>
{#if $primarySchedule == undefined}
<Skeleton layout={[[12]]} />
{:else if $primarySchedule}
<div class="w-full flex flex-col mb-4">
{#if can_write}
<div class="w-full flex-row-reverse flex mb-2">
<div class="flex flex-row gap-4">
<Button
on:click={() => {
$primarySchedule = false
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 1) - 1,
primary_schedule: undefined
}
}}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Trash }}
/>
{#if initialPrimarySchedule && !newItem}
<Toggle
disabled={emptyString($primarySchedule.cron)}
bind:checked={$primarySchedule.enabled}
options={{
right: 'Enabled'
}}
on:change={async (e) => {
if (!newItem && $initialPrimarySchedule != false) {
await ScheduleService.setScheduleEnabled({
path: path,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${path}`)
}
}}
/>
{/if}
{#if !newItem}
<Button
on:click={save}
color="dark"
size="sm"
startIcon={{ icon: Save }}
disabled={JSON.stringify({ ...$primarySchedule, enabled: true }) ==
JSON.stringify({ ...initialPrimarySchedule, enabled: true })}
>Apply changes now</Button
>
{:else}
<div class="text-sm text-secondary mt-1 text-center"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
</div>
{/if}
<!-- svelte-ignore a11y-autofocus -->
<div class="mt-5">
<Label label="Summary" class="font-semibold" primary>
<input
autofocus
type="text"
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
bind:value={$primarySchedule.summary}
/>
</Label>
</div>
</div>
<CronInput bind:schedule={$primarySchedule.cron} bind:timezone={$primarySchedule.timezone} />
<SchemaForm onlyMaskPassword {schema} bind:args={$primarySchedule.args} />
{#if emptyString($primarySchedule.cron)}
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
{/if}
{#if $initialPrimarySchedule != false && !newItem}
<div class="flex">
<Button size="xs" color="light" on:click={() => scheduleEditor?.openEdit(path, isFlow)}
>Advanced</Button
>
</div>
{/if}
{:else}
<div class="flex flex-row gap-4 mt-2">
<div class="flex items-center">
<Button
on:click={() => {
$primarySchedule = {
summary: '',
args: {},
cron: '0 0 */1 * * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
}
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: $primarySchedule.cron }
}
}}
variant="contained"
color="dark"
size="sm"
startIcon={{ icon: Calendar }}
>
Set primary schedule
</Button>
</div>
{#if $initialPrimarySchedule != undefined && $initialPrimarySchedule != false && !newItem}
<Button on:click={save} color="dark" size="md" startIcon={{ icon: Save }}>
Apply changes now
</Button>
{:else}
<div class="text-sm text-center text-secondary mt-2"
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
>
{/if}
</div>
<Label label="Summary" class="font-semibold" primary>
<input
type="text"
disabled
placeholder="Short summary to be displayed when listed"
class="text-sm w-full"
/>
</Label>
<CronInput schedule={''} disabled timezone={Intl.DateTimeFormat().resolvedOptions().timeZone} />
<SchemaForm disabled {schema} />
{/if}
{#if !newItem}
<div class="mt-10"></div>
{#if $primarySchedule}
<Button
on:click={() => scheduleEditor?.openNew(isFlow, path)}
variant="border"
color="light"
size="xs"
startIcon={{ icon: Calendar }}
>
New schedule
</Button>
{/if}
<Label label="Other schedules">
{#if $schedules}
{#if $schedules.length == 0 || $schedules == undefined}
<div class="text-xs text-tertiary"> No other schedules </div>
{:else}
<div class="flex flex-col divide-y">
{#each $schedules as schedule (schedule.path)}
<div class="grid grid-cols-6 text-xs items-center py-2">
<div class="col-span-3 truncate">{schedule.path}</div>
<div class="col-span-2 flex flex-row gap-4 flex-nowrap">
<div>{schedule.schedule}</div>
<div>{schedule.enabled ? 'on' : 'off'}</div>
</div>
<div class="flex justify-end">
<button
on:click={() => scheduleEditor?.openEdit(schedule.path, isFlow)}
class="px-2">Edit</button
>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<Skeleton layout={[[8]]} />
{/if}
</Label>
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import { onMount } from 'svelte'
let scheduleEditor = $state<ScheduleEditorInner | null>(null)
let {
selectedTrigger,
isFlow,
path,
defaultValues = undefined,
schema,
customLabel = undefined,
...restProps
} = $props()
function openScheduleEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
scheduleEditor?.openNew(isFlow, path, defaultValues)
} else {
scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
selectedTrigger?.type === 'schedule' &&
scheduleEditor &&
openScheduleEditor(isFlow, selectedTrigger.isDraft ?? false)
})
</script>
<ScheduleEditorInner
useDrawer={false}
bind:this={scheduleEditor}
hideTarget
allowDraft
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
primary={selectedTrigger.isPrimary}
draftSchema={schema}
{customLabel}
{...restProps}
>
{#snippet docDescription()}
<div class="flex flex-col gap-2 pb-4">
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
Run scripts and flows automatically on a recurring basis using cron expressions.
</Description>
</div>
{/snippet}
</ScheduleEditorInner>
<!-- hideTarget
hidePath
{header} -->
+180 -121
View File
@@ -4,7 +4,6 @@
type NewScript,
ScriptService,
type NewScriptWithDraft,
ScheduleService,
type Script,
type TriggersCount,
PostgresTriggerService,
@@ -12,13 +11,18 @@
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
import { defaultScripts, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import {
defaultScripts,
enterpriseLicense,
usedTriggerKinds,
userStore,
workspaceStore
} from '$lib/stores'
import {
cleanValueProperties,
emptySchema,
emptyString,
encodeState,
formatCron,
generateRandomString,
orderedJsonStringify,
replaceFalseWithUndefined,
@@ -68,7 +72,7 @@
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext, TriggerKind } from './triggers'
import type { ScheduleTrigger, TriggerContext } from './triggers'
import {
TS_PREPROCESSOR_MODULE_CODE,
TS_PREPROCESSOR_SCRIPT_INTRO,
@@ -79,8 +83,17 @@
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import DeployButton from './DeployButton.svelte'
import {
type NewScriptWithDraftAndDraftTriggers,
type Trigger,
deployTriggers,
filterDraftTriggers,
handleSelectTriggerFromKind
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
export let script: NewScript
export let script: NewScript & { draft_triggers?: Trigger[] }
export let fullyLoaded: boolean = true
export let initialPath: string = ''
export let template: 'docker' | 'bunnative' | 'script' = 'script'
@@ -89,7 +102,7 @@
export let showMeta: boolean = false
export let neverShowMeta: boolean = false
export let diffDrawer: DiffDrawer | undefined = undefined
export let savedScript: NewScriptWithDraft | undefined = undefined
export let savedScript: NewScriptWithDraftAndDraftTriggers | undefined = undefined
export let searchParams: URLSearchParams = new URLSearchParams()
export let disableHistoryChange = false
export let replaceStateFn: (url: string) => void = (url) =>
@@ -102,7 +115,10 @@
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
savedValue: savedScript,
modifiedValue: script
modifiedValue: {
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
}
}
}
@@ -134,20 +150,38 @@
let scriptEditor: ScriptEditor | undefined = undefined
let captureTable: CaptureTable | undefined = undefined
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
// Draft triggers confirmation modal
let draftTriggersModalOpen = false
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
const { selectedTriggers } = event.detail
// Continue with saving the flow
draftTriggersModalOpen = false
confirmDeploymentCallback(selectedTriggers)
}
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // keep for legacy
const triggersCount = writable<TriggersCount | undefined>(
savedPrimarySchedule
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
: undefined
)
const simplifiedPoll = writable(false)
const selectedTriggerStore = writable<TriggerKind>('webhooks')
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
primaryScheduleStore.set(schedule)
loadTriggers()
}
export function setDraftTriggers(triggers: Trigger[] | undefined) {
triggersState.setTriggers([
...(triggers ?? []),
...triggersState.triggers.filter((t) => !t.draftConfig)
])
loadTriggers()
}
const dispatch = createEventDispatcher()
$: initialPath != '' && loadTriggers()
@@ -176,29 +210,42 @@
workspace: $workspaceStore!,
path: initialPath
})
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
$triggersCount = {
...($triggersCount ?? {}),
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
primary_schedule: {
schedule: $primaryScheduleStore.cron
}
}
await triggersState.fetchTriggers(
triggersCount,
$workspaceStore,
initialPath,
false,
$primaryScheduleStore,
$userStore
)
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
savedScript = filterDraftTriggers(
savedScript,
triggersState
) as NewScriptWithDraftAndDraftTriggers
}
}
const triggerDefaultValuesStore = writable<Record<string, any> | undefined>(undefined)
// Add triggers context store
const triggersState = new Triggers(
[
{ type: 'webhook', path: '', isDraft: false },
{ type: 'email', path: '', isDraft: false },
...(script.draft_triggers ?? [])
],
undefined,
saveSessionDraft
)
const captureOn = writable<boolean | undefined>(undefined)
const showCaptureHint = writable<boolean | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
defaultValues: triggerDefaultValuesStore,
captureOn: captureOn,
showCaptureHint: showCaptureHint
showCaptureHint: showCaptureHint,
triggersState
})
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql', 'oracledb']
@@ -274,8 +321,26 @@
})
}
$: !disableHistoryChange &&
replaceStateFn('#' + encodeState({ ...script, primarySchedule: $primaryScheduleStore }))
$: !disableHistoryChange && encodeScriptState(script)
function encodeScriptState(script: NewScript) {
replaceStateFn(
'#' +
encodeState({
...script,
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
})
)
}
let timeout: NodeJS.Timeout | undefined = undefined
function saveSessionDraft() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
encodeScriptState(script)
}, 500)
}
if (script.content == '') {
initContent(script.language, script.kind, template)
}
@@ -317,31 +382,6 @@
}
}
async function createSchedule(path: string) {
if (!$primaryScheduleStore) {
return
}
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
try {
await ScheduleService.createSchedule({
workspace: $workspaceStore!,
requestBody: {
path: path,
schedule: formatCron(cron),
timezone,
script_path: path,
is_flow: false,
args,
enabled,
summary
}
})
} catch (err) {
sendUserToast(`The primary schedule could not be created: ${err}`, true)
}
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
@@ -419,8 +459,21 @@
async function editScript(
stay: boolean,
parentHash: string,
deploymentMsg?: string
deploymentMsg?: string,
triggersToDeploy?: Trigger[]
): Promise<void> {
if (!triggersToDeploy) {
// Check if there are draft triggers that need confirmation
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
if (draftTriggers.length > 0) {
draftTriggersModalOpen = true
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
await editScript(stay, parentHash, deploymentMsg, triggersToDeploy)
}
return
}
}
loadingSave = true
try {
try {
@@ -490,55 +543,21 @@
})
}
const scheduleExists =
initialPath != '' &&
(await ScheduleService.existsSchedule({
workspace: $workspaceStore ?? '',
path: script.path
}))
if ($primaryScheduleStore) {
const { enabled, timezone, args, cron, summary } = $primaryScheduleStore
if (scheduleExists) {
const schedule = await ScheduleService.getSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
if (
JSON.stringify(schedule.args) != JSON.stringify(args) ||
schedule.schedule != cron ||
schedule.timezone != timezone ||
schedule.summary != summary
) {
await ScheduleService.updateSchedule({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: {
schedule: formatCron(cron),
timezone,
args,
summary
}
})
}
if (enabled != schedule.enabled) {
await ScheduleService.setScheduleEnabled({
workspace: $workspaceStore ?? '',
path: script.path,
requestBody: { enabled }
})
}
} else if (enabled) {
await createSchedule(script.path)
}
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
await ScheduleService.deleteSchedule({
workspace: $workspaceStore ?? '',
path: script.path
})
if (triggersToDeploy) {
await deployTriggers(
triggersToDeploy,
$workspaceStore,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds,
script.path,
true
)
}
savedScript = structuredClone(script) as NewScriptWithDraft
const { draft_triggers: _, ...newScript } = structuredClone(script)
savedScript = structuredClone(newScript) as NewScriptWithDraft
triggersState.setTriggers([])
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
@@ -562,7 +581,8 @@
if (savedScript) {
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
const current = cleanValueProperties(script)
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
sendUserToast('No changes detected, ignoring', false, [
{
@@ -653,12 +673,16 @@
}
})
}
const draftTriggers = triggersState.getDraftTriggersSnapshot()
await DraftService.createDraft({
workspace: $workspaceStore!,
requestBody: {
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
typ: 'script',
value: { ...script, primary_schedule: $primaryScheduleStore }
value: {
...script,
draft_triggers: draftTriggers
}
}
})
@@ -666,8 +690,11 @@
...(initialPath == '' || savedScript?.draft_only
? { ...structuredClone(script), draft_only: true }
: savedScript),
draft: structuredClone(script)
} as NewScriptWithDraft
draft: {
...structuredClone(script),
draft_triggers: draftTriggers
}
} as NewScriptWithDraftAndDraftTriggers
let savedAtNewPath = false
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
@@ -690,7 +717,7 @@
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraft | undefined,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
diffDrawer: DiffDrawer | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
@@ -718,12 +745,19 @@
}
await syncWithDeployed()
const currentDraftTriggers = structuredClone(
triggersState.getDraftTriggersSnapshot()
)
diffDrawer?.openDrawer()
diffDrawer?.setDiff({
mode: 'normal',
deployed: deployedValue ?? savedScript,
draft: savedScript['draft'],
current: script
current: {
...script,
draft_triggers: currentDraftTriggers
}
})
}
}
@@ -799,8 +833,7 @@
function openTriggers(ev) {
metadataOpen = true
selectedTab = 'triggers'
selectedTriggerStore.set(ev.detail.kind)
triggerDefaultValuesStore.set(ev.detail.config)
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
captureOn.set(true)
}
@@ -824,6 +857,18 @@
}
selectedInputTab = 'preprocessor'
}
function handleDeployTrigger(trigger: Trigger) {
const { id, path, type } = trigger
//Update the saved script to remove the draft trigger that is deployed
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
(t) => t.id !== id || t.path !== path || t.type !== type
)
savedScript.draft.draft_triggers =
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
}
}
</script>
<svelte:window on:keydown={onKeyDown} />
@@ -837,11 +882,21 @@
bind:deployedValue
currentValue={script}
/>
<DraftTriggersConfirmationModal
bind:open={draftTriggersModalOpen}
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
on:canceled={() => {
draftTriggersModalOpen = false
}}
on:confirmed={handleDraftTriggersConfirmed}
/>
{#if !$userStore?.operator}
<Drawer
placement="right"
bind:open={metadataOpen}
size={selectedTab === 'ui' ? '1200px' : '800px'}
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
>
<DrawerContent noPadding title="Settings" on:close={() => (metadataOpen = false)}>
<!-- svelte-ignore a11y-autofocus -->
@@ -1459,27 +1514,30 @@
customUi={customUi?.settingsPanel?.metadata?.editableSchemaForm}
/>
</TabContent>
<TabContent value="triggers">
<TabContent value="triggers" class="h-full">
<TriggersEditor
on:applyArgs={applyArgs}
on:addPreprocessor={addPreprocessor}
on:exitTriggers={() => {
captureTable?.loadCaptures(true)
}}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
currentPath={script.path}
{initialPath}
{fakeInitialPath}
schema={script.schema}
noEditor={true}
isFlow={false}
currentPath={script.path}
hash={script.parent_hash}
newItem={initialPath == ''}
isFlow={false}
{hasPreprocessor}
canHavePreprocessor={script.language === 'bun' ||
script.language === 'deno' ||
script.language === 'python3'}
{hasPreprocessor}
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
isDeployed={savedScript && !savedScript?.draft_only}
schema={script.schema}
hash={script.parent_hash}
onDeployTrigger={handleDeployTrigger}
/>
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
</TabContent>
</div>
@@ -1510,7 +1568,10 @@
</div>
<div class="gap-4 flex">
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
{@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)}
{@const schedule = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
<Button
btnClasses="hidden lg:inline-flex"
startIcon={{ icon: Calendar }}
@@ -1520,14 +1581,12 @@
on:click={async () => {
metadataOpen = true
selectedTab = 'triggers'
$selectedTriggerStore = 'schedules'
triggersState.selectedTriggerIndex = primarySchedule ?? schedule
}}
>
{$primaryScheduleStore != undefined
? $primaryScheduleStore
? $primaryScheduleStore?.cron
: ''
: $triggersCount?.primary_schedule?.schedule}
{triggersState.triggers[primarySchedule]?.draftConfig?.schedule ??
triggersState.triggers[primarySchedule]?.lightConfig?.schedule ??
''}
</Button>
{/if}
{#if customUi?.topBar?.path != false}
@@ -94,7 +94,7 @@
{/if}
{#if disabled}
<input type="text" value={scriptPath ?? ''} disabled />
<input type="text" value={scriptPath ?? initialPath ?? ''} disabled />
{:else}
<Select
value={items?.find((x) => x.value == initialPath)}
+3 -1
View File
@@ -11,6 +11,7 @@
export let eeOnly = false
export let small: boolean = false
export let wrapperClass: string = ''
export let headerClass: string = ''
export let collapsable: boolean = false
export let collapsed: boolean = true
@@ -26,7 +27,8 @@
class={twMerge(
'font-semibold flex flex-row items-center gap-1',
breakAll ? 'break-all' : '',
small ? 'text-sm' : 'text-base'
small ? 'text-sm' : 'text-base',
headerClass
)}
>
{#if collapsable}
+10 -8
View File
@@ -21,7 +21,7 @@
export let lightMode: boolean = false
export let eeOnly: boolean = false
export let size: 'sm' | 'xs' | '2xs' = 'sm'
export let size: 'sm' | 'xs' | '2xs' | '2sm' = 'sm'
const dispatch = createEventDispatcher<{ change: boolean }>()
const bothOptions = Boolean(options.left) && Boolean(options.right)
@@ -40,7 +40,7 @@
class={twMerge(
'mr-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
size === 'xs' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
textClass
)}
style={textStyle}
@@ -73,13 +73,15 @@
color == 'red'
? 'peer-checked:bg-red-600'
: color == 'blue'
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
size === 'sm'
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
: size === '2sm'
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
: size === '2xs'
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
)}
></div>
</div>
@@ -88,7 +90,7 @@
class={twMerge(
'ml-2 font-medium duration-50 select-none',
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
size === 'xs' ? 'text-xs' : 'text-sm',
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
textClass
)}
style={textStyle}
@@ -0,0 +1,29 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import { ExternalLink } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
export let documentationLink: string | undefined = undefined
export let markdownTooltip: string | undefined = undefined
const plugins = [gfmPlugin()]
</script>
<div
class="shadow max-w-sm break-words py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left"
>
{#if markdownTooltip}
<div class="prose-sm">
<Markdown md={markdownTooltip} {plugins} />
</div>
{:else}
<slot />
{/if}
{#if documentationLink}
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
See documentation
<ExternalLink size="16" />
</div>
</a>
{/if}
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
interface Props {
url?: string
disabled?: boolean
label?: string
}
let { url = '', disabled = false, label = '' }: Props = $props()
</script>
<div class="flex justify-start w-full">
<Badge color="gray" class="rounded-r-none h-[27px]">{label}</Badge>
<ClipboardPanel
content={url}
class="rounded-l-none bg-surface border-none outline outline-2 outline-surface-secondary outline-offset-[-2px]"
{disabled}
/>
</div>
@@ -198,6 +198,7 @@
this={startIcon.icon}
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
@@ -246,6 +247,7 @@
this={startIcon.icon}
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
size={lucideIconSize}
{...startIcon.props}
/>
{/if}
@@ -288,12 +290,13 @@
<div
class={twMerge(
buttonClass,
'rounded-md m-0 p-0 !w-10 center-center h-full',
'rounded-md m-0 p-0 center-center h-full',
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
'rounded-r-md !rounded-l-none'
'rounded-r-md !rounded-l-none',
size === 'xs2' ? '!w-8' : '!w-10'
)}
>
<ChevronDown class="w-5 h-5" />
<ChevronDown size={lucideIconSize} />
</div>
</svelte:fragment>
</Dropdown>
@@ -10,6 +10,7 @@ export namespace ButtonType {
icon?: any | undefined
classes?: string
faIcon?: any | undefined
props?: any
}
export const FontSizeClasses: Record<ButtonType.Size, string> = {
@@ -4,6 +4,7 @@
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import { AlertTriangle, CornerDownLeft, Loader2, RefreshCcw } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
type Props = {
title: string
@@ -12,6 +13,7 @@
loading?: boolean
open?: boolean
type?: 'danger' | 'reload'
showIcon?: boolean
}
const {
@@ -20,7 +22,8 @@
keyListen = true,
loading = false,
open = false,
type: _type
type: _type,
showIcon = true
}: Props = $props()
const type = $derived(_type ?? 'danger')
@@ -92,12 +95,14 @@
)}
>
<div class="flex">
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<Icon class={theme[type].classes.icon} />
</div>
<div class="ml-4 text-left flex-1">
{#if showIcon}
<div
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
>
<Icon class={theme[type].classes.icon} />
</div>
{/if}
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
@@ -0,0 +1,178 @@
<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import { createEventDispatcher } from 'svelte'
import type { Trigger } from '$lib/components/triggers/utils'
import DataTable from '$lib/components/table/DataTable.svelte'
import { twMerge } from 'tailwind-merge'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
import { triggerIconMap } from '$lib/components/triggers/utils'
import { Star } from 'lucide-svelte'
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
interface Props {
open?: boolean
draftTriggers?: Trigger[]
isFlow?: boolean
}
let { open = $bindable(false), draftTriggers = [], isFlow = false }: Props = $props()
let selectedTriggers: Trigger[] = $state(draftTriggers)
const dispatch = createEventDispatcher<{
canceled: void
confirmed: { selectedTriggers: Trigger[] }
}>()
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
if (selected === 'discard') {
if (trigger.isDraft) {
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
} else {
selectedTriggers = selectedTriggers.filter(
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
)
}
} else if (!isSelected(selectedTriggers, trigger)) {
selectedTriggers = [...selectedTriggers, trigger]
}
}
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
if (trigger.isDraft) {
return triggers.some((t) => t.id === trigger.id)
} else {
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
}
}
function checkSavePermissions(trigger: Trigger) {
// Creating http trigger is forbidden for non-admin users
const adminOnly =
trigger.type === 'http' &&
!$userStore?.is_admin &&
!$userStore?.is_super_admin &&
trigger.isDraft
const invalidConfig = !trigger.draftConfig?.canSave
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
}
$effect(() => {
open &&
(selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy'))
})
</script>
<ConfirmationModal
{open}
title="Draft triggers detected !"
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
type="reload"
showIcon={false}
on:canceled={() => dispatch('canceled')}
on:confirmed={() => dispatch('confirmed', { selectedTriggers })}
>
<div class="flex flex-col w-full gap-8 pb-4">
<div class="text-secondary text-sm">
{`${isFlow ? 'Your flow' : 'Your script'} has draft triggers. Select which draft triggers to deploy with the ${isFlow ? 'flow' : 'script'}. Undeployed
draft triggers will be permanently deleted.`}
</div>
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
<DataTable size="sm" tableFixed={true}>
<thead>
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
<th class="text-left py-2 px-4">Triggers to deploy</th>
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
</tr>
</thead>
<tbody>
{#each draftTriggers as trigger}
{@const SvelteComponent = triggerIconMap[trigger.type]}
{@const permission = checkSavePermissions(trigger)}
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
<tr
class={twMerge(
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
)}
>
<td class={twMerge('text-center py-1 px-4')}>
<div class="flex flex-row items-center gap-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={14}
class={isSelectedTrigger
? 'text-blue-500'
: 'text-gray-500 dark:text-gray-400'}
/>
{#if trigger.isPrimary}
<Star size={8} class="absolute -mt-3 ml-3 text-blue-400" />
{/if}
</div>
<div class="flex grow min-w-0 items-center text-left">
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
</div>
</div>
</td>
<td class="text-left py-1">
{#if permission === 'deploy'}
<div class="flex justify-start">
<ToggleButtonGroup
let:item
class="w-fit h-fit"
selected={isSelectedTrigger ? 'deploy' : 'discard'}
on:selected={(e) => toggleTrigger(trigger, e.detail)}
>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 w-[54px] justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-marine-400 data-[state=on]:text-white data-[state=on]:dark:bg-marine-50 data-[state=on]:dark:text-primary-inverse w-[54px] justify-center"
/>
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Only admins can deploy http triggers"
>
Admin only
</div>
{:else if permission === 'invalid-config'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Invalid config"
>
Invalid config
</div>
{/if}
</td>
</tr>
{/each}
{#if draftTriggers.length === 0}
<tr>
<td colspan="3" class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
No draft triggers found
</td>
</tr>
{/if}
</tbody>
</DataTable>
</div>
</div>
</ConfirmationModal>
@@ -17,6 +17,7 @@ export { default as Tabs } from './tabs/Tabs.svelte'
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
export { default as FileInput } from './fileInput/FileInput.svelte'
export { default as Section } from '../Section.svelte'
export { default as Url } from './Url.svelte'
export * from './alert/model'
export * from './badge/model'
@@ -76,7 +76,7 @@
</script>
{#if menuOpen}
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
<FlowHistory bind:this={flowHistory} path={flow.path} />
{/if}
@@ -88,7 +88,7 @@
</script>
{#if menuOpen}
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
{/if}
<Row
@@ -35,6 +35,7 @@
light ? 'font-medium' : '',
'data-[state=on]:bg-surface data-[state=on]:shadow-md',
'bg-surface-secondary hover:bg-surface-hover',
disabled ? '!shadow-none' : '',
$$props.class
)}
use:melt={$item(value)}
@@ -48,6 +48,6 @@
{id}
>
<div class={twMerge('flex bg-surface-secondary rounded-md p-0.5 gap-1 h-full ', tabListClass)}>
<slot {item} />
<slot {item} {disabled} />
</div>
</div>
@@ -16,7 +16,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="grow min-w-0 w-full px-2 py-1 border flex items-center bg-surface-secondary text-primary justify-between rounded-md"
class={twMerge(
'grow min-w-0 w-full px-2 py-1 border flex items-center bg-surface-secondary text-primary justify-between rounded-md',
$$props.class
)}
class:cursor-not-allowed={disabled}
class:cursor-pointer={!disabled}
on:click={(e) => {
@@ -12,7 +12,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="flex flex-row flex-1 border p-2 rounded-md overflow-auto relative"
class="flex flex-col flex-1 border rounded-md relative"
class:cursor-not-allowed={disabled}
on:click={(e) => {
if (disabled) {
@@ -22,6 +22,10 @@
copyToClipboard(code)
}}
>
<Highlight {language} {code} class="pointer-events-none" />
<Clipboard size={14} class="w-8 top-2 right-2 absolute" />
<div class="absolute top-2 right-1 z-10 pointer-events-none">
<Clipboard size={14} class="w-8 cursor-pointer pointer-events-auto" />
</div>
<div class="p-2 overflow-auto w-full">
<Highlight {language} {code} class="pointer-events-none" />
</div>
</div>
@@ -3,24 +3,8 @@
import HighlightTheme from '../HighlightTheme.svelte'
import FlowViewerInner from '../FlowViewerInner.svelte'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
export let triggerSelected:
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
| 'kafka'
| 'mqtt'
| 'sqs'
| 'gcp'
| 'nats' = 'webhooks'
export let flow_json: any | undefined = undefined
export let simplfiedPoll: boolean = false
export let isOperator: boolean = false
@@ -52,21 +36,8 @@
<TabContent value="script" class="h-full">
<slot name="script" />
</TabContent>
<TabContent value="triggers" class="h-full pt-2">
<DetailPageTriggerPanel {simplfiedPoll} bind:triggerSelected>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
<TabContent value="triggers" class="h-full">
<slot name="triggers" />
</TabContent>
{#if flow_json}
<TabContent value="raw" class="flex flex-col flex-1 h-full overflow-auto p-2">
@@ -5,7 +5,7 @@
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
import { createEventDispatcher, getContext } from 'svelte'
import { createEventDispatcher, getContext, tick } from 'svelte'
import type { TriggerContext } from '../triggers'
import { Calendar } from 'lucide-svelte'
@@ -23,7 +23,7 @@
color?: 'red'
}
const { triggersCount, selectedTrigger } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
export let mainButtons: MainButton[] = []
export let menuItems: MenuItemButton[] = []
@@ -52,16 +52,20 @@
<Badge>tag: {tag}</Badge>
{/if}
<slot />
{#if $triggersCount?.primary_schedule}
{#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)}
{@const primarySchedule = triggersState.triggers.findIndex(
(t) => t.isPrimary && !t.isDraft
)}
<Button
btnClasses="inline-flex"
startIcon={{ icon: Calendar }}
variant="contained"
color="light"
size="xs"
on:click={() => {
$selectedTrigger = 'schedules'
dispatch('triggerDetail')
on:click={async () => {
dispatch('seeTriggers')
await tick()
triggersState.selectedTriggerIndex = primarySchedule
}}
>
{$triggersCount?.primary_schedule?.schedule ?? ''}
@@ -3,48 +3,14 @@
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import DetailPageDetailPanel from './DetailPageDetailPanel.svelte'
import type { ScheduleTrigger, TriggerContext } from '../triggers'
import { setContext } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import type { TriggersCount } from '$lib/gen'
import DetailPageTriggerPanel from './DetailPageTriggerPanel.svelte'
export let isOperator: boolean = false
export let flow_json: any | undefined = undefined
export let selected: string
export let triggersCount: Writable<TriggersCount | undefined>
let mobileTab: 'form' | 'detail' = 'form'
let clientWidth = window.innerWidth
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const selectedTriggerStore = writable<
| 'webhooks'
| 'emails'
| 'schedules'
| 'cli'
| 'routes'
| 'websockets'
| 'postgres'
| 'scheduledPoll'
| 'kafka'
| 'nats'
| 'mqtt'
| 'sqs'
| 'gcp'
>('webhooks')
const simplifiedPoll = writable(false)
setContext<TriggerContext>('TriggerContext', {
selectedTrigger: selectedTriggerStore,
primarySchedule: primaryScheduleStore,
triggersCount,
simplifiedPoll,
defaultValues: writable(undefined),
captureOn: writable(undefined),
showCaptureHint: writable(undefined)
})
</script>
<main class="h-screen w-full" bind:clientWidth>
@@ -57,28 +23,11 @@
<slot name="form" />
</Pane>
<Pane size={35} minSize={15}>
<DetailPageDetailPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
bind:selected
{isOperator}
{flow_json}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
<DetailPageDetailPanel bind:selected {isOperator} {flow_json}>
<slot slot="script" name="script" />
<slot slot="save_inputs" name="save_inputs" />
<slot slot="flow_step" name="flow_step" />
<slot slot="triggers" name="triggers" />
</DetailPageDetailPanel>
</Pane>
</Splitpanes>
@@ -108,25 +57,8 @@
<TabContent value="saved_inputs" class="flex flex-col flex-1 h-full">
<slot name="save_inputs" />
</TabContent>
<TabContent value="triggers" class="flex flex-col flex-1 h-full">
<DetailPageTriggerPanel
simplfiedPoll={$simplifiedPoll}
bind:triggerSelected={$selectedTriggerStore}
>
<slot slot="webhooks" name="webhooks" />
<slot slot="routes" name="routes" />
<slot slot="script" name="script" />
<slot slot="websockets" name="websockets" />
<slot slot="postgres" name="postgres" />
<slot slot="kafka" name="kafka" />
<slot slot="nats" name="nats" />
<slot slot="mqtt" name="mqtt" />
<slot slot="sqs" name="sqs" />
<slot slot="gcp" name="gcp" />
<slot slot="emails" name="emails" />
<slot slot="schedules" name="schedules" />
<slot slot="cli" name="cli" />
</DetailPageTriggerPanel>
<TabContent value="triggers" class="flex flex-col flex-1 h-full mt-[-2px]">
<slot name="triggers" />
</TabContent>
<TabContent value="script" class="flex flex-col flex-1 h-full">
<slot name="script" />
@@ -0,0 +1,70 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import { workspaceStore } from '$lib/stores'
import { base32 } from 'rfc4648'
import ClipboardPanel from './ClipboardPanel.svelte'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import { fade } from 'svelte/transition'
interface Props {
isFlow?: boolean
path: string
emailDomain?: string | null
captureInfo?: CaptureInfo | undefined
hasPreprocessor?: boolean
captureLoading?: boolean
}
let {
isFlow = false,
path,
emailDomain = null,
captureInfo = undefined,
hasPreprocessor = false,
captureLoading = false
}: Props = $props()
function getCaptureEmail() {
const cleanedPath = path.replaceAll('/', '.')
const plainPrefix = `capture+${$workspaceStore}+${(isFlow ? 'flow.' : '') + cleanedPath}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${encodedPrefix}@${emailDomain}`
}
let captureEmail = $derived(getCaptureEmail())
</script>
{#if captureInfo}
<CaptureSection
captureType="email"
disabled={false}
{captureInfo}
{captureLoading}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Send an email to the test address below to simulate an email trigger.
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to email events on this test address.
</p>
{/if}
{/snippet}
<Label label="Test email address" disabled={!captureInfo.active}>
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
@@ -10,8 +10,6 @@
import ClipboardPanel from './ClipboardPanel.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { base32 } from 'rfc4648'
import CaptureSection, { type CaptureInfo } from '../triggers/CaptureSection.svelte'
import CaptureTable from '../triggers/CaptureTable.svelte'
export let token: string = ''
export let isFlow: boolean = false
@@ -19,25 +17,9 @@
export let path: string
export let userSettings: any
export let emailDomain: string | null = null
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
let requestType: 'hash' | 'path' = 'path'
function getCaptureEmail() {
const cleanedPath = path.replaceAll('/', '.')
const plainPrefix = `capture+${$workspaceStore}+${(isFlow ? 'flow.' : '') + cleanedPath}`
const encodedPrefix = base32
.stringify(new TextEncoder().encode(plainPrefix), {
pad: false
})
.toLowerCase()
return `${encodedPrefix}@${emailDomain}`
}
$: captureEmail = getCaptureEmail()
function emailAddress(
requestType: 'hash' | 'path',
path: string,
@@ -63,23 +45,6 @@
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
bind:captureTable
captureType="email"
disabled={false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
>
<Label label="Email address">
<ClipboardPanel content={captureEmail} disabled={!captureInfo.active} />
</Label>
</CaptureSection>
{/if}
<div class="flex flex-col gap-4">
{#if SCRIPT_VIEW_SHOW_CREATE_TOKEN_BUTTON}
<Label label="Token">
@@ -7,20 +7,20 @@
import { SettingService } from '$lib/gen'
import Skeleton from '../common/skeleton/Skeleton.svelte'
import TriggerTokens from '../triggers/TriggerTokens.svelte'
import TriggersEditorSection from '../triggers/TriggersEditorSection.svelte'
import Description from '../Description.svelte'
import Section from '../Section.svelte'
import { createEventDispatcher } from 'svelte'
import EmailTriggerConfigSection from './EmailTriggerConfigSection.svelte'
let userSettings: UserSettings
const dispatch = createEventDispatcher()
export let token: string
export let scopes: string[] = []
export let isFlow: boolean = false
export let hash: string | undefined = undefined
export let path: string
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean = false
let emailDomain: string | null = null
let triggerTokens: TriggerTokens | undefined = undefined
@@ -35,6 +35,8 @@
}
getEmailDomain()
$: emailDomain && dispatch('email-domain', emailDomain)
</script>
<HighlightTheme />
@@ -50,7 +52,7 @@
{scopes}
/>
<div class="flex flex-col w-full gap-4">
<Section label="Email trigger" class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/advanced/email_triggers">
Email triggers execute scripts and flows when emails are sent to specific addresses. Each
trigger has its own unique email address that can be used to invoke the script or flow.
@@ -59,23 +61,7 @@
<Skeleton layout={[[18]]} />
{:else}
{#if emailDomain}
<TriggersEditorSection
cloudDisabled={false}
triggerType="email"
{isFlow}
noSave
data={{ emailDomain, userSettings, token, hash, path }}
{isEditor}
{path}
{canHavePreprocessor}
{hasPreprocessor}
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
alwaysOpened={true}
/>
<EmailTriggerConfigSection {hash} {token} {path} {isFlow} {userSettings} {emailDomain} />
{:else}
<div>
<Alert title="Email triggers are disabled" size="xs" type="warning">
@@ -95,4 +81,4 @@
<TriggerTokens bind:this={triggerTokens} {isFlow} {path} labelPrefix="email" />
{/if}
</div>
</Section>
@@ -13,6 +13,7 @@
import type { PropPickerContext, FlowPropPickerConfig } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import type { Flow } from '$lib/gen'
import type { Trigger } from '$lib/components/triggers/utils'
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
export let loading: boolean
@@ -28,6 +29,8 @@
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
let size = 50
const { currentStepStore: copilotCurrentStepStore } =
@@ -84,6 +87,7 @@
enableAi={!disableAi}
on:applyArgs
on:testWithArgs
{onDeployTrigger}
/>
{/if}
</Pane>
@@ -1,14 +1,16 @@
<script lang="ts">
import type { FlowModuleValue } from '$lib/gen'
import FlowCardHeader from './FlowCardHeader.svelte'
export let title: string | undefined = undefined
export let summary: string | undefined = undefined
export let noEditor: boolean
export let noHeader = false
export let flowModuleValue: FlowModuleValue | undefined = undefined
</script>
<div class="flex flex-col h-full">
{#if !noEditor}
{#if !noEditor && !noHeader}
<div>
<FlowCardHeader on:setHash on:reload {title} bind:summary {flowModuleValue}>
<slot name="header" />
@@ -6,13 +6,14 @@
import FlowInput from './FlowInput.svelte'
import FlowFailureModule from './FlowFailureModule.svelte'
import FlowConstants from './FlowConstants.svelte'
import TriggersEditor from '../../triggers/TriggersEditor.svelte'
import type { FlowModule, Flow } from '$lib/gen'
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import FlowPreprocessorModule from './FlowPreprocessorModule.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import { insertNewPreprocessorModule } from '../flowStateUtils'
import TriggersEditor from '../../triggers/TriggersEditor.svelte'
import { handleSelectTriggerFromKind, type Trigger } from '$lib/components/triggers/utils'
export let noEditor = false
export let enableAi = false
@@ -23,7 +24,7 @@
draft?: Flow | undefined
})
| undefined = undefined
export let onDeployTrigger: (trigger: Trigger) => void = () => {}
const {
selectedId,
flowStore,
@@ -36,7 +37,7 @@
flowInputEditorState
} = getContext<FlowEditorContext>('FlowEditorContext')
const { selectedTrigger, defaultValues, captureOn, showCaptureHint } =
const { showCaptureHint, triggersState, triggersCount } =
getContext<TriggerContext>('TriggerContext')
function checkDup(modules: FlowModule[]): string | undefined {
let seenModules: string[] = []
@@ -82,9 +83,7 @@
disabled={disabledFlowInputs}
on:openTriggers={(ev) => {
$selectedId = 'triggers'
selectedTrigger.set(ev.detail.kind)
defaultValues.set(ev.detail.config)
captureOn.set(true)
handleSelectTriggerFromKind(triggersState, triggersCount, savedFlow?.path, ev.detail.kind)
showCaptureHint.set(true)
}}
on:applyArgs
@@ -118,16 +117,18 @@
}
}}
on:testWithArgs
args={$previewArgs}
currentPath={$pathStore}
initialPath={$initialPathStore}
{fakeInitialPath}
schema={$flowStore.schema}
{noEditor}
newItem={newFlow}
isFlow={true}
hasPreprocessor={!!$flowStore.value.preprocessor_module}
canHavePreprocessor={true}
args={$previewArgs}
isDeployed={savedFlow && !savedFlow?.draft_only}
schema={$flowStore.schema}
{onDeployTrigger}
/>
{:else if $selectedId.startsWith('subflow:')}
<div class="p-4"
@@ -21,6 +21,7 @@
import { initFlowStepWarnings } from '../utils'
import { dfs } from '../dfs'
import type { TriggerContext } from '$lib/components/triggers'
import { formatCron } from '$lib/utils'
export let flowModule: FlowModule
export let noEditor: boolean = false
@@ -30,7 +31,7 @@
const { selectedId, flowStateStore, flowInputsStore, flowStore } =
getContext<FlowEditorContext>('FlowEditorContext')
const { primarySchedule } = getContext<TriggerContext>('TriggerContext')
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
let scriptKind: 'script' | 'trigger' | 'approval' = 'script'
let scriptTemplate: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' = 'script'
@@ -42,19 +43,33 @@
export let previousModule: FlowModule | undefined = undefined
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
if (!$primarySchedule) {
$primarySchedule = {
const primaryIndex = triggersState.triggers.findIndex((t) => t.isPrimary)
if (primaryIndex === -1) {
const primaryCfg = {
summary: 'Scheduled poll of flow',
args: {},
cron: '0 */15 * * *',
schedule: formatCron('0 */15 * * *'),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
enabled: true
enabled: true,
is_flow: true
}
triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, primaryCfg)
} else if (triggersState.triggers[primaryIndex].draftConfig) {
//If there is a primary schedule draft update it
const newCfg = { ...triggersState.triggers[primaryIndex].draftConfig }
let updated = false
if (!newCfg.schedule) {
newCfg.schedule = formatCron('0 */15 * * *')
updated = true
}
if (!newCfg.enabled) {
newCfg.enabled = true
updated = true
}
if (updated) {
triggersState.triggers[primaryIndex].draftConfig = newCfg
}
}
if (!$primarySchedule.cron) {
$primarySchedule.cron = '0 */15 * * *'
}
$primarySchedule.enabled = true
module.stop_after_if = {
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
@@ -1,7 +1,8 @@
<script lang="ts">
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte'
import type { ScheduleTrigger, TriggerContext } from '$lib/components/triggers'
import type { TriggerContext } from '$lib/components/triggers'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { FlowService, type Flow, type TriggersCount } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { setContext } from 'svelte'
@@ -12,19 +13,12 @@
let flow: Flow | undefined = undefined
const selectedTriggerStore = writable<
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'scheduledPoll'
>('webhooks')
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('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()
})
async function loadFlow(path: string) {
@@ -60,7 +60,7 @@
flowInputsStore,
pathStore
} = getContext<FlowEditorContext>('FlowEditorContext')
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
async function insertNewModuleAtIndex(
@@ -433,7 +433,7 @@
undefined
)
setExpr(detail.modules[index + 1], `results.${id}`)
setScheduledPollSchedule(primarySchedule, triggersCount)
setScheduledPollSchedule(triggersState, triggersCount)
}
if (`flow` in detail) {
@@ -1,14 +1,10 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import { createEventDispatcher } from 'svelte'
import { Cross } from 'lucide-svelte'
import StepGenQuick from '$lib/components/copilot/StepGenQuick.svelte'
import FlowInputsQuick from '../content/FlowInputsQuick.svelte'
import type { FlowModule } from '$lib/gen'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import InsertModuleInner from './InsertModuleInner.svelte'
import { twMerge } from 'tailwind-merge'
import type { ComputeConfig } from 'svelte-floating-ui'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
import PopupV2 from '$lib/components/common/popup/PopupV2.svelte'
import { flip, offset } from 'svelte-floating-ui/dom'
import { SchedulePollIcon } from '$lib/components/icons'
@@ -16,13 +12,9 @@
// import type { Writable } from 'svelte/store'
const dispatch = createEventDispatcher()
export let stop = false
export let index: number = 0
export let funcDesc = ''
export let modules: FlowModule[] = []
export let disableAi = false
export let kind: 'script' | 'trigger' | 'preprocessor' | 'failure' = 'script'
export let allowTrigger = true
export let iconSize = 12
type Alignment = 'start' | 'end' | 'center'
@@ -39,19 +31,8 @@
autoUpdate: true
}
$: !open && (funcDesc = '')
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' = kind
let preFilter: 'all' | 'workspace' | 'hub' = 'all'
let loading = false
let small = false
let open = false
let width = 0
let height = 0
$: displayPath = width > 650 || height > 400
$: small = kind === 'preprocessor' || kind === 'failure'
</script>
<!-- <Menu transitionDuration={0} pointerDown bind:show={open} noMinW {placement} let:close> -->
@@ -93,129 +74,5 @@ shouldUsePortal={true} -->
{/if}
</button>
</svelte:fragment>
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small
? 'w-[450px]'
: 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5 resize overflow-auto {small
? 'min-w-[450px]'
: 'min-w-[650px]'} min-h-[400px]"
on:wheel={(e) => {
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
<StepGenQuick
on:escape={() => close(null)}
{disableAi}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
</div>
<div class="flex flex-row grow min-h-0">
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary">
<TopLevelNode
label="Action"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode
label="Trigger"
selected={selectedKind === 'trigger'}
on:select={() => {
selectedKind = 'trigger'
}}
/>
{/if}
<TopLevelNode
label="Approval/Prompt"
selected={selectedKind === 'approval'}
on:select={() => {
selectedKind = 'approval'
}}
/>
{#if customUi?.flowNode != false}
<TopLevelNode
label="Flow"
selected={selectedKind === 'flow'}
on:select={() => {
selectedKind = 'flow'
}}
/>
{/if}
{#if stop}
<TopLevelNode
label="End flow"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{/if}
<TopLevelNode
label="For loop"
on:select={() => {
close(null)
dispatch('new', { kind: 'forloop' })
}}
/>
<TopLevelNode
label="While loop"
on:select={() => {
close(null)
dispatch('new', { kind: 'whileloop' })
}}
/>
<TopLevelNode
label="Branch to one"
on:select={() => {
close(null)
dispatch('new', { kind: 'branchone' })
}}
/>
<TopLevelNode
label="Branch to all"
on:select={() => {
close(null)
dispatch('new', { kind: 'branchall' })
}}
/>
</div>
{/if}
<FlowInputsQuick
{selectedKind}
bind:loading
filter={funcDesc}
{modules}
{index}
{disableAi}
{funcDesc}
{kind}
on:close={() => {
close(null)
}}
on:new
on:pickScript
on:pickFlow
{preFilter}
{small}
{displayPath}
/>
</div>
</div>
<InsertModuleInner on:close={() => close(null)} on:insert on:new on:pickFlow on:pickScript />
</PopupV2>
@@ -0,0 +1,167 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import StepGenQuick from '$lib/components/copilot/StepGenQuick.svelte'
import FlowInputsQuick from '../content/FlowInputsQuick.svelte'
import type { FlowModule } from '$lib/gen'
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
// import type { Writable } from 'svelte/store'
const dispatch = createEventDispatcher()
export let stop = false
export let index: number = 0
export let funcDesc = ''
export let modules: FlowModule[] = []
export let disableAi = false
export let kind: 'script' | 'trigger' | 'preprocessor' | 'failure' = 'script'
export let allowTrigger = true
let customUi: undefined | FlowBuilderWhitelabelCustomUi = getContext('customUi')
let selectedKind: 'script' | 'trigger' | 'preprocessor' | 'approval' | 'flow' | 'failure' = kind
let preFilter: 'all' | 'workspace' | 'hub' = 'all'
let loading = false
let small = false
let width = 0
let height = 0
$: displayPath = width > 650 || height > 400
$: small = kind === 'preprocessor' || kind === 'failure'
</script>
<!-- <Menu transitionDuration={0} pointerDown bind:show={open} noMinW {placement} let:close> -->
<!-- {floatingConfig}
floatingClasses="mt-2"
containerClasses="border rounded-lg shadow-lg bg-surface"
noTransition
shouldUsePortal={true} -->
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small
? 'w-[450px]'
: 'w-[650px]'} pt-1 pr-1 pl-1 gap-1.5 resize overflow-auto {small
? 'min-w-[450px]'
: 'min-w-[650px]'} min-h-[400px]"
on:wheel={(e) => {
e.stopPropagation()
}}
role="none"
bind:clientWidth={width}
bind:clientHeight={height}
>
<div class="flex flex-row items-center gap-2">
<StepGenQuick
on:escape={() => dispatch('close')}
{disableAi}
on:insert
bind:funcDesc
{preFilter}
{loading}
/>
{#if selectedKind != 'preprocessor' && selectedKind != 'flow'}
<ToggleHubWorkspaceQuick bind:selected={preFilter} />
{/if}
</div>
<div class="flex flex-row grow min-h-0">
{#if kind === 'script'}
<div class="flex-none flex flex-col text-xs text-primary">
<TopLevelNode
label="Action"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{#if customUi?.triggers != false && allowTrigger}
<TopLevelNode
label="Trigger"
selected={selectedKind === 'trigger'}
on:select={() => {
selectedKind = 'trigger'
}}
/>
{/if}
<TopLevelNode
label="Approval/Prompt"
selected={selectedKind === 'approval'}
on:select={() => {
selectedKind = 'approval'
}}
/>
{#if customUi?.flowNode != false}
<TopLevelNode
label="Flow"
selected={selectedKind === 'flow'}
on:select={() => {
selectedKind = 'flow'
}}
/>
{/if}
{#if stop}
<TopLevelNode
label="End flow"
selected={selectedKind === 'script'}
on:select={() => {
selectedKind = 'script'
}}
/>
{/if}
<TopLevelNode
label="For loop"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'forloop' })
}}
/>
<TopLevelNode
label="While loop"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'whileloop' })
}}
/>
<TopLevelNode
label="Branch to one"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'branchone' })
}}
/>
<TopLevelNode
label="Branch to all"
on:select={() => {
dispatch('close')
dispatch('new', { kind: 'branchall' })
}}
/>
</div>
{/if}
<FlowInputsQuick
{selectedKind}
bind:loading
filter={funcDesc}
{modules}
{index}
{disableAi}
{funcDesc}
{kind}
on:close={() => {
dispatch('close')
}}
on:new
on:pickScript
on:pickFlow
{preFilter}
{small}
{displayPath}
/>
</div>
</div>
@@ -1,6 +1,7 @@
import { ScheduleService, type Schedule, type TriggersCount } from '$lib/gen'
import type { ScheduleTrigger } from '../triggers'
import type { Writable } from 'svelte/store'
import { writable } from 'svelte/store'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/utils'
@@ -38,7 +39,8 @@ export async function loadSchedules(
initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined>,
workspace: string,
triggersCount: Writable<TriggersCount | undefined>,
loadPrimarySchedule: boolean = false
loadPrimarySchedule: boolean = false,
isDeployed: Writable<boolean | undefined> = writable(undefined)
) {
if (!path || path == '') {
schedules.set([])
@@ -53,6 +55,9 @@ export async function loadSchedules(
isFlow
})
const primary = allSchedules.find((s) => s.path == path)
if (primary) {
isDeployed.set(true)
}
let remotePrimarySchedule: ScheduleTrigger | false | undefined = undefined
if (loadPrimarySchedule && primary) {
remotePrimarySchedule = await loadSchedule(path, workspace)
@@ -64,7 +69,7 @@ export async function loadSchedules(
cron: primary.schedule,
timezone: primary.timezone,
enabled: primary.enabled
}
}
: false
}
primarySchedule.update((ps) => (ps === undefined || forceRefresh ? remotePrimarySchedule : ps))
@@ -137,3 +142,58 @@ export async function saveSchedule(
}
}
}
export async function saveScheduleFromCfg(
scheduleCfg: Record<string, any>,
edit: boolean,
workspace: string
): Promise<boolean> {
const requestBody = {
schedule: scheduleCfg.schedule,
timezone: scheduleCfg.timezone,
args: scheduleCfg.args,
on_failure: scheduleCfg.on_failure,
on_failure_times: scheduleCfg.on_failure_times,
on_failure_exact: scheduleCfg.on_failure_exact,
on_failure_extra_args: scheduleCfg.on_failure_extra_args,
on_recovery: scheduleCfg.on_recovery,
on_recovery_times: scheduleCfg.on_recovery_times,
on_recovery_extra_args: scheduleCfg.on_recovery_extra_args,
on_success: scheduleCfg.on_success,
on_success_extra_args: scheduleCfg.on_success_extra_args,
ws_error_handler_muted: scheduleCfg.ws_error_handler_muted,
retry: scheduleCfg.retry,
summary: scheduleCfg.summary,
description: scheduleCfg.description,
no_flow_overlap: scheduleCfg.no_flow_overlap,
tag: scheduleCfg.tag,
paused_until: scheduleCfg.paused_until,
cron_version: scheduleCfg.cron_version
}
try {
if (edit) {
await ScheduleService.updateSchedule({
workspace,
path: scheduleCfg.path,
requestBody: requestBody
})
sendUserToast(`Schedule ${scheduleCfg.path} updated`)
} else {
await ScheduleService.createSchedule({
workspace,
requestBody: {
path: scheduleCfg.path,
script_path: scheduleCfg.script_path,
is_flow: scheduleCfg.is_flow,
...requestBody,
enabled: true
}
})
sendUserToast(`Schedule ${scheduleCfg.path} created`)
}
return true
} catch (error) {
sendUserToast(error.body || error.message, true)
return false
}
}
@@ -2,13 +2,15 @@
import NodeWrapper from './NodeWrapper.svelte'
import TriggersWrapper from '../triggers/TriggersWrapper.svelte'
import { type GraphEventHandlers, type SimplifiableFlow } from '../../graphBuilder'
import type { FlowModule } from '$lib/gen'
import type { FlowModule, TriggersCount } from '$lib/gen'
import { getContext } from 'svelte'
import type { Writable } from 'svelte/store'
import { Maximize2, Minimize2, Calendar } from 'lucide-svelte'
import { getStateColor, getStateHoverColor } from '../../util'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
import VirtualItemWrapper from '$lib/components/flows/map/VirtualItemWrapper.svelte'
import { type Trigger, type TriggerType } from '$lib/components/triggers/utils'
import { tick } from 'svelte'
export let data: {
path: string
@@ -26,11 +28,24 @@
selectedId: Writable<string | undefined>
}>('FlowGraphContext')
const { primarySchedule, triggersCount, selectedTrigger } =
getContext<TriggerContext>('TriggerContext')
const { triggersCount, triggersState } = getContext<TriggerContext>('TriggerContext')
function getScheduleCfg(primary: Trigger | undefined, triggersCount: TriggersCount | undefined) {
return primary?.draftConfig
? {
enabled: primary?.draftConfig?.enabled,
schedule: primary?.draftConfig?.schedule
}
: primary?.lightConfig
? { enabled: primary?.lightConfig?.enabled, schedule: primary?.lightConfig?.schedule }
: {
enabled: !!triggersCount?.primary_schedule,
schedule: triggersCount?.primary_schedule?.schedule
}
}
</script>
<NodeWrapper wrapperClass="shadow-md" let:darkMode>
<NodeWrapper wrapperClass="shadow-md rounded-sm" let:darkMode>
{#if data.simplifiableFlow?.simplifiedFlow != true}
<TriggersWrapper
disableAi={data.disableAi}
@@ -38,6 +53,7 @@
path={data.path}
bgColor={getStateColor(undefined, darkMode)}
bgHoverColor={getStateHoverColor(undefined, darkMode)}
showDraft={data.isEditor ?? false}
on:new={(e) => {
data?.eventHandlers.insert({
modules: data.modules,
@@ -57,14 +73,24 @@
data?.eventHandlers?.simplifyFlow(true)
}}
on:openScheduledPoll={(e) => {
$selectedTrigger = 'scheduledPoll'
const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)
triggersState.selectedTriggerIndex = primarySchedule
}}
on:select={(e) => {
on:select={() => data?.eventHandlers?.select('triggers')}
onSelect={async (triggerIndex: number) => {
data?.eventHandlers?.select('triggers')
await tick()
triggersState.selectedTriggerIndex = triggerIndex
}}
on:delete={(e) => {
data.eventHandlers.delete(e, '')
}}
onAddDraftTrigger={async (type: TriggerType) => {
const newTrigger = triggersState.addDraftTrigger(triggersCount, type)
data?.eventHandlers?.select('triggers')
await tick()
triggersState.selectedTriggerIndex = newTrigger
}}
selected={$selectedId == 'triggers'}
newItem={data.newFlow}
modules={data.modules}
@@ -80,22 +106,23 @@
data?.eventHandlers?.select(e.detail)
}}
>
{#if $primarySchedule || ($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)}
{#if triggersState.triggers.some((t) => t.isPrimary) || $triggersCount?.primary_schedule}
{@const { enabled, schedule } = getScheduleCfg(
triggersState.triggers.find((t) => t.isPrimary),
$triggersCount
)}
<div class="text-2xs text-primary p-2 flex gap-2 items-center">
<Calendar size={12} />
<div>
Schedule every {$primarySchedule?.cron ?? $triggersCount?.primary_schedule?.schedule}
{$primarySchedule?.enabled ||
($primarySchedule == undefined && $triggersCount?.primary_schedule?.schedule)
? ''
: ' (disabled)'}
Schedule every {schedule}
{enabled ? '' : ' (disabled)'}
</div>
</div>
{:else}
<button
class="px-2 py-1 hover:bg-surface-inverse w-full hover:text-primary-inverse"
on:click={() => {
setScheduledPollSchedule(primarySchedule, triggersCount)
setScheduledPollSchedule(triggersState, triggersCount)
}}
>
Set primary schedule
@@ -1,110 +1,313 @@
<script lang="ts">
import { Calendar, Mail, Webhook, Unplug, Database, PlugZap } from 'lucide-svelte'
import TriggerButton from './TriggerButton.svelte'
import Popover from '$lib/components/Popover.svelte'
import TriggerCount from './TriggerCount.svelte'
import { createEventDispatcher, onMount, type ComponentType } from 'svelte'
import { Calendar, Mail, Webhook, Unplug, Database, Terminal } from 'lucide-svelte'
import { Loader2 } from 'lucide-svelte'
import { type ComponentType } from 'svelte'
import { Route } from 'lucide-svelte'
import { getContext } from 'svelte'
import { type TriggerContext } from '$lib/components/triggers'
import { FlowService, ScriptService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon } from '$lib/components/icons'
import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte'
import { enterpriseLicense } from '$lib/stores'
import { MqttIcon, NatsIcon, KafkaIcon, AwsIcon, GoogleCloudIcon } from '$lib/components/icons'
import { type Trigger, type TriggerType } from '$lib/components/triggers/utils'
import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents'
import { twMerge } from 'tailwind-merge'
import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte'
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
const { selectedTrigger, triggersCount } = getContext<TriggerContext>('TriggerContext')
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
export let path: string
export let newItem: boolean
export let isFlow: boolean
export let selected: boolean
export let showOnlyWithCount: boolean
export let triggersToDisplay: (
| 'webhooks'
| 'schedules'
| 'routes'
| 'websockets'
| 'kafka'
| 'nats'
| 'mqtt'
| 'emails'
| 'eventStreams'
| 'postgres'
| 'sqs'
| 'gcp'
)[] = showOnlyWithCount
? ['webhooks', 'schedules', 'routes', 'websockets', 'kafka', 'nats', 'emails']
: ['webhooks', 'schedules', 'routes', 'websockets', 'eventStreams', 'emails']
const dispatch = createEventDispatcher()
onMount(() => {
if (!newItem) {
loadCount()
}
})
async function loadCount() {
if (isFlow) {
$triggersCount = await FlowService.getTriggersCountOfFlow({
workspace: $workspaceStore!,
path
})
} else {
$triggersCount = await ScriptService.getTriggersCountOfScript({
workspace: $workspaceStore!,
path
})
}
interface Props {
path: string
newItem: boolean
isFlow: boolean
selected: boolean
showOnlyWithCount: boolean
numberOfTriggers?: number
small?: boolean
vertical?: boolean
limit?: number
showDraft?: boolean
onSelect?: (triggerIndex: number) => void
}
let {
selected,
showOnlyWithCount,
// @ts-ignore - This is an output-only prop used with bind:
numberOfTriggers = $bindable(0),
small = true,
vertical = false,
limit,
showDraft = true,
onSelect
}: Props = $props()
let menuOpen = $state(false)
const triggerTypeConfig: {
[key: string]: { icon: ComponentType; countKey?: string }
[key in TriggerType]: { icon: ComponentType; countKey?: string; disabled?: boolean }
} = {
webhooks: { icon: Webhook, countKey: 'webhook_count' },
schedules: { icon: Calendar, countKey: 'schedule_count' },
routes: { icon: Route, countKey: 'http_routes_count' },
websockets: { icon: Unplug, countKey: 'websocket_count' },
webhook: { icon: Webhook, countKey: 'webhook_count' },
schedule: { icon: Calendar, countKey: 'schedule_count' },
http: { icon: Route, countKey: 'http_routes_count' },
websocket: { icon: Unplug, countKey: 'websocket_count' },
postgres: { icon: Database, countKey: 'postgres_count' },
kafka: { icon: KafkaIcon, countKey: 'kafka_count' },
emails: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count' },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count' },
sqs: { icon: AwsIcon, countKey: 'sqs_count' },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count' },
eventStreams: { icon: PlugZap }
kafka: { icon: KafkaIcon, countKey: 'kafka_count', disabled: !$enterpriseLicense },
email: { icon: Mail, countKey: 'email_count' },
nats: { icon: NatsIcon, countKey: 'nats_count', disabled: !$enterpriseLicense },
mqtt: { icon: MqttIcon, countKey: 'mqtt_count', disabled: !$enterpriseLicense },
sqs: { icon: AwsIcon, countKey: 'sqs_count', disabled: !$enterpriseLicense },
gcp: { icon: GoogleCloudIcon, countKey: 'gcp_count', disabled: !$enterpriseLicense },
poll: { icon: SchedulePollIcon },
cli: { icon: Terminal }
}
const allTypes = [
'webhook',
'schedule',
'http',
'websocket',
'postgres',
'kafka',
'email',
'nats',
'mqtt',
'sqs',
'gcp',
'poll',
'cli'
]
function camelCaseToWords(s: string) {
const result = s.replace(/([A-Z])/g, ' $1')
return result.charAt(0).toUpperCase() + result.slice(1).toLowerCase()
}
const itemClass = twMerge(
'text-secondary text-left font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary flex flex-row items-center flex-nowrap'
)
// Group triggers by their mapped type
let triggersGrouped = $derived.by(() => {
const triggersWithIndex = triggersState.triggers.map((trigger, index) => ({
...trigger,
index
}))
const triggersFiltered = showDraft
? triggersWithIndex
: triggersWithIndex.filter((trigger) => !trigger.isDraft)
return triggersFiltered.reduce(
(acc, trigger) => {
const configType = trigger.type
if (!acc[configType]) {
acc[configType] = []
}
acc[configType].push(trigger)
return acc
},
{} as Record<TriggerType, Trigger[]>
)
})
const noTriggers = $derived(triggersState.triggers.length === 0)
// Extract unique trigger types for display, only keep the first
let allTriggerTypes = $derived.by(() => {
const types = !noTriggers ? (Object.keys(triggersGrouped) as TriggerType[]) : allTypes
//filter out types if showOnlyTriggersWithCount is true and there are no triggers for that type
return types.filter(
(type) =>
(!showOnlyTriggersWithCount ||
((triggerTypeConfig[type].countKey &&
($triggersCount?.[triggerTypeConfig[type].countKey] ?? 0)) ||
0) > 0) &&
!triggerTypeConfig[type].disabled
)
})
let triggersToDisplay = $derived(limit ? allTriggerTypes.slice(0, limit) : allTriggerTypes)
let extraTriggers = $derived(
limit && allTriggerTypes.length > limit
? allTriggerTypes.slice(limit).flatMap((type) => triggersGrouped[type])
: []
)
// Fallback for when there are no triggers in the store but we have the types
let extraTriggersType = $derived(
limit && allTriggerTypes.length > limit ? allTriggerTypes.slice(limit) : []
)
let showOnlyTriggersWithCount = $derived(showOnlyWithCount || triggersState.triggers.length === 0)
$effect(() => {
if (allTriggerTypes) {
numberOfTriggers = allTriggerTypes?.length
}
})
</script>
{#each triggersToDisplay as type}
{@const { icon, countKey } = triggerTypeConfig[type]}
{#if (!showOnlyWithCount || ((countKey && $triggersCount?.[countKey]) || 0) > 0) && !(type === 'gcp' && !$enterpriseLicense) && !(type === 'sqs' && !$enterpriseLicense) && !(type === 'kafka' && !$enterpriseLicense) && !(type === 'nats' && !$enterpriseLicense) && !(type === 'mqtt')}
<Popover>
<svelte:fragment slot="text">{camelCaseToWords(type)}</svelte:fragment>
<TriggerButton
on:click={() => {
$selectedTrigger = type === 'eventStreams' ? 'kafka' : type
dispatch('select')
}}
selected={selected &&
($selectedTrigger === type ||
(type === 'eventStreams' &&
($selectedTrigger === 'kafka' ||
$selectedTrigger === 'nats' ||
$selectedTrigger === 'sqs' ||
$selectedTrigger === 'mqtt' ||
$selectedTrigger === 'gcp')))}
<Menubar
class={twMerge(
'items-center justify-center',
vertical ? 'flex flex-col gap-2' : 'flex flex-row gap-1'
)}
>
{#snippet children({ createMenu })}
{#each triggersToDisplay as type}
{@const isSelected =
selected && triggersState.selectedTrigger && triggersState.selectedTrigger?.type === type}
{@const singleItem =
type === 'webhook' ||
type === 'email' ||
type === 'cli' ||
(triggersGrouped[type] && triggersGrouped[type].length === 1)}
<Tooltip
disablePopup={menuOpen}
placement={vertical ? 'right' : 'bottom'}
on:click={(e) => e.stopPropagation()}
>
{#if countKey}
<TriggerCount count={$triggersCount?.[countKey]} />
{#snippet text()}
{camelCaseToWords(type)}
{/snippet}
{#if singleItem}
{@render triggerButton({ type, isSelected, singleItem })}
{:else}
<Menu
{createMenu}
usePointerDownOutside
placement={vertical ? 'right-start' : 'bottom'}
menuClass={'max-w-56'}
class="h-fit"
bind:open={menuOpen}
disabled={!triggersGrouped[type]}
>
{#snippet trigger({ trigger })}
{@render triggerButton({
type,
isSelected,
meltElement: trigger
})}
{/snippet}
{#snippet children({ item })}
{#if triggersGrouped[type] && triggersGrouped[type].length > 0}
{#each triggersGrouped[type] as trigger}
{@render triggerItem({ triggerIndex: trigger.index, item })}
{/each}
{:else}
<div class="text-xs text-gray-400 p-2">No {camelCaseToWords(type)} triggers</div>
{/if}
{/snippet}
</Menu>
{/if}
<svelte:component this={icon} size={12} />
</TriggerButton>
</Popover>
{/if}
{/each}
</Tooltip>
{/each}
{#if extraTriggers.length > 0}
<Menu
{createMenu}
usePointerDownOutside
placement="bottom"
menuClass={'w-56'}
class="h-fit center-center mr-1"
bind:open={menuOpen}
>
{#snippet trigger({ trigger })}
<MeltButton
class="w-[23px] h-[23px] rounded-md center-center text-[12px] hover:bg-slate-300 transition-all duration-100 font-normal text-secondary hover:text-primary"
meltElement={trigger}
>
+{extraTriggers.length}
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#if extraTriggers.length > 0}
{#each extraTriggers as trigger}
{@render triggerItem({ triggerIndex: trigger.index, item })}
{/each}
{:else if extraTriggersType.length > 0}
{#each extraTriggersType as type}
{@render simpleTriggerItem({ item, type })}
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
{#snippet triggerButton({ type, isSelected, meltElement = undefined, singleItem = false })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
<MeltButton
class={twMerge(
'hover:bg-surface-hover rounded-md shadow-sm text-xs relative center-center cursor-pointer bg-slate-100 dark:bg-slate-700',
'dark:outline dark:outline-1 outline-tertiary/20 group',
isSelected ? 'outline-tertiary outline' : '',
small ? 'w-[23px] h-[23px] outline-[1.5px]' : 'p-2 outline-[2px]'
)}
on:click={(e) => {
e.stopPropagation()
e.preventDefault()
if (singleItem) {
onSelect?.(triggersGrouped[type][0].index)
}
}}
{meltElement}
>
{#if countKey}
{@const count = $triggersCount?.[countKey] ?? 0}
{#if count > 0}
<div
class={twMerge(
// Base styles that apply in all cases
'absolute z-10 rounded-full overflow-hidden',
'flex center-center text-primary-inverse font-mono',
'bg-tertiary/50 group-hover:bg-primary transition-all duration-[100ms]',
noTriggers ? 'bg-primary' : '',
// Size variants based on small prop
small
? '-right-[3px] -top-[3px] h-3 w-3 text-[8px]'
: '-right-1.5 -top-1.5 h-4 w-4 text-xs',
// Special case for no triggers
noTriggers && small ? 'h-3 w-3 text-[8px] -right-0.5 -top-0.5' : '',
noTriggers && !small ? 'h-4 w-4 text-xs -right-1 -top-1' : ''
)}
>
{#if count === undefined}
<Loader2 class="animate-spin text-2xs" />
{:else}
<p>{count}</p>
{/if}
</div>
{/if}
{/if}
<SvelteComponent size={small ? 12 : 14} />
</MeltButton>
{/snippet}
{#snippet triggerItem({ triggerIndex, item })}
<MenuItem
{item}
class={itemClass}
on:click={() => {
onSelect?.(triggerIndex)
}}
>
<TriggerLabel trigger={triggersState.triggers[triggerIndex]} />
</MenuItem>
{/snippet}
{#snippet simpleTriggerItem({ item, type })}
{@const { icon: SvelteComponent, countKey } = triggerTypeConfig[type]}
<MenuItem {item} class={itemClass}>
<div class="flex flex-row items-center gap-2">
<SvelteComponent size={14} />
{camelCaseToWords(type)}
{#if countKey}
<div class="text-xs text-gray-400">
{$triggersCount?.[countKey] ?? 0}
</div>
{/if}
</div>
</MenuItem>
{/snippet}
@@ -1,59 +1,124 @@
<script lang="ts">
import { NODE } from '../../util'
import { createEventDispatcher } from 'svelte'
import type { TriggerType } from '$lib/components/triggers/utils'
import TriggersBadge from './TriggersBadge.svelte'
import InsertModuleButton from '$lib/components/flows/map/InsertModuleButton.svelte'
import type { FlowModule } from '$lib/gen'
import { Plus } from 'lucide-svelte'
import InsertModuleInner from '$lib/components/flows/map/InsertModuleInner.svelte'
import AddTriggersButton from '$lib/components/triggers/AddTriggersButton.svelte'
import { twMerge } from 'tailwind-merge'
export let path: string
export let newItem: boolean
export let selected: boolean
export let isEditor: boolean = false
export let disableAi: boolean = false
export let modules: FlowModule[] = []
export let bgColor: string
export let bgHoverColor: string = ''
interface Props {
path: string
newItem: boolean
selected: boolean
isEditor?: boolean
disableAi?: boolean
modules?: FlowModule[]
bgColor: string
bgHoverColor?: string
showDraft?: boolean
onSelect?: (triggerIndex: number) => void
onAddDraftTrigger?: (type: TriggerType) => void
}
let {
path,
newItem,
selected,
isEditor = false,
disableAi = false,
modules = [],
bgColor,
bgHoverColor = '',
showDraft,
onSelect,
onAddDraftTrigger
}: Props = $props()
let showTriggerScriptPicker = $state(false)
let numberOfTriggers = $state(0)
const dispatch = createEventDispatcher()
let hover = false
let hover = $state(false)
let addTriggersButton = $state<AddTriggersButton | undefined>(undefined)
</script>
<div style={`width: ${NODE.width}px;`}>
<button
style="background-color: {hover && bgHoverColor ? bgHoverColor : bgColor};"
class="flex w-full flex-row gap-1 px-2 p-1 items-center {selected
? 'outline outline-2 outline-gray-600 rounded-sm dark:bg-white/5 dark:outline-gray-400'
class="relative flex w-full flex-row gap-1.5 px-2 p-1 items-center justify-center rounded-sm {selected
? 'outline outline-2 outline-gray-600 dark:bg-white/5 dark:outline-gray-400'
: ''}"
on:pointerdown={() => {
onclick={() => {
dispatch('select')
}}
on:mouseenter={() => (hover = true)}
on:mouseleave={() => (hover = false)}
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
>
<div class="flex flex-col mr-1 ml-1">
<div class="flex flex-row items-center text-2xs font-normal"> Triggers </div>
<div
class={twMerge(
'flex flex-row items-center text-2xs font-normal',
numberOfTriggers > 6 ? 'absolute left-0 -top-[20px]' : ''
)}
>
Triggers
</div>
<TriggersBadge showOnlyWithCount={false} {path} {newItem} isFlow {selected} on:select />
<TriggersBadge
showOnlyWithCount={false}
{showDraft}
{path}
{newItem}
isFlow
{selected}
bind:numberOfTriggers
limit={isEditor ? 7 : 8}
{onSelect}
/>
{#if isEditor}
<InsertModuleButton
{disableAi}
on:new
on:pickScript
on:select
on:open={() => {
dispatch('openScheduledPoll')
<AddTriggersButton
bind:this={addTriggersButton}
onAddScheduledPoll={() => {
showTriggerScriptPicker = true
}}
kind="trigger"
index={0}
{modules}
class={twMerge(
'hover:bg-surface-hover rounded-md border text-xs w-[23px] h-[23px] relative center-center cursor-pointer bg-surface outline-0'
)}
/>
class="w-fit h-fit"
triggerScriptPicker={showTriggerScriptPicker ? triggerScriptPicker : undefined}
onClose={() => {
showTriggerScriptPicker = false
}}
isEditor
{onAddDraftTrigger}
>
<button
class="hover:bg-slate-300 dark:hover:bg-slate-600 rounded-md outline-1 outline-dashed outline-secondary outline-offset-[-1px] text-xs w-[23px] h-[23px] relative center-center cursor-pointer text-secondary"
>
<Plus size={12} />
</button>
</AddTriggersButton>
{/if}
</button>
</div>
{#snippet triggerScriptPicker()}
<div class="border rounded-lg shadow-lg bg-surface z5000">
<InsertModuleInner
{disableAi}
on:new
on:pickScript
on:select
on:open={() => {
dispatch('openScheduledPoll')
}}
on:close={() => {
addTriggersButton?.close()
}}
kind="trigger"
index={0}
{modules}
/>
</div>
{/snippet}
@@ -1,11 +1,10 @@
<script lang="ts">
export let height = '20px'
export let width = '20px'
export let size: number = 16
</script>
<svg
{width}
{height}
width={size}
height={size}
viewBox="0 -25 256 256"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
@@ -1,12 +1,21 @@
<script lang="ts">
import { type AnyMeltElement } from '@melt-ui/svelte'
import { conditionalMelt } from '$lib/utils'
export let meltElement: AnyMeltElement
export let meltElement: AnyMeltElement | undefined = undefined
export let type: 'button' | 'submit' | 'reset' | null | undefined = undefined
export let title: string = ''
export let id: string | undefined = undefined
</script>
<button use:meltElement class={$$props.class} {type} {title} {id} {...$meltElement}>
<button
use:conditionalMelt={meltElement}
class={$$props.class}
{type}
{title}
{id}
{...$meltElement}
on:click
>
<slot />
</button>
@@ -15,6 +15,8 @@
export let createMenu: MenubarBuilders['createMenu']
export let invisible: boolean = false
export let usePointerDownOutside: boolean = false
export let menuClass: string = ''
export let open = false
// Use the passed createMenu function
const menu = createMenu({
@@ -32,8 +34,6 @@
states
} = menu
let open = false
const sync = createSync(states)
$: sync.open(open, (v) => (open = Boolean(v)))
@@ -76,12 +76,13 @@
class={twMerge(
'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto',
lightMode ? 'bg-surface-inverse' : 'bg-surface',
invisible ? 'opacity-0' : ''
invisible ? 'opacity-0' : '',
menuClass
)}
on:click
>
<div class="py-1" style="max-height: {maxHeight}px; ">
<slot {item} />
<slot {item} {open} />
</div>
</div>
{/if}
@@ -1,12 +1,11 @@
<script lang="ts">
import Markdown from 'svelte-exmarkdown'
import type { Placement } from '@floating-ui/core'
import { ExternalLink, InfoIcon } from 'lucide-svelte'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { InfoIcon } from 'lucide-svelte'
import { zIndexes } from '$lib/zIndexes'
import { createTooltip, melt } from '@melt-ui/svelte'
import { fade } from 'svelte/transition'
import TooltipInner from '../TooltipInner.svelte'
export let light = false
export let placement: Placement | undefined = 'bottom'
@@ -18,8 +17,6 @@
export let closeDelay: number = 0
export let portal: string | undefined | null = 'body'
const plugins = [gfmPlugin()]
const {
elements: { trigger, content },
states: { open }
@@ -49,27 +46,9 @@
{/if}
{#if $open && !disablePopup}
<div
use:melt={$content}
transition:fade={{ duration: 100 }}
class="shadow max-w-sm break-words py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left"
style="z-index: {zIndexes.tooltip}"
>
{#if markdownTooltip}
<div class="prose-sm">
<Markdown md={markdownTooltip} {plugins} />
</div>
{:else}
<div use:melt={$content} transition:fade={{ duration: 100 }} style="z-index: {zIndexes.tooltip}">
<TooltipInner {documentationLink} {markdownTooltip}>
<slot name="text" />
{/if}
{#if documentationLink}
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
<div class="flex flex-row gap-2 mt-4">
See documentation
<ExternalLink size="16" />
</div>
</a>
{/if}
</TooltipInner>
</div>
{/if}
@@ -50,7 +50,7 @@
</script>
<Portal name="run-row">
<ScheduleEditor on:update={() => goto('/schedules')} bind:this={scheduleEditor} />
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
</Portal>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -6,6 +6,7 @@
let name: string = ''
export let customName: string | undefined = undefined
export let disabled: boolean = false
const dispatch = createEventDispatcher()
@@ -15,7 +16,7 @@
}
</script>
<Popover closeButton={false} class="w-full">
<Popover closeButton={false} class="w-full" {disabled}>
<svelte:fragment slot="trigger">
<slot name="trigger" />
</svelte:fragment>
@@ -30,6 +31,7 @@
close()
}
}}
{disabled}
/>
<Button
variant="contained"
@@ -40,7 +42,7 @@
addField()
close()
}}
disabled={!name}
disabled={!name || disabled}
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
>
Add {customName ? customName.toLowerCase() : 'field'}
@@ -76,7 +76,11 @@
</script>
<div
class={twMerge('h-full', rounded ? 'rounded-md' : '', noBorder ? 'border-0' : 'border')}
class={twMerge(
'h-full',
rounded ? 'rounded-md overflow-hidden' : '',
noBorder ? 'border-0' : 'border'
)}
bind:clientHeight={tableHeight}
>
<List justify="between" gap="none" hFull={true}>
+21 -21
View File
@@ -1,5 +1,7 @@
import type { CaptureTriggerKind, TriggersCount } from '$lib/gen'
import type { Writable } from 'svelte/store'
import { type Writable } from 'svelte/store'
import { formatCron } from '$lib/utils'
import { Triggers } from './triggers/triggers.svelte'
export type ScheduleTrigger = {
summary: string | undefined
@@ -11,34 +13,32 @@ export type ScheduleTrigger = {
}
export type TriggerContext = {
selectedTrigger: Writable<TriggerKind>
primarySchedule: Writable<ScheduleTrigger | undefined | false>
triggersCount: Writable<TriggersCount | undefined>
simplifiedPoll: Writable<boolean | undefined>
defaultValues: Writable<Record<string, any> | undefined>
captureOn: Writable<boolean | undefined>
showCaptureHint: Writable<boolean | undefined>
triggersState: Triggers
}
export function setScheduledPollSchedule(
primarySchedule: Writable<ScheduleTrigger | undefined | false>,
triggersState: Triggers,
triggersCount: Writable<TriggersCount | undefined>
) {
const cron = '0 */5 * * * *'
primarySchedule.set({
enabled: true,
summary: 'Check for new events every 5 minutes',
cron: cron,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {}
})
triggersCount.update((triggersCount) => {
return {
...(triggersCount ?? {}),
schedule_count: (triggersCount?.schedule_count ?? 0) + 1,
primary_schedule: { schedule: cron }
const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)
if (primarySchedule !== -1) {
triggersState.selectedTriggerIndex = primarySchedule
} else {
const draftCfg = {
enabled: true,
summary: 'Check for new events every 5 minutes',
schedule: formatCron('0 */5 * * * *'),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
args: {},
is_flow: true
}
})
triggersState.addDraftTrigger(triggersCount, 'schedule', undefined, draftCfg)
triggersState.selectedTriggerIndex = triggersState.triggers.length - 1
}
}
export type TriggerKind =
@@ -74,7 +74,7 @@ export function captureTriggerKindToTriggerKind(kind: CaptureTriggerKind): Trigg
case 'sqs':
return 'sqs'
case 'postgres':
return 'postgres'
return 'postgres'
case 'gcp':
return 'gcp'
default:
@@ -0,0 +1,133 @@
<script lang="ts">
import { triggerIconMap, type TriggerType } from './utils'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { SchedulePollIcon } from '$lib/components/icons'
import type { Placement } from '@floating-ui/core'
import { isCloudHosted } from '$lib/cloud'
import { CloudOff } from 'lucide-svelte'
interface Props {
setDropdownWidthToButtonWidth?: boolean
children?: import('svelte').Snippet
triggerScriptPicker?: import('svelte').Snippet | undefined
class?: string
placement?: Placement
isEditor?: boolean
onAddDraftTrigger?: (type: TriggerType) => void
onAddScheduledPoll?: () => void
onClose?: () => void
}
let {
setDropdownWidthToButtonWidth = false,
children,
class: className,
triggerScriptPicker,
placement = 'bottom',
isEditor = false,
onAddDraftTrigger,
onAddScheduledPoll,
onClose
}: Props = $props()
let dropdown: DropdownV2 | undefined
const cloudHosted = isCloudHosted()
// Dropdown items for adding new triggers
const addTriggerItems: Item[] = [
{
displayName: 'Schedule',
action: () => onAddDraftTrigger?.('schedule'),
icon: triggerIconMap.schedule
},
{ displayName: 'HTTP', action: () => onAddDraftTrigger?.('http'), icon: triggerIconMap.http },
{
displayName: 'WebSocket',
action: () => onAddDraftTrigger?.('websocket'),
icon: triggerIconMap.websocket,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Postgres',
action: () => onAddDraftTrigger?.('postgres'),
icon: triggerIconMap.postgres,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Kafka',
action: () => onAddDraftTrigger?.('kafka'),
icon: triggerIconMap.kafka,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'NATS',
action: () => onAddDraftTrigger?.('nats'),
icon: triggerIconMap.nats,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'MQTT',
action: () => onAddDraftTrigger?.('mqtt'),
icon: triggerIconMap.mqtt,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'SQS',
action: () => onAddDraftTrigger?.('sqs'),
icon: triggerIconMap.sqs,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'GCP Pub/Sub',
action: () => onAddDraftTrigger?.('gcp'),
icon: triggerIconMap.gcp,
extra: cloudHosted ? extra : undefined
},
{
displayName: 'Scheduled Poll',
action: (e) => {
e.preventDefault()
onAddDraftTrigger?.('poll')
onAddScheduledPoll?.()
},
icon: SchedulePollIcon,
hidden: !isEditor
}
].filter((item) => !item.hidden)
let triggersButtonWidth = $state(0)
export function close() {
dropdown?.close()
}
</script>
{#snippet extra()}
<p
class="text-xs text-yellow-700 dark:text-yellow-100/90 bg-yellow-50 dark:bg-yellow-900/40 rounded-md p-1 px-2 -my-1"
title="Disabled in multi-tenant cloud"
>
<CloudOff size={14} />
</p>
{/snippet}
<DropdownV2
bind:this={dropdown}
items={addTriggerItems}
{placement}
class={className}
customWidth={setDropdownWidthToButtonWidth ? triggersButtonWidth : undefined}
usePointerDownOutside
customMenu={!!triggerScriptPicker}
on:close={() => onClose?.()}
>
{#snippet buttonReplacement()}
<div class={className} bind:clientWidth={triggersButtonWidth}>
{@render children?.()}
</div>
{/snippet}
{#snippet menu()}
{@render triggerScriptPicker?.()}
{/snippet}
</DropdownV2>
@@ -1,4 +1,4 @@
<script context="module" lang="ts">
<script module lang="ts">
export type CaptureInfo = {
active: boolean
hasPreprocessor: boolean
@@ -6,33 +6,70 @@
isFlow: boolean
path: string
connectionInfo: ConnectionInfo | undefined
loading?: boolean
}
</script>
<script lang="ts">
import { slide } from 'svelte/transition'
import { fade } from 'svelte/transition'
import AnimatedButton from '../common/button/AnimatedButton.svelte'
import PulseButton from '../common/button/PulseButton.svelte'
import Button from '../common/button/Button.svelte'
import { CircleStop } from 'lucide-svelte'
import { CircleStop, History, Play, Loader2 } from 'lucide-svelte'
import ConnectionIndicator, {
type ConnectionInfo
} from '../common/alert/ConnectionIndicator.svelte'
import CaptureTable from './CaptureTable.svelte'
import { createEventDispatcher, onDestroy, getContext } from 'svelte'
import type { CaptureTriggerKind } from '$lib/gen'
import { createEventDispatcher, onDestroy, getContext, onMount } from 'svelte'
import type { CaptureTriggerKind, Capture } from '$lib/gen'
import CaptureIcon from './CaptureIcon.svelte'
import Tooltip from '../Tooltip.svelte'
import type { TriggerContext } from '$lib/components/triggers'
export let disabled: boolean
export let captureType: CaptureTriggerKind
export let captureInfo: CaptureInfo
export let captureTable: CaptureTable | undefined
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { Popover } from '$lib/components/meltComponents'
import { CaptureService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { isObject, sendUserToast } from '$lib/utils'
import { triggerIconMap } from './utils'
import { formatDateShort } from '$lib/utils'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import DisplayResultControlBar from '$lib/components/DisplayResultControlBar.svelte'
import { base } from '$lib/base'
import Description from '$lib/components/Description.svelte'
import { twMerge } from 'tailwind-merge'
import { FlaskConical } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
interface Props {
disabled?: boolean | undefined
captureType: CaptureTriggerKind
captureInfo: CaptureInfo
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
description?: import('svelte').Snippet
children?: import('svelte').Snippet
displayAlert?: boolean
}
let {
disabled = undefined,
captureType,
captureInfo,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
description,
children,
displayAlert = false
}: Props = $props()
const testKind: 'preprocessor' | 'main' = $derived(hasPreprocessor ? 'preprocessor' : 'main')
const dispatch = createEventDispatcher<{
captureToggle: { disableOnly?: boolean }
updateSchema: { payloadData: Record<string, any>; redirect: boolean }
updateSchema: { payloadData: Record<string, any>; redirect: boolean; args?: boolean }
addPreprocessor: null
testWithArgs: Record<string, any>
applyArgs: { kind: 'main' | 'preprocessor'; args: Record<string, any> }
}>()
const { showCaptureHint } = getContext<TriggerContext>('TriggerContext')
@@ -44,16 +81,16 @@
disableOnly: true
})
}
stopCaptureListening()
})
function handleUpdateSchema(e: any) {
/* function handleUpdateSchema(e: any) {
dispatch('updateSchema', {
payloadData: e.detail.payloadData,
redirect: e.detail.redirect
})
}
} */
let openingDuration = 400
let pulseButton: PulseButton | undefined
function updateShowCaptureHint(show: boolean | undefined) {
if (show) {
@@ -63,77 +100,400 @@
}, 300)
}
}
$: updateShowCaptureHint($showCaptureHint)
$effect(() => {
updateShowCaptureHint($showCaptureHint)
})
let selectedCapture: Capture | undefined = $state(undefined)
function handleSelectCapture(e: any) {
if (e.detail) {
selectCapture(e.detail)
} else if (lastCapture) {
selectCapture(lastCapture)
}
}
// New code for capture fetching and management
let lastCapture: Capture | undefined = $state(undefined)
let newCaptureReceived = $state(false)
let isLoadingBigPayload = $state(false)
let capturePollingInterval: ReturnType<typeof setInterval> | undefined = undefined
let lastCaptureId: number | undefined = undefined
let displayResult: DisplayResult | undefined = $state(undefined)
let toolbarLocation: 'internal' | 'external' | undefined = $state(undefined)
function selectCapture(capture: Capture) {
selectedCapture = capture
if (
capture.main_args === 'WINDMILL_TOO_BIG' ||
capture.preprocessor_args === 'WINDMILL_TOO_BIG'
) {
loadBigPayload(capture)
}
}
// Function to fetch the last capture when component mounts
async function fetchLastCapture() {
try {
if (!captureInfo.path) return
const captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: captureInfo.isFlow ? 'flow' : 'script',
path: captureInfo.path,
triggerKind: captureType,
page: 1,
perPage: 1
})
if (captures.length > 0) {
lastCapture = captures[0]
lastCaptureId = lastCapture.id
selectCapture(lastCapture)
}
} catch (error) {
console.error('Failed to fetch last capture:', error)
}
}
// Function to listen for new captures (polls every 100ms)
function listenForCaptures() {
if (capturePollingInterval) return
capturePollingInterval = setInterval(async () => {
if (!captureInfo.active) return
try {
const captures = await CaptureService.listCaptures({
workspace: $workspaceStore!,
runnableKind: captureInfo.isFlow ? 'flow' : 'script',
path: captureInfo.path,
triggerKind: captureType,
page: 1,
perPage: 1
})
if (captures.length > 0 && lastCaptureId !== captures[0].id) {
lastCapture = captures[0]
lastCaptureId = lastCapture.id
// Trigger animation for new capture
showNewCaptureAnimation()
selectCapture(lastCapture)
}
} catch (error) {
console.error('Error polling for new captures:', error)
}
}, 100)
}
// Stop listening for captures
function stopCaptureListening() {
if (capturePollingInterval) {
clearInterval(capturePollingInterval)
capturePollingInterval = undefined
}
}
// Show animation when new capture arrives
function showNewCaptureAnimation() {
newCaptureReceived = true
setTimeout(() => {
newCaptureReceived = false
}, 2000) // Animation duration
}
// Load big payload when requested
async function loadBigPayload(capture: Capture | undefined) {
if (!capture) return
try {
isLoadingBigPayload = true
const fullCapture = await CaptureService.getCapture({
workspace: $workspaceStore!,
id: capture.id
})
capture.main_args = fullCapture.main_args
capture.preprocessor_args = fullCapture.preprocessor_args
isLoadingBigPayload = false
} catch (error) {
sendUserToast('Failed to load large payload', true)
isLoadingBigPayload = false
}
}
function getCapturePayload(capture: Capture) {
let payloadData: any = {}
const preprocessor_args = isObject(capture.preprocessor_args) ? capture.preprocessor_args : {}
if ('wm_trigger' in preprocessor_args) {
// v1
payloadData =
testKind === 'preprocessor'
? {
...(typeof capture.main_args === 'object' ? capture.main_args : {}),
...preprocessor_args
}
: capture.main_args
} else {
// v2
payloadData = testKind === 'preprocessor' ? capture.preprocessor_args : capture.main_args
}
return payloadData
}
// Start or stop capture listening based on active state
$effect(() => {
if (captureInfo.active) {
listenForCaptures()
} else {
stopCaptureListening()
}
})
// Fetch last capture when component mounts
onMount(() => {
fetchLastCapture()
})
</script>
<div transition:slide={{ duration: openingDuration }} class="pb-12 overflow-hidden">
<div class="border p-4 rounded-lg">
<div class="flex flex-col gap-1 mb-4">
<div class="flex flex-row items-center justify-start gap-1">
<PulseButton bind:this={pulseButton} numberOfPulses={1} pulseDuration={1}>
<AnimatedButton
animate={captureInfo.active || captureInfo.loading}
baseRadius="6px"
wrapperClasses="ml-[-2px]"
>
<Button
size="xs2"
on:click={() => dispatch('captureToggle', {})}
variant="border"
{disabled}
color="light"
btnClasses={captureInfo.active ? 'text-blue-500 hover:text-blue-500' : ''}
<Splitpanes>
<Pane size={50} minSize={30}>
<div
class="flex flex-col gap-1 px-4 py-2 h-full w-full overflow-auto"
style="scrollbar-gutter: stable"
>
<div class="text-sm text-secondary flex items-center gap-1">
<FlaskConical size={16} />
Test trigger
</div>
<div class="flex flex-col gap-1 mb-4 w-full">
<div class="flex justify-center w-full">
<div class="relative h-fit">
<AnimatedButton
animate={captureInfo.active}
wrapperClasses={captureInfo.active ? 'm-[-2px]' : ''}
baseRadius="7px"
>
<div class="flex flex-row items-center gap-1 w-28 justify-center">
<Button
size="xs"
on:click={() => dispatch('captureToggle', {})}
{disabled}
color={captureInfo.active ? 'light' : 'dark'}
btnClasses={captureInfo.active ? 'text-blue-500' : ''}
startIcon={captureInfo.active
? { icon: CircleStop }
: { icon: CaptureIcon, props: { variant: 'redDot' } }}
loading={captureLoading}
>
{#if captureInfo.active}
<CircleStop size={14} />
<p class="w-24" transition:fade={{ duration: 300 }}>Stop capturing</p>
{:else}
<CaptureIcon variant="redDot" size={14} />
<p class="w-24" transition:fade={{ duration: 300 }}>Start capturing</p>
{/if}
{captureInfo.active ? 'Stop' : 'Start capturing'}
</div>
</Button>
</AnimatedButton>
</PulseButton>
</Button>
</AnimatedButton>
{#if captureInfo.active}
<ConnectionIndicator connectionInfo={captureInfo.connectionInfo} />
{:else}
<Tooltip>
Start capturing to test your runnables with real data. Once active, all incoming
payloads will be captured and displayed below, allowing you to test your runnables
effectively.
</Tooltip>
<div class="absolute top-1/2 -translate-y-1/2 -right-5">
{#if captureInfo.active}
<ConnectionIndicator connectionInfo={captureInfo.connectionInfo} />
{/if}
</div>
</div>
</div>
<div class="mt-4 mb-2">
{#if displayAlert}
<Alert type="warning" title="Trigger deployed" size="xs" class="mb-4">
Capturing will suscribe to the trigger endpoint. Treat carefully.
</Alert>
{/if}
<Description>
<div class="relative min-h-8">
{#key (captureInfo.active, disabled)}
<div
class={twMerge(
'absolute top-0 left-0 w-full text-center',
disabled === true ? 'text-red-600 dark:text-red-400' : ''
)}
in:fade={{ duration: 100, delay: 50 }}
out:fade={{ duration: 50 }}
>
{#if disabled === true}
Enter a valid configuration to start capturing.
{:else}
{@render description?.()}
{/if}
</div>
{/key}
</div>
</Description>
</div>
{#if children}
<div class="grow min-h-0 flex flex-col gap-4">
{@render children?.()}
</div>
{/if}
</div>
</div>
</Pane>
<Pane minSize={30} class="flex flex-col">
<div class="flex flex-row gap-1 justify-between min-h-[33.5px] pl-1 pr-4">
<div class="flex flex-row gap-1 items-center">
{#if lastCapture}
<Popover
placement="left"
contentClasses="w-48 min-h-48 max-h-64 overflow-auto"
floatingConfig={{
placement: 'left-start',
offset: { mainAxis: 8, crossAxis: -4.5 },
gutter: 0 // hack to make offset effective, see https://github.com/melt-ui/melt-ui/issues/528
}}
usePointerDownOutside
>
{#snippet trigger()}
<Button
size="xs2"
color="light"
iconOnly
startIcon={{ icon: History }}
nonCaptureEvent
btnClasses="h-[27px]"
></Button>
{/snippet}
{#snippet content()}
<CaptureTable
{captureType}
isFlow={captureInfo.isFlow}
path={captureInfo.path}
on:selectCapture={handleSelectCapture}
fullHeight
headless
addButton={false}
noBorder
/>
{/snippet}
</Popover>
{/if}
{#if selectedCapture}
{@const SvelteComponent = triggerIconMap[captureType]}
<div
class={'min-w-16 text-secondary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2 h-[27px]'}
>
<SvelteComponent size={12} />
<span class="text-xs text-secondary truncate">
Capture {formatDateShort(selectedCapture?.created_at)}
</span>
</div>
{/if}
{#if selectedCapture}
{@const label = isFlow && testKind === 'main' ? 'Test flow with args' : 'Apply args'}
{@const title =
isFlow && testKind === 'main'
? 'Test flow using captured data'
: testKind === 'preprocessor'
? 'Apply args to preprocessor'
: 'Apply args to inputs'}
<Button
size="xs2"
color="dark"
btnClasses="h-[27px]"
dropdownItems={[
{
label: 'Use as input schema',
onClick: async () => {
if (!lastCapture) return
const payloadData = selectedCapture?.main_args
dispatch('updateSchema', {
payloadData: payloadData ?? {},
redirect: true,
args: true
})
},
disabled: !selectedCapture,
hidden: !isFlow || testKind !== 'main'
}
].filter((item) => !item.hidden)}
on:click={async () => {
if (!selectedCapture) return
const payloadData = selectedCapture?.main_args ?? {}
if (isFlow && testKind === 'main') {
dispatch('testWithArgs', payloadData)
} else {
const trigger_extra = isObject(selectedCapture.preprocessor_args)
? selectedCapture.preprocessor_args
: {}
dispatch('applyArgs', {
kind: testKind,
args: { ...structuredClone(payloadData), ...trigger_extra }
})
}
}}
disabled={testKind === 'preprocessor' && !hasPreprocessor}
{title}
startIcon={isFlow && testKind === 'main' ? { icon: Play } : {}}
>
{label}
</Button>
{/if}
</div>
{#if disabled}
<div class="text-sm font-normal text-red-600 dark:text-red-400" transition:slide>
Enter a valid configuration to start capturing.
</div>
{#if displayResult && toolbarLocation === 'external'}
<DisplayResultControlBar
{base}
result={selectedCapture?.main_args}
disableTooltips={false}
on:open-drawer={() => {
if (displayResult && typeof displayResult.openDrawer === 'function') {
displayResult.openDrawer()
}
}}
/>
{/if}
</div>
<div class="grow min-h-0 rounded-md w-full pl-2 py-1 pb-2 overflow-auto">
{#if isLoadingBigPayload}
<Loader2 class="animate-spin" />
{:else if selectedCapture?.main_args}
<div class="bg-surface rounded-md text-sm" class:animate-highlight={newCaptureReceived}>
<DisplayResult
bind:this={displayResult}
workspaceId={undefined}
jobId={undefined}
result={getCapturePayload(selectedCapture)}
externalToolbarAvailable
on:toolbar-location-changed={({ detail }) => {
toolbarLocation = detail
}}
/>
</div>
{:else}
<div class="text-center text-tertiary p-4 bg-surface rounded-md"
>No captures to show yet.</div
>
{/if}
</div>
</Pane>
</Splitpanes>
{#if $$slots.default}
<div class:opacity-50={disabled || !captureInfo.active} class="flex flex-col gap-4 mb-4">
<slot />
</div>
{/if}
<style>
@keyframes highlight {
0% {
color: rgba(59, 130, 246, 1);
background-color: rgba(59, 130, 246, 0.1);
}
100% {
color: inherit;
}
}
<CaptureTable
bind:this={captureTable}
{captureType}
hasPreprocessor={captureInfo.hasPreprocessor}
canHavePreprocessor={captureInfo.canHavePreprocessor}
isFlow={captureInfo.isFlow}
path={captureInfo.path}
canEdit={true}
on:applyArgs
on:updateSchema={handleUpdateSchema}
on:addPreprocessor
on:testWithArgs
fullHeight={false}
captureActiveIndicator={captureInfo.active}
/>
</div>
</div>
.animate-highlight {
animation: highlight 2s ease-out forwards;
}
</style>
@@ -30,6 +30,7 @@
export let canEdit = false
export let fullHeight = true
export let limitPayloadSize = false
export let noBorder = false
export let captureActiveIndicator: boolean | undefined = undefined
let selected: number | undefined = undefined
@@ -59,6 +60,7 @@
}
select: any
testWithArgs: any
selectCapture: Capture | undefined
}>()
interface CaptureWithPayload extends Capture {
@@ -141,6 +143,7 @@
const payloadData = await getPayload(capture)
selected = capture.id
dispatch('select', structuredClone(payloadData))
dispatch('selectCapture', capture)
}
}
@@ -271,6 +274,7 @@
on:error={(e) => handleError(e.detail)}
on:select={(e) => handleSelect(e.detail)}
bind:length={capturesLength}
{noBorder}
neverShowLoader={captureActiveIndicator !== undefined}
>
<svelte:fragment slot="columns">
@@ -388,7 +392,7 @@
</SchemaPickerRow>
</svelte:fragment>
<svelte:fragment slot="empty">
<div class="text-center text-xs text-tertiary my-2">No captures yet</div>
<div class="text-center text-xs text-tertiary py-2">No captures yet</div>
</svelte:fragment>
</InfiniteList>
</div>
@@ -2,36 +2,55 @@
import { workspaceStore } from '$lib/stores'
import { CaptureService, type CaptureConfig, type CaptureTriggerKind } from '$lib/gen'
import { onDestroy } from 'svelte'
import { capitalize, isObject, sendUserToast, sleep } from '$lib/utils'
import { isCloudHosted } from '$lib/cloud'
import Alert from '../common/alert/Alert.svelte'
import RouteEditorConfigSection from './http/RouteEditorConfigSection.svelte'
import WebsocketEditorConfigSection from './websocket/WebsocketEditorConfigSection.svelte'
import WebhooksConfigSection from './webhook/WebhooksConfigSection.svelte'
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte'
import { isObject, sendUserToast, sleep } from '$lib/utils'
import RouteCapture from './http/RouteCapture.svelte'
import type { ConnectionInfo } from '../common/alert/ConnectionIndicator.svelte'
import type { CaptureInfo } from './CaptureSection.svelte'
import CaptureTable from './CaptureTable.svelte'
import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte'
import MqttEditorConfigSection from './mqtt/MqttEditorConfigSection.svelte'
import SqsTriggerEditorConfigSection from './sqs/SqsTriggerEditorConfigSection.svelte'
import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte'
import { invalidRelations } from './postgres/utils'
import { DEFAULT_V3_CONFIG, DEFAULT_V5_CONFIG } from './mqtt/constant'
import GcpTriggerEditorConfigSection from './gcp/GcpTriggerEditorConfigSection.svelte'
import WebhooksCapture from './webhook/WebhooksCapture.svelte'
import EmailTriggerCaptures from '../details/EmailTriggerCaptures.svelte'
import WebsocketCapture from './websocket/WebsocketCapture.svelte'
import PostgresCapture from './postgres/PostgresCapture.svelte'
import KafkaCapture from './kafka/KafkaCapture.svelte'
import NatsCapture from './nats/NatsCapture.svelte'
import MqttCapture from './mqtt/MqttCapture.svelte'
import SqsCapture from './sqs/SqsCapture.svelte'
import GcpCapture from './gcp/GcpCapture.svelte'
export let isFlow: boolean
export let path: string
export let hasPreprocessor: boolean
export let canHavePreprocessor: boolean
export let captureType: CaptureTriggerKind = 'webhook'
export let showCapture = false
export let data: any = {}
export let connectionInfo: ConnectionInfo | undefined = undefined
export let loading = false
export let args: Record<string, any> = {}
export let captureTable: CaptureTable | undefined = undefined
interface Props {
isFlow: boolean
path: string
hasPreprocessor: boolean
canHavePreprocessor: boolean
captureType?: CaptureTriggerKind
data?: any
connectionInfo?: ConnectionInfo | undefined
args?: Record<string, any>
isValid?: boolean
triggerDeployed?: boolean
}
let {
isFlow,
path,
hasPreprocessor,
canHavePreprocessor,
captureType = 'webhook',
data = {},
connectionInfo = $bindable(undefined),
args = $bindable({}),
isValid = false,
triggerDeployed = false
}: Props = $props()
let captureLoading = $state(false)
let captureActive = $state(false)
let captureConfigs: {
[key: string]: CaptureConfig
} = $state({})
let ready = $state(false)
const config: CaptureConfig | undefined = $derived(captureConfigs[captureType])
export async function setConfig(): Promise<boolean> {
if (captureType === 'postgres') {
@@ -39,18 +58,18 @@
sendUserToast('Table to track must be set', true)
return false
}
if (
invalidRelations(args.publication.table_to_track, {
showError: true,
trackSchemaTableError: true
}) === true
}) !== ''
) {
return false
}
}
try {
loading = true
args = await CaptureService.setCaptureConfig({
await CaptureService.setCaptureConfig({
requestBody: {
trigger_kind: captureType,
path,
@@ -59,22 +78,19 @@
},
workspace: $workspaceStore!
})
loading = false
return true
} catch (error) {
loading = false
sendUserToast(error.body, true)
return false
}
}
let captureActive = false
let captureConfigs: {
[key: string]: CaptureConfig
} = {}
const STREAMING_CAPTURES = ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp']
function isStreamingCapture() {
if (captureType === 'gcp' && args.delivery_type === 'push') {
return false
}
return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'].includes(captureType)
}
async function getCaptureConfigs() {
const captureConfigsList = await CaptureService.getCaptureConfigs({
@@ -82,10 +98,12 @@
runnableKind: isFlow ? 'flow' : 'script',
path
})
captureConfigs = captureConfigsList.reduce((acc, c) => {
acc[c.trigger_kind] = c
return acc
}, {})
if (isStreamingCapture() && captureActive) {
const config = captureConfigs[captureType]
if (config && config.error) {
@@ -115,30 +133,15 @@
}
i++
await sleep(1000)
captureTable?.loadCaptures(true)
}
}
let ready = false
function setDefaultArgs(captureConfigs: { [key: string]: CaptureConfig }) {
if (captureType in captureConfigs) {
const triggerConfig = captureConfigs[captureType].trigger_config
args = isObject(triggerConfig) ? triggerConfig : {}
} else {
switch (captureType) {
case 'mqtt':
//define these field so any reactive statement that may use them will not crash trying to access their property
args = {
v3_config: DEFAULT_V3_CONFIG,
v5_config: DEFAULT_V5_CONFIG,
client_version: 'v5',
subscribe_topics: []
}
break
default:
args = {}
}
args = {}
}
ready = true
}
@@ -158,25 +161,19 @@
if (captureActive || e.detail.disableOnly) {
captureActive = false
} else {
const configSet = await setConfig()
if (configSet) {
capture()
try {
captureLoading = true
const configSet = await setConfig()
if (configSet) {
capture()
}
} finally {
captureLoading = false
}
}
}
let config: CaptureConfig | undefined
$: config = captureConfigs[captureType]
let cloudDisabled = STREAMING_CAPTURES.includes(captureType) && isCloudHosted()
function isStreamingCapture() {
if (captureType === 'gcp' && args.delivery_type === 'push') {
return false
}
return ['mqtt', 'sqs', 'websocket', 'postgres', 'kafka', 'nats', 'gcp'].includes(captureType)
}
function updateConnectionInfo(config: CaptureConfig | undefined, captureActive: boolean) {
if (isStreamingCapture() && config && captureActive) {
const serverEnabled = getServerEnabled(config)
@@ -192,37 +189,31 @@
connectionInfo = undefined
}
}
$: updateConnectionInfo(config, captureActive)
$effect(() => {
updateConnectionInfo(config, captureActive)
})
let captureInfo: CaptureInfo
$: captureInfo = {
let captureInfo: CaptureInfo = $derived({
active: captureActive,
hasPreprocessor,
canHavePreprocessor,
isFlow,
path,
connectionInfo,
loading: loading
}
connectionInfo
})
$: args && (captureActive = false)
$effect(() => {
args && (captureActive = false)
})
</script>
{#key ready}
<div class="flex flex-col gap-4 w-full">
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
{capitalize(captureType)} triggers are disabled in the multi-tenant cloud.
</Alert>
{:else if captureType === 'websocket'}
<WebsocketEditorConfigSection
can_write={true}
headless={true}
bind:url={args.url}
bind:url_runnable_args={args.url_runnable_args}
{showCapture}
<div class="flex flex-col gap-4 w-full h-full">
{#if captureType === 'websocket'}
<WebsocketCapture
{isValid}
{captureInfo}
bind:captureTable
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -230,14 +221,13 @@
on:testWithArgs
/>
{:else if captureType === 'postgres'}
<PostgresEditorConfigSection
bind:postgres_resource_path={args.postgres_resource_path}
bind:publication={args.publication}
{showCapture}
<PostgresCapture
{captureInfo}
can_write={true}
headless={true}
bind:captureTable
{captureLoading}
postgres_resource_path={args.postgres_resource_path}
{hasPreprocessor}
{isFlow}
{triggerDeployed}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -245,16 +235,13 @@
on:testWithArgs
/>
{:else if captureType === 'webhook'}
<WebhooksConfigSection
<WebhooksCapture
{hasPreprocessor}
{isFlow}
{path}
hash={data?.hash}
token={data?.token}
runnableArgs={data?.args}
scopes={data?.scopes}
{showCapture}
{captureInfo}
bind:captureTable
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -262,18 +249,15 @@
on:testWithArgs
/>
{:else if captureType === 'http'}
<RouteEditorConfigSection
{showCapture}
can_write={true}
<RouteCapture
runnableArgs={data?.args}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
bind:raw_string={args.raw_string}
bind:wrap_body={args.wrap_body}
capture_mode={true}
headless
route_path={args.route_path}
http_method={args.http_method}
{isValid}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{isFlow}
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -281,16 +265,13 @@
on:testWithArgs
/>
{:else if captureType === 'email'}
<EmailTriggerConfigSection
hash={data?.hash}
token={data?.token}
<EmailTriggerCaptures
{path}
{isFlow}
userSettings={data?.userSettings}
emailDomain={data?.emailDomain}
{showCapture}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -298,14 +279,13 @@
on:testWithArgs
/>
{:else if captureType === 'kafka'}
<KafkaTriggersConfigSection
headless={true}
{path}
bind:args
staticInputDisabled={false}
{showCapture}
<KafkaCapture
{isValid}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{isFlow}
{captureLoading}
{triggerDeployed}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -313,32 +293,27 @@
on:testWithArgs
/>
{:else if captureType === 'nats'}
<NatsTriggersConfigSection
headless={true}
bind:args
{path}
staticInputDisabled={false}
{showCapture}
<NatsCapture
{isValid}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{isFlow}
{captureLoading}
{triggerDeployed}
on:applyArgs
on:updateSchema
on:addPreprocessor
on:captureToggle={handleCapture}
on:testWithArgs
/>
{:else if captureType === 'mqtt'}
<MqttEditorConfigSection
can_write={true}
headless={true}
{showCapture}
<MqttCapture
{isValid}
{captureInfo}
bind:v3_config={args.v3_config}
bind:v5_config={args.v5_config}
bind:client_version={args.client_version}
bind:subscribe_topics={args.subscribe_topics}
bind:mqtt_resource_path={args.mqtt_resource_path}
bind:client_id={args.client_id}
bind:captureTable
{hasPreprocessor}
{isFlow}
{captureLoading}
{triggerDeployed}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -346,16 +321,13 @@
on:testWithArgs
/>
{:else if captureType === 'sqs'}
<SqsTriggerEditorConfigSection
can_write={true}
headless={true}
bind:queue_url={args.queue_url}
bind:aws_resource_path={args.aws_resource_path}
bind:message_attributes={args.message_attributes}
bind:aws_auth_resource_type={args.aws_auth_resource_type}
{showCapture}
<SqsCapture
{isValid}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{isFlow}
{captureLoading}
{triggerDeployed}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -363,21 +335,14 @@
on:testWithArgs
/>
{:else if captureType === 'gcp'}
<GcpTriggerEditorConfigSection
can_write={true}
headless={true}
bind:gcp_resource_path={args.gcp_resource_path}
bind:topic_id={args.topic_id}
bind:subscription_id={args.subscription_id}
bind:cloud_subscription_id={args.subscription_id}
bind:create_update_subscription_id={args.subscription_id}
bind:delivery_config={args.delivery_config}
bind:delivery_type={args.delivery_type}
bind:subscription_mode={args.subscription_mode}
bind:base_endpoint={args.base_endpoint}
{showCapture}
<GcpCapture
{isValid}
{captureInfo}
bind:captureTable
{hasPreprocessor}
{isFlow}
{triggerDeployed}
deliveryType={args.delivery_type}
{captureLoading}
on:applyArgs
on:updateSchema
on:addPreprocessor
@@ -0,0 +1,137 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { Trash, Save, RotateCcw } from 'lucide-svelte'
import { type Snippet } from 'svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { Tooltip } from '../meltComponents'
interface Props {
isDraftOnly: any
hasDraft: any
saveDisabled: any
enabled: boolean | undefined
allowDraft: any
edit: any
isLoading: any
permissions: 'write' | 'create' | 'none'
isDeployed: boolean
extra?: Snippet
onDelete?: () => void
onReset?: () => void
onToggleEnabled?: (enabled: boolean) => void
onUpdate?: () => void
cloudDisabled?: boolean
triggerType?: string
}
let {
isDraftOnly,
hasDraft,
saveDisabled,
enabled,
allowDraft,
edit,
isLoading,
permissions,
isDeployed,
extra,
onDelete,
onReset,
onToggleEnabled,
onUpdate,
cloudDisabled = false
}: Props = $props()
const canSave = $derived((permissions === 'write' && edit) || permissions === 'create')
</script>
{#if !allowDraft}
{@render extra?.()}
{#if edit && enabled !== undefined}
<Toggle
size="sm"
disabled={permissions === 'none'}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={({ detail }) => {
onToggleEnabled?.(detail)
}}
/>
{/if}
{#if canSave}
<Button
size="sm"
startIcon={{ icon: Save }}
disabled={saveDisabled}
on:click={() => {
onUpdate?.()
}}
loading={isLoading}
>
Save
</Button>
{/if}
{:else}
<div class="flex flex-row gap-2 items-center">
{#if !isDraftOnly && !hasDraft && enabled !== undefined}
<div class="center-center">
<Toggle
size="2sm"
disabled={permissions === 'none'}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={({ detail }) => {
onToggleEnabled?.(detail)
}}
/>
</div>
{/if}
{#if isDraftOnly}
<Button
size="xs"
startIcon={{ icon: Trash }}
iconOnly
color={'light'}
on:click={() => {
onDelete?.()
}}
btnClasses="hover:bg-red-500 hover:text-white"
/>
{:else if hasDraft}
<Button
size="xs"
startIcon={{ icon: RotateCcw }}
color={'light'}
on:click={() => {
onReset?.()
}}
>
Reset changes
</Button>
{/if}
{#if canSave && (isDraftOnly || hasDraft)}
<Tooltip placement="bottom-end" disablePopup={!saveDisabled && !cloudDisabled && isDeployed}>
<Button
size="xs"
startIcon={{ icon: Save }}
disabled={saveDisabled || cloudDisabled || !isDeployed}
on:click={() => {
onUpdate?.()
}}
loading={isLoading}
>
{isDraftOnly ? 'Deploy' : 'Update'}
</Button>
<span slot="text">
{#if !isDeployed}
Deploy the runnable to enable trigger creation
{:else if cloudDisabled}
This trigger is disabled in the multi-tenant cloud
{:else}
Enter a valid config to {isDraftOnly ? 'deploy' : 'update'} the trigger
{/if}
</span>
</Tooltip>
{/if}
</div>
{/if}
@@ -0,0 +1,43 @@
<script lang="ts">
import { getTriggerLabel, type Trigger } from './utils'
import { twMerge } from 'tailwind-merge'
let { trigger, discard }: { trigger: Trigger; discard?: boolean } = $props()
let label = $derived.by(() => getTriggerLabel(trigger))
</script>
<span
class={twMerge(
trigger.isDraft ? 'text-frost-400 italic dark:text-frost-200' : 'font-normal',
'truncate pr-1',
discard ? 'line-through' : ''
)}
title={label}
>
{label}
</span>
{#if trigger.isPrimary}
<span
class="ml-2 bg-blue-50 dark:bg-blue-900/40 px-1.5 py-0.5 rounded text-xs text-blue-700 dark:text-blue-100 whitespace-nowrap"
>
Primary
</span>
{/if}
{#if trigger.draftConfig && !trigger.isDraft}
<span
class="ml-2 text-xs bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-100 px-1.5 py-0.5 rounded whitespace-nowrap"
>
Modified
</span>
{/if}
{#if trigger.isDraft}
<span
class="ml-2 text-xs bg-frost-400 dark:bg-frost-900 text-white px-1.5 py-0.5 rounded whitespace-nowrap"
>
New
</span>
{/if}
@@ -1,269 +1,348 @@
<script lang="ts">
import Tab from '$lib/components/common/tabs/Tab.svelte'
import { Tabs } from '$lib/components/common'
import WebhooksPanel from '$lib/components/triggers/webhook/WebhooksPanel.svelte'
import EmailTriggerPanel from '$lib/components/details/EmailTriggerPanel.svelte'
import RoutesPanel from '$lib/components/triggers/http/RoutesPanel.svelte'
import RunPageSchedules from '$lib/components/RunPageSchedules.svelte'
import { canWrite } from '$lib/utils'
import { userStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import FlowCard from '../flows/common/FlowCard.svelte'
import { getContext, onDestroy, createEventDispatcher } from 'svelte'
import type { TriggerContext } from '$lib/components/triggers'
import ScheduledPollPanel from './scheduled/ScheduledPollPanel.svelte'
import WebsocketTriggersPanel from './websocket/WebsocketTriggersPanel.svelte'
import PostgresTriggersPanel from './postgres/PostgresTriggersPanel.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import { KafkaIcon, MqttIcon, NatsIcon, AwsIcon, GoogleCloudIcon } from '../icons'
import KafkaTriggersPanel from './kafka/KafkaTriggersPanel.svelte'
import NatsTriggersPanel from './nats/NatsTriggersPanel.svelte'
import MqttTriggersPanel from './mqtt/MqttTriggersPanel.svelte'
import SqsTriggerPanel from './sqs/SqsTriggerPanel.svelte'
import GcpTriggerPanel from './gcp/GcpTriggerPanel.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import TriggersTable from './TriggersTable.svelte'
import CaptureWrapper from './CaptureWrapper.svelte'
import { fade } from 'svelte/transition'
import TriggersBadge from '../graph/renderers/triggers/TriggersBadge.svelte'
import { twMerge } from 'tailwind-merge'
import AddTriggersButton from '$lib/components/triggers/AddTriggersButton.svelte'
import { Plus } from 'lucide-svelte'
import Button from '../common/button/Button.svelte'
import TriggersWrapperV2 from './TriggersWrapper.svelte'
import {
triggerTypeToCaptureKind,
type TriggerType,
CLOUD_DISABLED_TRIGGER_TYPES,
type Trigger
} from './utils'
import { isCloudHosted } from '$lib/cloud'
export let noEditor: boolean
export let newItem = false
export let currentPath: string
export let fakeInitialPath: string
export let hash: string | undefined = undefined
export let initialPath: string
export let schema: any
export let isFlow: boolean
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let args: Record<string, any> = {}
let eventStreamType: 'kafka' | 'nats' | 'sqs' | 'mqtt' | 'gcp' = 'kafka'
$: {
if (
$selectedTrigger === 'kafka' ||
$selectedTrigger === 'nats' ||
$selectedTrigger === 'sqs' ||
$selectedTrigger === 'mqtt' ||
$selectedTrigger === 'gcp'
) {
eventStreamType = $selectedTrigger
}
interface Props {
noEditor: boolean
newItem?: boolean
currentPath: string
fakeInitialPath?: string
hash?: string | undefined
args?: Record<string, any>
initialPath: string
isFlow: boolean
canHavePreprocessor?: boolean
hasPreprocessor?: boolean
isDeployed?: boolean
schema?: Record<string, any> | undefined
noCapture?: boolean
isEditor?: boolean
onDeployTrigger?: (trigger: Trigger) => void
}
const { selectedTrigger, simplifiedPoll } = getContext<TriggerContext>('TriggerContext')
let {
noEditor,
newItem = false,
currentPath,
fakeInitialPath = '',
hash = undefined,
args = {},
initialPath,
isFlow,
canHavePreprocessor = false,
hasPreprocessor = false,
isDeployed = false,
schema = undefined,
noCapture = false,
isEditor = true,
onDeployTrigger
}: Props = $props()
let config: Record<string, any> = $state({})
let width = $state(0)
let emailDomain: string | undefined = $state(undefined)
let isValid = $state(false)
let renderCount = $state(0)
let loading = $state(false)
const useVerticalTriggerBar = $derived(width < 1000)
const { triggersState, triggersCount } = getContext<TriggerContext>('TriggerContext')
const dispatch = createEventDispatcher()
onDestroy(() => {
dispatch('exitTriggers')
})
// Handle trigger selection
function onSelect(triggerIndex: number) {
triggersState.selectedTriggerIndex = triggerIndex
}
function deleteDraftTrigger(triggerIndex: number | undefined) {
if (triggerIndex === undefined) {
return
}
triggersState.deleteTrigger(triggersCount, triggerIndex)
triggersState.selectedTriggerIndex = triggersState.triggers.length - 1
}
async function handleUpdate(trigger: number | undefined, path: string) {
if (!trigger || trigger === -1) {
return
}
const { type: triggerType, id: triggerId, path: triggerPath } = triggersState.triggers[trigger]
loading = true
triggersState.selectedTriggerIndex = undefined
triggersState.deleteTrigger(triggersCount, trigger)
if (triggerType === 'schedule') {
await triggersState.fetchSchedules(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
undefined,
$userStore
)
} else if (triggerType === 'websocket') {
await triggersState.fetchWebsocketTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'postgres') {
await triggersState.fetchPostgresTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'kafka') {
await triggersState.fetchKafkaTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'nats') {
await triggersState.fetchNatsTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'gcp') {
await triggersState.fetchGcpTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'sqs') {
await triggersState.fetchSqsTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'mqtt') {
await triggersState.fetchMqttTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
} else if (triggerType === 'http') {
await triggersState.fetchHttpTriggers(
triggersCount,
$workspaceStore,
currentPath,
isFlow,
$userStore
)
}
triggersState.selectedTriggerIndex = triggersState.triggers.findIndex(
(t) => t.path === path && t.type === triggerType
)
loading = false
onDeployTrigger?.({ type: triggerType, id: triggerId, path: triggerPath })
}
function handleUpdateDraftConfig(
triggerIndex: number | undefined,
newConfig: Record<string, any>,
saveDisabled: boolean
) {
if (triggerIndex && triggerIndex !== -1 && newConfig) {
triggersState.setDraftConfig(triggerIndex, { ...newConfig, canSave: !saveDisabled })
}
}
function handleResetDraft(trigger: number | undefined) {
if (!trigger) {
return
}
triggersState.setDraftConfig(trigger, undefined)
renderCount++
}
function handleAddTrigger(type: TriggerType) {
const newTrigger = triggersState.addDraftTrigger(
triggersCount,
type,
type === 'schedule' ? initialPath : undefined
)
triggersState.selectedTriggerIndex = newTrigger
}
const cloudDisabled = $derived(
CLOUD_DISABLED_TRIGGER_TYPES.includes(triggersState.selectedTrigger?.type ?? '') &&
isCloudHosted()
)
</script>
<FlowCard {noEditor} title="Triggers">
{#if !$simplifiedPoll}
<div class="h-full flex flex-col">
<Tabs bind:selected={$selectedTrigger} wrapperClass="overflow-hidden shrink-0">
<Tab value="webhooks" selectedClass="text-primary font-semibold">Webhooks</Tab>
<Tab value="schedules" selectedClass="text-primary text-sm font-semibold">Schedules</Tab>
<Tab value="routes" selectedClass="text-primary text-sm font-semibold">HTTP</Tab>
<Tab value="websockets" selectedClass="text-primary text-sm font-semibold">WebSockets</Tab>
<Tab value="postgres" selectedClass="text-primary text-sm font-semibold">Postgres</Tab>
<Tab
value="kafka"
otherValues={['nats', 'sqs', 'mqtt', 'gcp']}
selectedClass="text-primary text-sm font-semibold"
>
Event streams
</Tab>
<Tab value="emails" selectedClass="text-primary text-sm font-semibold">Email</Tab>
{#if isFlow}
<Tab value="scheduledPoll" selectedClass="text-primary text-sm font-semibold"
>Scheduled Poll</Tab
>
{/if}
<div bind:clientWidth={width} class="h-full w-full">
<FlowCard {noEditor} noHeader>
<Splitpanes horizontal>
<Pane>
<div class="flex flex-row h-full">
<!-- Left Pane - Triggers List -->
{#if !useVerticalTriggerBar}
<div class="w-[350px] flex-shrink-0 overflow-auto pr-2 pl-4 pt-2 pb-2">
<TriggersTable
selectedTrigger={triggersState.selectedTriggerIndex}
{onSelect}
triggers={triggersState.triggers}
{isEditor}
onAddDraftTrigger={handleAddTrigger}
onDeleteDraft={deleteDraftTrigger}
onReset={handleResetDraft}
webhookToken={$triggersCount?.webhook_count}
emailToken={$triggersCount?.email_count}
/>
</div>
{:else}
<div class="p-2 flex flex-col gap-2 border-r">
<AddTriggersButton
onAddDraftTrigger={handleAddTrigger}
class="w-fit h-fit"
placement="right-start"
>
<Button size="xs" nonCaptureEvent btnClasses="p-2 w-fit" wrapperClasses="p-0">
<Plus size="14" />
</Button>
</AddTriggersButton>
<TriggersBadge
showOnlyWithCount={false}
path={initialPath || fakeInitialPath}
{newItem}
isFlow
selected={true}
small={false}
vertical
onSelect={(triggerIndex: number) => {
triggersState.selectedTriggerIndex = triggerIndex
}}
/>
</div>
{/if}
<svelte:fragment slot="content">
<div class="min-h-0 grow overflow-y-auto">
{#if $selectedTrigger === 'webhooks'}
<div class="p-4">
<WebhooksPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={initialPath || fakeInitialPath}
{hash}
{isFlow}
{args}
token=""
{newItem}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'emails'}
<div class="p-4">
<EmailTriggerPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
/>
</div>
{:else if $selectedTrigger === 'routes'}
<div class="p-4">
<RoutesPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
{args}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'websockets'}
<div class="p-4">
<WebsocketTriggersPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
</div>
{:else if $selectedTrigger === 'postgres'}
<div class="p-4">
<PostgresTriggersPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
{canHavePreprocessor}
{hasPreprocessor}
isEditor={true}
/>
</div>
{:else if $selectedTrigger === 'kafka' || $selectedTrigger === 'nats' || $selectedTrigger === 'sqs' || $selectedTrigger === 'mqtt' || $selectedTrigger === 'gcp'}
<div class="p-4 flex flex-col gap-2">
<ToggleButtonGroup bind:selected={eventStreamType} let:item>
<ToggleButton value="kafka" label="Kafka" icon={KafkaIcon} {item} />
<ToggleButton value="nats" label="NATS" icon={NatsIcon} {item} />
<ToggleButton value="mqtt" label="MQTT" icon={MqttIcon} {item} />
<ToggleButton value="sqs" label="SQS" icon={AwsIcon} {item} />
<ToggleButton value="gcp" label="GCP" icon={GoogleCloudIcon} {item} />
</ToggleButtonGroup>
{#if eventStreamType === 'kafka'}
<KafkaTriggersPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
<div
class={twMerge(
'flex-grow overflow-auto pl-2 pr-4 pb-4 pt-2',
useVerticalTriggerBar ? 'pl-4 pt-2' : ''
)}
style="scrollbar-gutter: stable"
>
{#if loading}
<div
class="animate-skeleton dark:bg-frost-900/50 [animation-delay:1000ms] h-full w-full"
></div>
{:else if triggersState.selectedTrigger}
{#key [renderCount, triggersState.selectedTriggerIndex].join('-')}
<div in:fade={{ duration: 100, delay: 100 }} out:fade={{ duration: 100 }}>
<TriggersWrapperV2
selectedTrigger={triggersState.selectedTrigger}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
{:else if eventStreamType === 'nats'}
<NatsTriggersPanel
on:applyArgs
on:addPreprocessor
{initialPath}
{fakeInitialPath}
{currentPath}
{hash}
{isDeployed}
small={useVerticalTriggerBar}
{args}
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
{schema}
{isEditor}
onDelete={() => {
deleteDraftTrigger(triggersState.selectedTriggerIndex)
}}
onUpdate={(path) => {
handleUpdate(triggersState.selectedTriggerIndex, path)
}}
onConfigChange={(cfg, canSave, updated) => {
if (updated) {
handleUpdateDraftConfig(triggersState.selectedTriggerIndex, cfg, canSave)
}
}}
onCaptureConfigChange={(cfg, isValidConfig) => {
config = cfg
isValid = isValidConfig
}}
onReset={() => {
handleResetDraft(triggersState.selectedTriggerIndex)
}}
on:email-domain={({ detail }) => {
emailDomain = detail
}}
/>
{:else if eventStreamType === 'sqs'}
<SqsTriggerPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
{:else if eventStreamType === 'mqtt'}
<MqttTriggersPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
{:else if eventStreamType === 'gcp'}
<GcpTriggerPanel
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
{newItem}
path={initialPath || fakeInitialPath}
{isFlow}
isEditor={true}
{canHavePreprocessor}
{hasPreprocessor}
/>
{/if}
</div>
{:else if $selectedTrigger === 'schedules'}
<div class="p-4">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{:else if $selectedTrigger === 'scheduledPoll'}
<div class="p-4">
<ScheduledPollPanel />
</div>
</div>
{/key}
{:else}
<span class="text-sm text-tertiary text-center mx-auto mt-2"
>{`Select a trigger from the ${useVerticalTriggerBar ? 'left toolbar' : 'table'} or a create a new one`}</span
>
{/if}
</div>
</svelte:fragment>
</Tabs>
</div>
{:else}
<div class="px-4 pb-2">
<RunPageSchedules
{schema}
{isFlow}
path={initialPath}
{newItem}
can_write={canWrite(currentPath, {}, $userStore)}
/>
</div>
{/if}
</FlowCard>
</div>
</Pane>
{#if !cloudDisabled && triggersState.selectedTrigger && triggersState.selectedTrigger.type !== 'schedule' && triggersState.selectedTrigger.type != 'poll' && !noCapture}
{@const captureKind = triggersState.selectedTrigger
? triggerTypeToCaptureKind(triggersState.selectedTrigger.type)
: undefined}
{#if captureKind}
{#key captureKind}
<Pane minSize={20} size={40}>
<CaptureWrapper
path={initialPath || fakeInitialPath}
{isFlow}
captureType={captureKind}
{hasPreprocessor}
{canHavePreprocessor}
args={config}
data={{ args, hash, emailDomain }}
{isValid}
triggerDeployed={!triggersState.selectedTrigger.isDraft}
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
/>
</Pane>
{/key}
{/if}
{/if}
</Splitpanes>
</FlowCard>
</div>
@@ -1,135 +0,0 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { ChevronDown, Save } from 'lucide-svelte'
import CaptureWrapper from './CaptureWrapper.svelte'
import { capitalize } from '$lib/utils'
import Popover from '../Popover.svelte'
import Section from '../Section.svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { type CaptureTriggerKind } from '$lib/gen'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersWrapper from './TriggersWrapper.svelte'
import { twMerge } from 'tailwind-merge'
export let cloudDisabled: boolean
export let triggerType: CaptureTriggerKind
export let isFlow: boolean = false
export let data: any = {}
export let noSave = false
export let isEditor: boolean = false
export let path: string = ''
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let newItem: boolean
export let openForm: boolean = false
export let alwaysOpened: boolean = false
export let showCapture: boolean = false
const captureTypeLabels: Record<CaptureTriggerKind, string> = {
http: 'custom HTTP route',
websocket: 'WebSocket trigger',
webhook: 'Webhook',
kafka: 'Kafka trigger',
email: 'Email trigger',
nats: 'NATS trigger',
mqtt: 'MQTT trigger',
sqs: 'SQS trigger',
postgres: 'Postgres trigger',
gcp: 'GCP Pub/Sub trigger'
}
const { captureOn } = getContext<TriggerContext>('TriggerContext')
let args: Record<string, any> = {}
$: collapsed = !openForm
const dispatch = createEventDispatcher()
let init = false
$: updateShowCapture(!!$captureOn)
function updateShowCapture(show: boolean) {
if (show && !init) {
$captureOn = undefined
showCapture = true
init = true
}
}
</script>
<Section
label={`+ New ${captureTypeLabels[triggerType]}`}
collapsable={!alwaysOpened}
bind:collapsed
>
<svelte:fragment slot="action">
{#if !collapsed || alwaysOpened}
<div class="flex flex-row grow w-min-0 gap-2 items-center justify-end">
{#if isEditor}
<Button
size="xs2"
on:click={() => {
showCapture = !showCapture
}}
variant="border"
color="light"
endIcon={{
icon: ChevronDown,
classes: twMerge('transition', showCapture ? 'rotate-180' : '')
}}
>
Test trigger
</Button>
{/if}
{#if !noSave}
{@const disabled = newItem || cloudDisabled}
<Popover notClickable>
<Button
size="xs2"
{disabled}
startIcon={{ icon: Save }}
on:click={() => {
dispatch('saveTrigger', {
config: args
})
}}
>
Save
</Button>
<svelte:fragment slot="text">
{#if disabled}
{#if newItem}
Deploy the runnable to enable trigger creation
{:else if cloudDisabled}
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud
{/if}
{:else}
Create new {captureTypeLabels[triggerType].toLowerCase()}
{/if}
</svelte:fragment>
</Popover>
{/if}
</div>
{/if}
</svelte:fragment>
{#if isEditor}
<CaptureWrapper
{path}
{isFlow}
captureType={triggerType}
{hasPreprocessor}
{canHavePreprocessor}
on:applyArgs
on:addPreprocessor
on:updateSchema
on:saveTrigger
on:testWithArgs
bind:args
{data}
{showCapture}
/>
{:else}
<TriggersWrapper {path} {isFlow} {triggerType} {cloudDisabled} {args} {data} />
{/if}
</Section>
@@ -0,0 +1,139 @@
<script lang="ts">
import DataTable from '$lib/components/table/DataTable.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Plus, Star, Loader2, Trash, RotateCcw } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { triggerIconMap, type Trigger, type TriggerType } from './utils'
import AddTriggersButton from './AddTriggersButton.svelte'
import TriggerLabel from './TriggerLabel.svelte'
interface Props {
// Props
selectedTrigger?: number | undefined
triggers?: Trigger[]
isEditor?: boolean
webhookToken?: number
emailToken?: number
onSelect?: (trigger: number) => void
onAddDraftTrigger?: (type: TriggerType) => void
onDeleteDraft?: (triggerIndex: number) => void
onReset?: (triggerIndex: number) => void
}
let {
selectedTrigger = undefined,
triggers = [],
isEditor = false,
webhookToken,
emailToken,
onSelect,
onAddDraftTrigger,
onDeleteDraft,
onReset
}: Props = $props()
// Component state
let loading = false
</script>
<div class="flex flex-col space-y-2 w-full">
<div class="w-full">
<AddTriggersButton {onAddDraftTrigger} setDropdownWidthToButtonWidth class="w-full" {isEditor}>
<Button
size="xs"
color="blue"
startIcon={{ icon: Plus }}
nonCaptureEvent
btnClasses="w-full justify-center"
>
<span>Add trigger</span>
</Button>
</AddTriggersButton>
</div>
<DataTable {loading} size="sm" tableFixed={true}>
<tbody>
{#each triggers as trigger, index}
{@const SvelteComponent = triggerIconMap[trigger.type]}
<tr
class={twMerge(
'hover:bg-surface-hover cursor-pointer h-10 group',
selectedTrigger === index ? 'bg-surface-hover ' : ''
)}
onclick={() => onSelect?.(index)}
>
<td class="w-12 text-center py-2 px-2">
<div class="relative flex justify-center items-center">
<SvelteComponent
size={16}
class={trigger.isDraft ? 'text-frost-400' : 'text-tertiary'}
/>
{#if trigger.isPrimary}
<Star size={10} class="absolute -mt-3 ml-3 text-blue-400" />
{/if}
</div>
</td>
<td class="py-2 px-2 text-xs">
<div class="flex items-center justify-between gap-1">
<div class="flex items-center grow min-w-0">
<TriggerLabel {trigger} />
{#if trigger.type === 'webhook' && webhookToken}
<span
class="ml-2 px-1.5 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse py-0.5"
>
{`${webhookToken} token${webhookToken > 1 ? 's' : ''}`}
</span>
{:else if trigger.type === 'email' && emailToken}
<span
class="ml-2 text-xs rounded-md bg-tertiary/50 group-hover:bg-primary text-primary-inverse px-1.5 py-0.5"
>
{`${emailToken} token${emailToken > 1 ? 's' : ''}`}
</span>
{/if}
</div>
{#if !['email', 'webhook', 'cli'].includes(trigger.type)}
{#if trigger.isDraft}
<Button
size="xs"
color="light"
btnClasses="transition-all duration-200 text-transparent hover:bg-surface group-hover:text-primary bg-transparent px-1 py-1"
startIcon={{ icon: Trash }}
iconOnly
on:click={() => onDeleteDraft?.(index)}
/>
{:else if !!trigger.draftConfig && !trigger.isDraft}
<Button
size="xs"
color="light"
btnClasses="transition-all duration-200 text-transparent hover:bg-surface group-hover:text-primary bg-transparent px-1 py-1"
startIcon={{ icon: RotateCcw }}
iconOnly
title="Reset to deployed version"
on:click={() => onReset?.(index)}
/>
{/if}
{/if}
</div>
</td>
</tr>
{/each}
{#if !loading && triggers.length === 0}
<tr>
<td colspan="2" class="text-center py-4 text-tertiary text-sm"> No triggers found </td>
</tr>
{/if}
{#if loading && triggers.length === 0}
<tr>
<td colspan="2" class="text-center py-4 text-tertiary text-sm">
<div class="flex justify-center items-center gap-2">
<Loader2 class="animate-spin" size={16} />
<span>Loading triggers...</span>
</div>
</td>
</tr>
{/if}
</tbody>
</DataTable>
</div>
@@ -1,117 +1,173 @@
<script lang="ts">
import { type CaptureTriggerKind } from '$lib/gen'
import { capitalize } from '$lib/utils'
import Alert from '../common/alert/Alert.svelte'
import RouteEditorConfigSection from './http/RouteEditorConfigSection.svelte'
import WebsocketEditorConfigSection from './websocket/WebsocketEditorConfigSection.svelte'
import WebhooksConfigSection from './webhook/WebhooksConfigSection.svelte'
import EmailTriggerConfigSection from '../details/EmailTriggerConfigSection.svelte'
import KafkaTriggersConfigSection from './kafka/KafkaTriggersConfigSection.svelte'
import NatsTriggersConfigSection from './nats/NatsTriggersConfigSection.svelte'
import MqttEditorConfigSection from './mqtt/MqttEditorConfigSection.svelte'
import SqsTriggerEditorConfigSection from './sqs/SqsTriggerEditorConfigSection.svelte'
import PostgresEditorConfigSection from './postgres/PostgresEditorConfigSection.svelte'
import GcpTriggerEditorConfigSection from './gcp/GcpTriggerEditorConfigSection.svelte'
import RoutesPanel from './http/RoutesPanel.svelte'
import WebhooksPanel from './webhook/WebhooksPanel.svelte'
import EmailTriggerPanel from '../details/EmailTriggerPanel.svelte'
import SchedulePanel from '$lib/components/SchedulePanel.svelte'
import PostgresTriggersPanel from './postgres/PostgresTriggersPanel.svelte'
import KafkaTriggerPanel from './kafka/KafkaTriggersPanel.svelte'
import NatsTriggersPanel from './nats/NatsTriggersPanel.svelte'
import MqttTriggerPanel from './mqtt/MqttTriggersPanel.svelte'
import SqsTriggerPanel from './sqs/SqsTriggerPanel.svelte'
import GcpTriggerPanel from './gcp/GcpTriggerPanel.svelte'
import ScheduledPollPanel from './scheduled/ScheduledPollPanel.svelte'
import WebsocketTriggersPanel from './websocket/WebsocketTriggersPanel.svelte'
import { triggerIconMap, type Trigger } from './utils'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import CliHelpBox from '../CliHelpBox.svelte'
import TriggerLabel from './TriggerLabel.svelte'
import { twMerge } from 'tailwind-merge'
export let triggerType: CaptureTriggerKind = 'webhook'
export let cloudDisabled: boolean = false
export let args: any
export let isFlow: boolean = false
export let path: string = ''
export let data: any = {}
interface Props {
selectedTrigger: Trigger
isFlow: boolean
initialPath: string
fakeInitialPath: string
currentPath: string
hash?: string
isDeployed: boolean
small: boolean
args: Record<string, any>
newItem: boolean
schema: Record<string, any> | undefined
isEditor?: boolean
onConfigChange?: (cfg: Record<string, any>, canSave: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, isValidConfig: boolean) => void
onUpdate?: (path: string) => void
onDelete?: () => void
onReset?: () => void
}
let {
selectedTrigger,
isFlow = false,
initialPath,
fakeInitialPath,
currentPath,
hash,
small,
args,
newItem,
schema,
...props
}: Props = $props()
</script>
<div class="flex flex-col gap-4 w-full">
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
{capitalize(triggerType)} triggers are disabled in the multi-tenant cloud.
</Alert>
{:else if triggerType === 'websocket'}
<WebsocketEditorConfigSection
can_write={true}
headless={true}
bind:url={args.url}
bind:url_runnable_args={args.url_runnable_args}
showCapture={false}
{#if selectedTrigger.type === 'http'}
<RoutesPanel
{selectedTrigger}
{isFlow}
path={initialPath || fakeInitialPath}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'webhook'}
<WebhooksPanel
{isFlow}
path={initialPath || fakeInitialPath}
{hash}
token=""
{args}
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
{newItem}
/>
{:else if selectedTrigger.type === 'email'}
<EmailTriggerPanel
token=""
scopes={isFlow ? [`run:flow/${currentPath}`] : [`run:script/${currentPath}`]}
path={initialPath || fakeInitialPath}
{isFlow}
on:email-domain
/>
{:else if selectedTrigger.type === 'schedule'}
<SchedulePanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
{schema}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'websocket'}
<WebsocketTriggersPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'kafka'}
<KafkaTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'postgres'}
<PostgresTriggersPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'nats'}
<NatsTriggersPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'mqtt'}
<MqttTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'sqs'}
<SqsTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'gcp'}
<GcpTriggerPanel
{isFlow}
path={initialPath || fakeInitialPath}
{selectedTrigger}
defaultValues={selectedTrigger.draftConfig ?? selectedTrigger.captureConfig ?? undefined}
customLabel={small ? customLabel : undefined}
{...props}
/>
{:else if selectedTrigger.type === 'poll'}
<ScheduledPollPanel />
{:else if selectedTrigger.type === 'cli'}
<div class="py-1 flex flex-col gap-4">
<ClipboardPanel content={selectedTrigger.extra?.cliCommand ?? ''} />
<CliHelpBox />
</div>
{/if}
{#snippet customLabel()}
{@const IconComponent = triggerIconMap[selectedTrigger.type]}
<div class="flex flex-row gap-2 items-center grow min-w-0 pr-2">
<IconComponent
size={16}
class={twMerge(selectedTrigger.isDraft ? 'text-frost-400' : '', 'shrink-0')}
/>
{:else if triggerType === 'postgres'}
<PostgresEditorConfigSection
can_write={true}
headless={true}
showCapture={false}
bind:publication={args.publication}
bind:postgres_resource_path={args.postgres_resource_path}
/>
{:else if triggerType === 'webhook'}
<WebhooksConfigSection
{isFlow}
{path}
hash={data?.hash}
token={data?.token}
runnableArgs={data?.args}
scopes={data?.scopes}
showCapture={false}
/>
{:else if triggerType === 'http'}
<RouteEditorConfigSection
showCapture={false}
can_write={true}
bind:route_path={args.route_path}
bind:http_method={args.http_method}
bind:raw_string={args.raw_string}
bind:wrap_body={args.wrap_body}
capture_mode={true}
headless
/>
{:else if triggerType === 'email'}
<EmailTriggerConfigSection
hash={data?.hash}
token={data?.token}
{path}
{isFlow}
userSettings={data?.userSettings}
emailDomain={data?.emailDomain}
/>
{:else if triggerType === 'kafka'}
<KafkaTriggersConfigSection headless={true} bind:args staticInputDisabled={false} {path} />
{:else if triggerType === 'nats'}
<NatsTriggersConfigSection headless={true} bind:args staticInputDisabled={false} {path} />
{:else if triggerType === 'mqtt'}
<MqttEditorConfigSection
bind:v3_config={args.v3_config}
bind:v5_config={args.v5_config}
bind:client_version={args.client_version}
bind:subscribe_topics={args.subscribe_topics}
bind:mqtt_resource_path={args.mqtt_resource_path}
bind:client_id={args.client_id}
showCapture={false}
headless={true}
can_write={true}
/>
{:else if triggerType === 'sqs'}
<SqsTriggerEditorConfigSection
bind:queue_url={args.queue_url}
bind:aws_resource_path={args.aws_resource_path}
bind:message_attributes={args.message_attributes}
bind:aws_auth_resource_type={args.aws_auth_resource_type}
headless={true}
can_write={true}
showCapture={false}
/>
{:else if triggerType === 'gcp'}
<GcpTriggerEditorConfigSection
bind:gcp_resource_path={args.gcp_resource_path}
bind:topic_id={args.topic_id}
bind:subscription_id={args.subscription_id}
bind:delivery_config={args.delivery_config}
bind:delivery_type={args.delivery_type}
bind:subscription_mode={args.subscription_mode}
bind:cloud_subscription_id={args.subscription_id}
bind:create_update_subscription_id={args.subscription_id}
bind:base_endpoint={args.base_endpoint}
headless={true}
can_write={true}
showCapture={false}
/>
{/if}
</div>
<TriggerLabel trigger={selectedTrigger} />
</div>
{/snippet}
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import { workspaceStore } from '$lib/stores'
import { Url } from '$lib/components/common'
import { fade } from 'svelte/transition'
import { base } from '$lib/base'
interface Props {
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
deliveryType?: 'push' | 'pull'
captureLoading?: boolean
triggerDeployed?: boolean
}
let {
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
deliveryType = 'pull',
captureLoading = false,
triggerDeployed = false
}: Props = $props()
function getCaptureUrl(captureInfo: CaptureInfo | undefined) {
if (!captureInfo) {
return
}
return `${window.location.origin}${base}/api/w/${$workspaceStore}/capture_u/gcp/${
captureInfo.isFlow ? 'flow' : 'script'
}/${captureInfo.path}`
}
</script>
{#if captureInfo}
<CaptureSection
captureType="gcp"
disabled={isValid === false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
{captureLoading}
displayAlert={triggerDeployed}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to GCP Pub/Sub events...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to GCP Pub/Sub events.
</p>
{/if}
{/snippet}
{#if deliveryType === 'push'}
{@const captureUrl = getCaptureUrl(captureInfo)}
<Url url={captureUrl} label="Test URL" />
{/if}
</CaptureSection>
{/if}
@@ -2,7 +2,9 @@
import { tick } from 'svelte'
import GcpTriggerEditorInner from './GcpTriggerEditorInner.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()
@@ -12,16 +14,17 @@
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
defaultValues?: Record<string, any>,
newDraft?: boolean
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: GcpTriggerEditorInner
let drawer: GcpTriggerEditorInner | undefined = $state()
</script>
{#if open}
<GcpTriggerEditorInner on:update bind:this={drawer} />
<GcpTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -1,10 +1,8 @@
<script lang="ts">
import Section from '$lib/components/Section.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import Required from '$lib/components/Required.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import { copyToClipboard, emptyStringTrimmed } from '$lib/utils'
import { emptyStringTrimmed } from '$lib/utils'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import {
@@ -15,8 +13,6 @@
} from '$lib/gen'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Label from '$lib/components/Label.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import { base } from '$lib/base'
import Toggle from '$lib/components/Toggle.svelte'
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
@@ -24,15 +20,13 @@
import Select from '$lib/components/apps/svelte-select/lib/Select.svelte'
import { workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { ClipboardCopy, RefreshCw } from 'lucide-svelte'
import { Button, Url } from '$lib/components/common'
import { RefreshCw } from 'lucide-svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import TestingBadge from '../testingBadge.svelte'
export let can_write: boolean = false
export let headless: boolean = false
export let showCapture: boolean = false
export let captureTable: CaptureTable | undefined = undefined
export let captureInfo: CaptureInfo | undefined = undefined
export let isValid: boolean = false
export let gcp_resource_path = ''
export let subscription_id = ''
@@ -40,16 +34,17 @@
export let delivery_type: DeliveryType | undefined = 'pull'
export let delivery_config: PushConfig | undefined
export let subscription_mode: SubscriptionMode = 'create_update'
export let base_endpoint: string = getBaseUrl(captureInfo)
export let path = captureInfo ? captureInfo.path : ''
export let base_endpoint: string = getBaseUrl()
export let path = ''
export let showTestingBadge: boolean = false
let topic_items: string[] = []
let subscription_items: string[] = []
let darkMode = false
const DEFAULT_PUSH_CONFIG: PushConfig = {
audience: '',
authenticate: false,
audience: getBaseUrl(),
authenticate: false
}
async function loadAllPubSubTopicsFromProject() {
@@ -76,7 +71,10 @@
export let cloud_subscription_id = ''
export let create_update_subscription_id = ''
$: isValid = !emptyStringTrimmed(gcp_resource_path) && !emptyStringTrimmed(topic_id)
$: isValid =
!emptyStringTrimmed(gcp_resource_path) &&
!emptyStringTrimmed(topic_id) &&
!emptyStringTrimmed(subscription_id)
$: if (!delivery_type) {
delivery_type = 'pull'
}
@@ -84,43 +82,28 @@
delivery_config = DEFAULT_PUSH_CONFIG
}
function getBaseUrl(captureInfo: CaptureInfo | undefined) {
if (captureInfo) {
return `${window.location.origin}${base}/api/w/${$workspaceStore}/capture_u/gcp/${
captureInfo.isFlow ? 'flow' : 'script'
}`
} else {
return `${window.location.origin}${base}/api/gcp/w/${$workspaceStore!}`
}
function getBaseUrl() {
return `${window.location.origin}${base}/api/gcp/w/${$workspaceStore!}`
}
$: !base_endpoint && (base_endpoint = getBaseUrl(captureInfo))
$: !base_endpoint && (base_endpoint = getBaseUrl())
$: {
if (emptyStringTrimmed(subscription_id) && !emptyStringTrimmed(path)) {
subscription_id = `windmill-${$workspaceStore!}-${path.replaceAll('/', '_')}`
}
}
</script>
<DarkModeObserver bind:darkMode />
<div>
{#if showCapture && captureInfo}
{@const captureURL = `${base_endpoint}/${path}`}
<CaptureSection
captureType="gcp"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
bind:captureTable
>
{#if delivery_type === 'push'}
<Label label="URL">
<ClipboardPanel content={captureURL} disabled={!captureInfo.active} />
</Label>
{/if}
</CaptureSection>
{/if}
<Section label="GCP Pub/Sub" {headless}>
<svelte:fragment slot="header">
{#if showTestingBadge}
<TestingBadge />
{/if}
</svelte:fragment>
<div class="flex flex-col w-full gap-4">
<Subsection label="Connection setup">
<div class="flex flex-col gap-1 mt-2">
@@ -208,7 +191,7 @@
{#if subscription_mode === 'create_update'}
<Subsection
label="Subscription id"
tooltip="The unique identifier for the Pub/Sub subscription. Leave it empty to let the system automatically generate a subscription ID, or provide a custom one if needed."
tooltip="The unique identifier for the Pub/Sub subscription."
>
<div class="mt-2">
<input
@@ -248,28 +231,9 @@
</Subsection>
{#if delivery_type === 'push' && delivery_config}
<div class="flex flex-col gap-3 mt-1">
<Subsection
label="URL"
tooltip="The URL of the service that receives push messages."
>
<div class="flex gap-2">
<input
type="text"
autocomplete="off"
placeholder="URL"
disabled
value={`${base_endpoint}/${path}`}
/>
<Button
on:click={() => copyToClipboard(`${base_endpoint}/${path}`)}
color="dark"
size="xs"
startIcon={{ icon: ClipboardCopy }}
>
Copy URL
</Button>
</div>
</Subsection>
<div class="flex gap-2">
<Url url={`${base_endpoint}/${path}`} label="Production URL" />
</div>
<Subsection label="Authenticate">
<p class="text-xs mb-2 text-tertiary">
Enable Google Cloud authentication for push delivery using a verified token.<Required
@@ -329,9 +293,9 @@
</div>
<Alert title="Push Subscription URL Requirements" type="warning">
If the subscription uses <strong>push delivery</strong>, its endpoint URL must
match the following format: <strong>{base_endpoint}/*</strong>, meaning it must
start with
<strong>{base_endpoint}</strong> followed by any path segment.
match the following format: <strong>{`${base_endpoint}/${path}`}/*</strong>,
meaning it must start with
<strong>{`${base_endpoint}/${path}`}</strong> followed by any path segment.
</Alert>
</div>
{/if}
@@ -5,10 +5,8 @@
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 {
GcpTriggerService,
type DeliveryType,
@@ -20,34 +18,83 @@
import Required from '$lib/components/Required.svelte'
import GcpTriggerEditorConfigSection from './GcpTriggerEditorConfigSection.svelte'
import { base } from '$app/paths'
import type { Snippet } from 'svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import { saveGcpTriggerFromCfg } from './utils'
import { handleConfigChange } from '../utils'
let is_loading = false
let drawer: Drawer
let is_flow: boolean = false
let initialPath = ''
let edit = true
let delivery_type: DeliveryType = 'pull'
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 topic_id: string = ''
let gcp_resource_path: string = ''
let subscription_id: string = ''
let isValid = false
let delivery_config: PushConfig | undefined = undefined
let subscription_mode: SubscriptionMode = 'create_update'
const dispatch = createEventDispatcher()
let drawer: Drawer | undefined = $state(undefined)
let is_flow: boolean = $state(false)
let initialPath = $state('')
let edit = $state(true)
let delivery_type: DeliveryType = $state('pull')
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 topic_id: string = $state('')
let gcp_resource_path: string = $state('')
let subscription_id: string = $state('')
let isValid = $state(false)
let delivery_config: PushConfig | undefined = $state(undefined)
let subscription_mode: SubscriptionMode = $state('create_update')
let initialConfig: Record<string, any> | undefined = undefined
let deploymentLoading = $state(false)
let base_endpoint = $derived(`${window.location.origin}${base}`)
$: is_flow = itemKind === 'flow'
let {
useDrawer = true,
description = undefined,
hideTarget = false,
hideTooltips = false,
isEditor = false,
allowDraft = false,
hasDraft = false,
isDraftOnly = false,
isDeployed = false,
customLabel = undefined,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined,
cloudDisabled = false
}: {
useDrawer?: boolean
description?: Snippet | undefined
hideTarget?: boolean
hideTooltips?: boolean
isEditor?: boolean
allowDraft?: boolean
hasDraft?: boolean
isDraftOnly?: boolean
isDeployed?: boolean
customLabel?: Snippet
onConfigChange?: (cfg: Record<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, isValid: boolean) => void
onUpdate?: (path?: string) => void
onDelete?: () => void
onReset?: () => void
cloudDisabled?: boolean
} = $props()
export async function openEdit(ePath: string, isFlow: boolean) {
const gcpConfig = $derived.by(getGcpConfig)
const saveDisabled = $derived(
pathError != '' || emptyString(script_path) || !isValid || !can_write
)
const captureConfig = $derived.by(isEditor ? getGcpCaptureConfig : () => ({}))
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultValues?: Record<string, any>
) {
drawerLoading = true
try {
drawer?.openDrawer()
@@ -55,11 +102,14 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger()
await loadTrigger(defaultValues)
} catch (err) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${err.body}`, true)
} finally {
drawerLoading = false
if (!defaultValues) {
initialConfig = structuredClone($state.snapshot(getGcpConfig()))
}
}
}
@@ -82,144 +132,183 @@
subscription_id = ''
topic_id = defaultValues?.topic_id
subscription_mode = defaultValues?.subscription_mode ?? 'create_update'
path = ''
path = defaultValues?.path ?? ''
initialPath = ''
edit = false
dirtyPath = false
enabled = defaultValues?.enabled ?? false
} finally {
drawerLoading = false
}
}
async function loadTrigger(): Promise<void> {
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
script_path = s.script_path
initialScriptPath = s.script_path
gcp_resource_path = s.gcp_resource_path
delivery_type = s.delivery_type
subscription_id = s.subscription_id
delivery_config = s.delivery_config
subscription_mode = s.subscription_mode
is_flow = s.is_flow
path = s.path
enabled = s.enabled
topic_id = s.topic_id
can_write = canWrite(s.path, s.extra_perms, $userStore)
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
try {
const s = await GcpTriggerService.getGcpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
} catch (error) {
sendUserToast(`Could not load GCP Pub/Sub trigger: ${error.body}`, true)
}
}
}
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
gcp_resource_path = cfg?.gcp_resource_path
delivery_type = cfg?.delivery_type
subscription_id = cfg?.subscription_id
delivery_config = cfg?.delivery_config
subscription_mode = cfg?.subscription_mode
is_flow = cfg?.is_flow
path = cfg?.path
enabled = cfg?.enabled
topic_id = cfg?.topic_id
can_write = canWrite(cfg?.path, cfg?.extra_perms, $userStore)
}
async function updateTrigger(): Promise<void> {
try {
is_loading = true
const base_endpoint = `${window.location.origin}${base}`
if (delivery_type === 'push') {
if (!delivery_config) {
sendUserToast('Must set route path when delivery type is push', true)
return
}
} else {
delivery_config = undefined
}
if (edit) {
await GcpTriggerService.updateGcpTrigger({
workspace: $workspaceStore!,
path: initialPath,
requestBody: {
gcp_resource_path,
subscription_mode,
subscription_id,
delivery_type,
delivery_config,
base_endpoint,
topic_id,
path,
script_path,
enabled,
is_flow
}
})
sendUserToast(`GCP Pub/Sub trigger ${path} updated`)
} else {
await GcpTriggerService.createGcpTrigger({
workspace: $workspaceStore!,
requestBody: {
gcp_resource_path,
subscription_mode,
subscription_id,
delivery_type,
delivery_config,
base_endpoint,
topic_id,
path,
script_path,
enabled: true,
is_flow
}
})
sendUserToast(`GCP Pub/Sub trigger ${path} created`)
}
deploymentLoading = true
const cfg = gcpConfig
if (!cfg) {
return
}
const isSaved = await saveGcpTriggerFromCfg(
initialPath,
cfg,
edit,
$workspaceStore!,
usedTriggerKinds
)
if (isSaved) {
onUpdate?.(cfg.path)
drawer?.closeDrawer()
}
deploymentLoading = false
}
if (!$usedTriggerKinds.includes('gcp')) {
$usedTriggerKinds = [...$usedTriggerKinds, 'gcp']
}
dispatch('update')
drawer.closeDrawer()
is_loading = false
} catch (error) {
is_loading = false
sendUserToast(error.body, true)
function getGcpConfig() {
return {
gcp_resource_path,
subscription_mode,
subscription_id,
delivery_type,
delivery_config,
base_endpoint,
topic_id,
path,
script_path,
enabled,
is_flow
}
}
function getGcpCaptureConfig() {
return {
gcp_resource_path,
subscription_mode,
subscription_id,
delivery_type,
delivery_config,
base_endpoint,
topic_id,
path
}
}
async function handleToggleEnabled(toggleEnabled: boolean) {
enabled = toggleEnabled
if (!isDraftOnly && !hasDraft) {
await GcpTriggerService.setGcpTriggerEnabled({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { enabled: toggleEnabled }
})
sendUserToast(`${toggleEnabled ? 'enabled' : 'disabled'} GCP Pub/Sub trigger ${initialPath}`)
}
}
$effect(() => {
onCaptureConfigChange?.(captureConfig, isValid)
})
$effect(() => {
if (!drawerLoading) {
handleConfigChange(gcpConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
</script>
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
: `GCP Pub/Sub trigger ${initialPath}`
: 'New GCP Pub/Sub trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{#if !drawerLoading && can_write}
{#if edit}
<div class="mr-8 center-center -mt-1">
<Toggle
disabled={!can_write}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={async (e) => {
sendUserToast(
`${e.detail ? 'enabled' : 'disabled'} GCP Pub/Sub trigger ${initialPath}`
)
}}
/>
</div>
{/if}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || emptyString(script_path) || !isValid || !can_write}
on:click={updateTrigger}
loading={is_loading}
>
Save
</Button>
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
: `GCP Pub/Sub trigger ${initialPath}`
: 'New GCP Pub/Sub trigger'}
on:close={drawer?.closeDrawer}
>
<svelte:fragment slot="actions">
{@render actionsButtons()}
</svelte:fragment>
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'GCP Pub/Sub trigger' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
{#if drawerLoading}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{:else}
<div class="flex flex-col gap-5">
<svelte:fragment slot="action">
{@render actionsButtons()}
</svelte:fragment>
{@render config()}
</Section>
{/if}
{#snippet actionsButtons()}
{#if !drawerLoading && can_write}
<TriggerEditorToolbar
{isDraftOnly}
{hasDraft}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{enabled}
isLoading={deploymentLoading}
{edit}
{allowDraft}
{isDeployed}
onUpdate={updateTrigger}
{onReset}
{onDelete}
onToggleEnabled={handleToggleEnabled}
{cloudDisabled}
/>
{/if}
{/snippet}
{#snippet config()}
{#if drawerLoading}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{:else}
<div class="flex flex-col gap-5">
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info">
{#if edit}
Changes can take up to 30 seconds to take effect.
@@ -227,23 +316,26 @@
New GCP Pub/Sub trigger can take up to 30 seconds to start listening.
{/if}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="gcp_trigger"
kind="gcp_trigger"
disabled={!can_write}
disableEditing={!can_write}
/>
</Label>
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="gcp_trigger"
kind="gcp_trigger"
disabled={!can_write}
/>
</Label>
</div>
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
Pick a script or flow to be triggered <Required required={true} />
@@ -256,35 +348,38 @@
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
/>
{#if emptyString(script_path)}
<Button
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
disabled={!can_write}
href={itemKind === 'flow' ? '/flows/add?hub=68' : '/scripts/add?hub=hub%2F19662'}
target="_blank">Create from template</Button
>
{/if}
</div>
</Section>
{/if}
<GcpTriggerEditorConfigSection
bind:isValid
bind:gcp_resource_path
bind:subscription_id
bind:delivery_type
bind:delivery_config
bind:topic_id
bind:subscription_mode
bind:path
cloud_subscription_id={subscription_id}
create_update_subscription_id={subscription_id}
{can_write}
headless={true}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
<GcpTriggerEditorConfigSection
bind:isValid
bind:gcp_resource_path
bind:subscription_id
bind:delivery_type
bind:delivery_config
bind:topic_id
bind:subscription_mode
bind:path
cloud_subscription_id={subscription_id}
create_update_subscription_id={subscription_id}
{can_write}
headless={true}
showTestingBadge={isEditor}
/>
</div>
{/if}
{/snippet}
@@ -1,131 +1,68 @@
<script lang="ts">
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { GcpTriggerService, type GcpTrigger } from '$lib/gen'
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
import GcpTriggerEditor from './GcpTriggerEditor.svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
import { enterpriseLicense } from '$lib/stores'
import GcpTriggerEditorInner from './GcpTriggerEditorInner.svelte'
import { onMount } from 'svelte'
let gcpTriggerEditor: GcpTriggerEditor
let openForm = true
let dontCloseOnLoad = false
let {
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
customLabel = undefined,
...restProps
} = $props()
let gcpTriggerEditor: GcpTriggerEditorInner | undefined = $state(undefined)
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'gcp' &&
Object.keys($defaultValues ?? {}).length > 0
) {
gcpTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let gcpTriggers: (GcpTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
gcpTriggers = (
await GcpTriggerService.listGcpTriggers({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), gcp_count: gcpTriggers?.length }
openForm = gcpTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load GCP Pub/Sub triggers', e)
async function openGcpTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
gcpTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
gcpTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
</script>
<GcpTriggerEditor
on:update={() => {
loadTriggers()
}}
bind:this={gcpTriggerEditor}
/>
onMount(() => {
gcpTriggerEditor && openGcpTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
})
const cloudDisabled = $derived(isCloudHosted())
</script>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
GCP Pub/Sub triggers are an enterprise only feature.
</Alert>
{:else if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
GCP Pub/Sub triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://cloud.google.com/pubsub/docs?hl=en">
GCP Pub/Sub triggers allow your scripts or flows to process messages from Google Cloud
Pub/Sub in real time. Each trigger listens to a Pub/Sub subscription and executes a script or
a flow when new messages are published to the corresponding topic.
</Description>
{#if !newItem && gcpTriggers && gcpTriggers.length > 0}
<Section label="GCP Pub/Sub">
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y pt-2">
{#each gcpTriggers as gcpTrigger (gcpTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{gcpTrigger.path}</div>
<div class="flex justify-end">
<button
on:click={() => gcpTriggerEditor?.openEdit(gcpTrigger.path, isFlow)}
class="px-2"
>
{#if gcpTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</div>
</Section>
{/if}
<TriggersEditorSection
on:applyArgs
on:saveTrigger={(e) => {
gcpTriggerEditor?.openNew(isFlow, path, e.detail.config)
}}
on:addPreprocessor
on:updateSchema
on:testWithArgs
cloudDisabled={false}
triggerType="gcp"
{isFlow}
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
{openForm}
bind:showCapture={dontCloseOnLoad}
/>
<GcpTriggerEditorInner
bind:this={gcpTriggerEditor}
useDrawer={false}
hideTarget
hideTooltips={!isDeployed || cloudDisabled}
allowDraft={true}
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
{customLabel}
{isDeployed}
{cloudDisabled}
{...restProps}
>
{#snippet description()}
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
GCP Pub/Sub triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<Description link="https://www.windmill.dev/docs/core_concepts/gcp_triggers">
GCP Pub/Sub triggers execute scripts and flows in response to messages published to
Google Cloud Pub/Sub topics.
</Description>
{/if}
{/snippet}
</GcpTriggerEditorInner>
</div>
{/if}
@@ -0,0 +1,52 @@
import { GcpTriggerService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { get, type Writable } from 'svelte/store'
export async function saveGcpTriggerFromCfg(
initialPath: string,
cfg: Record<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
try {
const requestBody = {
gcp_resource_path: cfg.gcp_resource_path,
subscription_mode: cfg.subscription_mode,
subscription_id: cfg.subscription_id,
delivery_type: cfg.delivery_type,
delivery_config: cfg.delivery_config,
base_endpoint: cfg.base_endpoint,
topic_id: cfg.topic_id,
path: cfg.path,
script_path: cfg.script_path,
enabled: cfg.enabled,
is_flow: cfg.is_flow
}
if (edit) {
await GcpTriggerService.updateGcpTrigger({
workspace,
path: initialPath,
requestBody
})
sendUserToast(`GCP Pub/Sub trigger ${cfg.path} updated`)
} else {
await GcpTriggerService.createGcpTrigger({
workspace: workspace,
requestBody: {
...requestBody,
enabled: true
}
})
sendUserToast(`GCP Pub/Sub trigger ${cfg.path} created`)
}
if (!get(usedTriggerKinds).includes('gcp')) {
usedTriggerKinds.update((t) => [...t, 'gcp'])
}
return true
} catch (error) {
sendUserToast(error.body || error.message, true)
return false
}
}
@@ -2,13 +2,25 @@
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { Snippet } from 'svelte'
export let raw_string: boolean
export let wrap_body: boolean
interface Props {
raw_string: boolean
wrap_body: boolean
disabled?: boolean
testingBadge?: Snippet | undefined
}
let {
raw_string = $bindable(),
wrap_body = $bindable(),
disabled = false,
testingBadge = undefined
}: Props = $props()
</script>
<Label label="Raw body" class="w-full">
<svelte:fragment slot="header">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing#body-processing-options"
>
@@ -16,31 +28,39 @@
script authentication method and useful for signature verification or other advanced use
cases.
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
{#if testingBadge}
{@render testingBadge()}
{/if}
{/snippet}
{#snippet action()}
<Toggle
checked={raw_string}
on:change={() => {
raw_string = !raw_string
}}
{disabled}
/>
</svelte:fragment>
{/snippet}
</Label>
<Label label="Wrap body" class="w-full">
<svelte:fragment slot="header">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/http_routing#body-processing-options"
>
Wraps the payload in an object under the 'body' key, useful for handling unknown payloads.
Note that this will have no effect when using a preprocessor.
Wraps the body in a JSON object with the key 'body'. Useful for compatibility with existing
code that expects a JSON object.
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
{#if testingBadge}
{@render testingBadge()}
{/if}
{/snippet}
{#snippet action()}
<Toggle
checked={wrap_body}
on:change={() => {
wrap_body = !wrap_body
}}
{disabled}
/>
</svelte:fragment>
{/snippet}
</Label>
@@ -0,0 +1,87 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import Label from '$lib/components/Label.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { bash } from 'svelte-highlight/languages'
// import { page } from '$app/stores'
import { base } from '$lib/base'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import { isObject } from '$lib/utils'
import { Url } from '$lib/components/common'
import { fade } from 'svelte/transition'
interface Props {
route_path: string | undefined
http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' | undefined
captureInfo?: CaptureInfo | undefined
runnableArgs?: any
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
}
let {
route_path,
http_method,
captureInfo = undefined,
runnableArgs = {},
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false
}: Props = $props()
let captureURL = $derived(
`${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
captureInfo?.isFlow ? 'flow' : 'script'
}/${captureInfo?.path.replaceAll('/', '.')}/${route_path ?? ''}`
)
let cleanedRunnableArgs = $derived(
isObject(runnableArgs) && 'wm_trigger' in runnableArgs
? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger'))
: runnableArgs
)
</script>
{#if captureInfo}
<CaptureSection
captureType="http"
disabled={isValid === false}
{captureInfo}
{captureLoading}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
>
{#snippet description()}
{#if captureInfo.active}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Send a POST request to the URL below to simulate a http event.
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to HTTP requests on this test URL.
</p>
{/if}
{/snippet}
<Url url={captureURL} label="Test URL" />
<Label label="Example cURL" disabled={!captureInfo.active}>
<CopyableCodeBlock
disabled={!captureInfo.active}
code={`curl \\
-X ${(http_method ?? 'post').toUpperCase()} ${captureURL} \\
-H 'Content-Type: application/json' \\
-d '${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)}'`}
language={bash}
/>
</Label>
</CaptureSection>
{/if}
@@ -2,7 +2,13 @@
import { tick } from 'svelte'
import RouteEditorInner from './RouteEditorInner.svelte'
let open = false
interface Props {
onUpdate?: (cfg?: Record<string, any>) => void
}
let { onUpdate = undefined }: Props = $props()
let open = $state(false)
export async function openEdit(ePath: string, isFlow: boolean) {
open = true
await tick()
@@ -19,13 +25,9 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
export async function getTriggers() {
return drawer?.getTriggers()
}
let drawer: RouteEditorInner
let drawer: RouteEditorInner | undefined = $state()
</script>
{#if open}
<RouteEditorInner on:update bind:this={drawer} />
<RouteEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -1,25 +1,16 @@
<script lang="ts">
import { Alert, Badge } from '$lib/components/common'
import { Alert, Url } from '$lib/components/common'
import Required from '$lib/components/Required.svelte'
import Section from '$lib/components/Section.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import Label from '$lib/components/Label.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { bash } from 'svelte-highlight/languages'
import { HttpTriggerService } from '$lib/gen'
// import { page } from '$app/stores'
import { base } from '$lib/base'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import ClipboardPanel from '../../details/ClipboardPanel.svelte'
import { isObject } from '$lib/utils'
import { getHttpRoute } from './utils'
import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte'
import { isCloudHosted } from '$lib/cloud'
import Toggle from '$lib/components/Toggle.svelte'
import TestingBadge from '../testingBadge.svelte'
export let initialTriggerPath: string | undefined = undefined
export let dirtyRoutePath: boolean = false
@@ -28,16 +19,12 @@
export let can_write: boolean = false
export let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
undefined
export let showCapture = false
export let headless: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
export let workspaced_route: boolean = false
export let isValid = false
export let runnableArgs: any = {}
export let raw_string: boolean = false
export let wrap_body: boolean = false
export let capture_mode: boolean
export let isDraftOnly: boolean = true
export let showTestingBadge: boolean = false
let validateTimeout: NodeJS.Timeout | undefined = undefined
let routeError: string = ''
@@ -84,47 +71,20 @@
$: !http_method && (http_method = 'post')
$: route_path === undefined && (route_path = '')
$: userIsAdmin = $userStore?.is_admin || $userStore?.is_super_admin
$: userCanEditConfig = userIsAdmin || isDraftOnly // User can edit config if they are admin or if the trigger is a draft which will not be saved
</script>
<div>
{#if showCapture && captureInfo}
{@const captureURL = `${location.origin}${base}/api/w/${$workspaceStore}/capture_u/http/${
captureInfo.isFlow ? 'flow' : 'script'
}/${captureInfo.path.replaceAll('/', '.')}/${route_path}`}
{@const cleanedRunnableArgs =
isObject(runnableArgs) && 'wm_trigger' in runnableArgs
? Object.fromEntries(Object.entries(runnableArgs).filter(([key]) => key !== 'wm_trigger'))
: runnableArgs}
<CaptureSection
captureType="http"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
bind:captureTable
>
<Label label="URL">
<ClipboardPanel content={captureURL} disabled={!captureInfo.active} />
</Label>
<Label label="Example cUrl">
<CopyableCodeBlock
disabled={!captureInfo.active}
code={`curl \\
-X ${(http_method ?? 'post').toUpperCase()} ${captureURL} \\
-H 'Content-Type: application/json' \\
-d '${JSON.stringify(cleanedRunnableArgs ?? {}, null, 2)}'`}
language={bash}
/>
</Label>
</CaptureSection>
{/if}
<Section label="HTTP" {headless}>
{#if !($userStore?.is_admin || $userStore?.is_super_admin)}
<Alert type="info" title="Admin only" collapsible>
<svelte:fragment slot="header">
{#if showTestingBadge}
<TestingBadge />
{/if}
</svelte:fragment>
{#if !userCanEditConfig && isDraftOnly}
<Alert type="info" title="Admin only" collapsible size="xs">
Route endpoints can only be edited by workspace admins
</Alert>
<div class="my-2"></div>
@@ -144,7 +104,7 @@
type="text"
autocomplete="off"
bind:value={route_path}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) || !can_write}
disabled={!userCanEditConfig || !can_write}
class={routeError === ''
? ''
: 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'}
@@ -158,44 +118,28 @@
<ToggleButtonGroup
class="w-auto"
bind:selected={http_method}
disabled={!($userStore?.is_admin || $userStore?.is_super_admin) ||
!can_write ||
!!static_asset_config}
disabled={!userCanEditConfig || !can_write || !!static_asset_config}
let:item
let:disabled
>
<ToggleButton label="GET" value="get" {item} />
<ToggleButton label="POST" value="post" {item} />
<ToggleButton label="PUT" value="put" {item} />
<ToggleButton label="PATCH" value="patch" {item} />
<ToggleButton label="DELETE" value="delete" {item} />
<ToggleButton label="GET" value="get" {item} {disabled} />
<ToggleButton label="POST" value="post" {item} {disabled} />
<ToggleButton label="PUT" value="put" {item} {disabled} />
<ToggleButton label="PATCH" value="patch" {item} {disabled} />
<ToggleButton label="DELETE" value="delete" {item} {disabled} />
</ToggleButtonGroup>
<div class="flex flex-col w-full mt-2">
<div class="flex justify-start w-full">
<Badge
color="gray"
class="center-center !bg-surface-secondary !text-tertiary !w-[90px] !h-[24px] rounded-r-none border"
>
Full endpoint
</Badge>
<input
type="text"
readonly
value={fullRoute}
size={fullRoute.length || 50}
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
on:focus={({ currentTarget }) => {
currentTarget.select()
}}
/>
</div>
<div class="flex flex-col w-full">
<Url url={fullRoute} label="Production URL" />
<div class="text-red-600 dark:text-red-400 text-2xs mt-1.5"
>{dirtyRoutePath ? routeError : ''}</div
>
{#if !capture_mode && !isCloudHosted()}
{#if !isCloudHosted()}
<div class="mt-1">
<Toggle
size="sm"
checked={workspaced_route}
disabled={!can_write}
on:change={() => {
workspaced_route = !workspaced_route
dirtyRoutePath = true
@@ -211,9 +155,6 @@
</div>
{/if}
</div>
{#if capture_mode}
<RouteBodyTransformerOption bind:raw_string bind:wrap_body />
{/if}
</div>
</Section>
</div>
@@ -8,9 +8,8 @@
import { HttpTriggerService, VariableService, type AuthenticationMethod } 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, Pipette, Plus } from 'lucide-svelte'
import { Loader2, Pipette, Plus } from 'lucide-svelte'
import Label from '$lib/components/Label.svelte'
import VariableEditor from '../../VariableEditor.svelte'
import { json } from 'svelte-highlight/languages'
@@ -27,42 +26,94 @@
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import ItemPicker from '../../ItemPicker.svelte'
import { Popover } from '$lib/components/meltComponents'
import { HUB_SCRIPT_ID, SECRET_KEY_PATH } from './utils'
import { HUB_SCRIPT_ID, saveHttpRouteFromCfg, SECRET_KEY_PATH } from './utils'
import { HubFlow } from '$lib/hub'
import RouteBodyTransformerOption from './RouteBodyTransformerOption.svelte'
let is_flow: boolean = false
let initialPath = ''
let edit = true
import TestingBadge from '../testingBadge.svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import { handleConfigChange } from '../utils'
let itemKind: 'flow' | 'script' = 'script'
let {
useDrawer = true,
hideTarget = false,
description = undefined,
isEditor = false,
customLabel = undefined,
isDraftOnly = false,
allowDraft = false,
hasDraft = false,
isDeployed = false,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined
} = $props()
$: is_flow = itemKind === 'flow'
let script_path = ''
let initialScriptPath = ''
let fixedScriptPath = ''
let path: string = ''
let pathError = ''
let isValid = false
let dirtyRoutePath = false
let is_async = false
let authentication_method: AuthenticationMethod = 'none'
let route_path = ''
let http_method: 'get' | 'post' | 'put' | 'patch' | 'delete' = 'post'
let static_asset_config: { s3: string; storage?: string; filename?: string } | undefined =
// Form data state
let initialPath = $state('')
let edit = $state(true)
let itemKind = $state<'flow' | 'script'>('script')
let is_flow = $state(false)
let script_path = $state('')
let initialScriptPath = $state('')
let fixedScriptPath = $state('')
let path = $state('')
let pathError = $state('')
let isValid = $state(false)
let dirtyRoutePath = $state(false)
let dirtyPath = $state(false)
let is_async = $state(false)
let authentication_method = $state<AuthenticationMethod>('none')
let route_path = $state('')
let http_method = $state<'get' | 'post' | 'put' | 'patch' | 'delete'>('post')
let static_asset_config = $state<{ s3: string; storage?: string; filename?: string } | undefined>(
undefined
let is_static_website: boolean = false
let s3FilePicker: S3FilePicker
let s3FileUploadRawMode = false
let s3Editor: SimpleEditor | undefined = undefined
let workspaced_route: boolean = false
let raw_string = false
let wrap_body = false
let drawerLoading = true
let authentication_resource_path: string = ''
let variablePicker: ItemPicker
let variableEditor: VariableEditor
let variable_path: string = ''
)
let is_static_website = $state(false)
let s3FileUploadRawMode = $state(false)
let workspaced_route = $state(false)
let raw_string = $state(false)
let wrap_body = $state(false)
let drawerLoading = $state(true)
let showLoader = $state(false)
let authentication_resource_path = $state('')
let variable_path = $state('')
let signature_options_type = $state<'custom_script' | 'custom_signature'>('custom_signature')
let can_write = $state(true)
let extraPerms = $state<Record<string, string> | undefined>(undefined)
// Component references
let s3FilePicker = $state<S3FilePicker | null>(null)
let s3Editor = $state<SimpleEditor | null>(null)
let variablePicker = $state<ItemPicker | null>(null)
let variableEditor = $state<VariableEditor | null>(null)
let drawer = $state<Drawer | null>(null)
let initialConfig: Record<string, any> | undefined = undefined
let deploymentLoading = $state(false)
const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin)
const routeConfig = $derived.by(getRouteConfig)
const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({}))
const saveDisabled = $derived(
drawerLoading ||
!can_write ||
pathError != '' ||
!isValid ||
(!static_asset_config && emptyString(script_path))
)
$effect(() => {
is_flow = itemKind === 'flow'
})
// Update is_static_website based on static_asset_config
$effect(() => {
if (!static_asset_config) {
is_static_website = false
}
})
type AuthenticationOption = {
label: string
value: AuthenticationMethod
@@ -74,8 +125,6 @@
return await VariableService.listVariable({ workspace: $workspaceStore ?? '' })
}
let signature_options_type: 'custom_script' | 'custom_signature' = 'custom_signature'
const authentication_options: AuthenticationOption[] = [
{
label: 'No Auth',
@@ -108,20 +157,34 @@
}
]
export async function openEdit(ePath: string, isFlow: boolean) {
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>
) {
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
}, 100) // if loading takes less than 100ms, we don't show the loader
try {
drawer?.openDrawer()
initialPath = ePath
path = ePath
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
dirtyRoutePath = false
await loadTrigger()
await loadTrigger(defaultConfig)
} catch (err) {
sendUserToast(`Could not load route: ${err}`, true)
} finally {
if (!defaultConfig) {
// If the route is loaded from the backend, we to set the initial config
initialConfig = structuredClone($state.snapshot(getRouteConfig()))
}
clearTimeout(loader)
drawerLoading = false
showLoader = false
}
}
@@ -131,6 +194,9 @@
defaultValues?: Record<string, any>
) {
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
}, 100) // if loading takes less than 100ms, we don't show the loader
try {
drawer?.openDrawer()
is_flow = nis_flow
@@ -146,7 +212,7 @@
script_path = fixedScriptPath
static_asset_config = undefined
s3FileUploadRawMode = false
path = ''
path = defaultValues?.path ?? ''
initialPath = ''
dirtyPath = false
is_static_website = false
@@ -157,47 +223,73 @@
raw_string = defaultValues?.raw_string ?? false
wrap_body = defaultValues?.wrap_body ?? false
} finally {
clearTimeout(loader)
drawerLoading = false
showLoader = false
}
}
const dispatch = createEventDispatcher()
let can_write = true
async function loadTrigger(): Promise<void> {
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
script_path = s.script_path
initialScriptPath = s.script_path
is_flow = s.is_flow
path = s.path
route_path = s.route_path
http_method = s.http_method ?? 'post'
is_async = s.is_async
workspaced_route = s.workspaced_route
wrap_body = s.wrap_body
raw_string = s.raw_string
authentication_resource_path = s.authentication_resource_path ?? ''
if (s.authentication_method === 'custom_script') {
function loadTriggerConfig(cfg?: Record<string, any>): void {
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
is_flow = cfg?.is_flow
path = cfg?.path
route_path = cfg?.route_path
http_method = cfg?.http_method ?? 'post'
is_async = cfg?.is_async
workspaced_route = cfg?.workspaced_route
wrap_body = cfg?.wrap_body
raw_string = cfg?.raw_string
authentication_resource_path = cfg?.authentication_resource_path ?? ''
if (cfg?.authentication_method === 'custom_script') {
authentication_method = 'signature'
signature_options_type = 'custom_script'
} else {
authentication_method = s.authentication_method
authentication_method = cfg?.authentication_method
signature_options_type = 'custom_signature'
}
if (!isCloudHosted()) {
static_asset_config = s.static_asset_config
s3FileUploadRawMode = !!static_asset_config
is_static_website = s.is_static_website
static_asset_config = cfg?.static_asset_config
s3FileUploadRawMode = !!cfg?.static_asset_config
is_static_website = cfg?.is_static_website
}
extraPerms = cfg?.extra_perms
can_write = canWrite(path, cfg?.extra_perms, $userStore)
}
can_write = canWrite(s.path, s.extra_perms, $userStore)
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await HttpTriggerService.getHttpTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
}
}
async function triggerScript(): Promise<void> {
deploymentLoading = true
const saveCfg = routeConfig
const isSaved = await saveHttpRouteFromCfg(
initialPath,
saveCfg,
edit,
$workspaceStore!,
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
usedTriggerKinds
)
if (isSaved) {
onUpdate(saveCfg.path)
drawer?.closeDrawer()
}
deploymentLoading = false
}
function getRouteConfig(): Record<string, any> {
// If the user selects "signature" with the "custom_script" option,
// we explicitly set the authentication method to "custom_script"
// (which is a valid enum on its own in the backend)
@@ -205,60 +297,47 @@
authentication_method === 'signature' && signature_options_type === 'custom_script'
? 'custom_script'
: authentication_method
if (edit) {
await HttpTriggerService.updateHttpTrigger({
workspace: $workspaceStore!,
path: initialPath,
requestBody: {
path,
script_path,
is_flow,
is_async,
authentication_method: auth_method,
route_path: $userStore?.is_admin || $userStore?.is_super_admin ? route_path : undefined,
http_method,
static_asset_config,
is_static_website,
workspaced_route,
authentication_resource_path,
wrap_body,
raw_string
}
})
sendUserToast(`Route ${path} updated`)
} else {
await HttpTriggerService.createHttpTrigger({
workspace: $workspaceStore!,
requestBody: {
path,
script_path,
is_flow,
is_async,
authentication_method: auth_method,
route_path,
http_method,
static_asset_config,
is_static_website,
workspaced_route,
authentication_resource_path,
wrap_body,
raw_string
}
})
sendUserToast(`Route ${path} created`)
const nCfg = {
script_path,
is_flow,
path,
route_path,
http_method,
is_async,
workspaced_route,
wrap_body,
raw_string,
authentication_resource_path,
authentication_method: auth_method,
static_asset_config,
is_static_website,
extra_perms: extraPerms
}
if (!$usedTriggerKinds.includes('http')) {
$usedTriggerKinds = [...$usedTriggerKinds, 'http']
}
dispatch('update')
drawer.closeDrawer()
return nCfg
}
let drawer: Drawer
let dirtyPath = false
// Update config for captures
function getCaptureConfig() {
const newCaptureConfig = {
route_path: routeConfig.route_path,
http_method: routeConfig.http_method,
raw_string: routeConfig.raw_string,
wrap_body: routeConfig.wrap_body,
path: routeConfig.path
}
//
return newCaptureConfig
}
$: !static_asset_config && (is_static_website = false)
$effect(() => {
onCaptureConfigChange?.(captureConfig, isValid)
})
$effect(() => {
if (!drawerLoading) {
handleConfigChange(routeConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
</script>
{#if static_asset_config}
@@ -274,46 +353,30 @@
/>
{/if}
<Drawer size="700px" bind:this={drawer}>
<DrawerContent
title={edit ? (can_write ? `Edit route ${initialPath}` : `Route ${initialPath}`) : 'New route'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{#if !drawerLoading && can_write}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' ||
!isValid ||
(!static_asset_config && emptyString(script_path)) ||
(static_asset_config && emptyString(static_asset_config.s3)) ||
!can_write}
on:click={triggerScript}
>
Save
</Button>
{/if}
</svelte:fragment>
{#if drawerLoading}
{#snippet config()}
{#if drawerLoading}
{#if showLoader}
<Loader2 class="animate-spin" />
{:else}
<div class="flex flex-col gap-12">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="route"
kind="http_trigger"
hideUser
disabled={!can_write}
/>
</Label>
</div>
{/if}
{:else}
<div class="flex flex-col gap-12">
<Section label="Metadata">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="route"
kind="http_trigger"
hideUser
disableEditing={!can_write}
/>
</Label>
</Section>
{#if !hideTarget}
<Section label="Target">
{#if !isCloudHosted()}
<ToggleButtonGroup
@@ -341,10 +404,11 @@
}
}}
let:item
let:disabled
>
<ToggleButton label="Runnable" value="runnable" {item} />
<ToggleButton label="Static asset" value="static_asset" {item} />
<ToggleButton label="Static website" value="static_website" {item} />
<ToggleButton label="Runnable" value="runnable" {item} {disabled} />
<ToggleButton label="Static asset" value="static_asset" {item} {disabled} />
<ToggleButton label="Static website" value="static_website" {item} {disabled} />
</ToggleButtonGroup>
{/if}
@@ -371,13 +435,7 @@
{#if s3FileUploadRawMode}
{#if can_write}
<JsonEditor
bind:editor={s3Editor}
on:focus={(e) => {
dispatch('focus')
}}
on:blur={(e) => {
dispatch('blur')
}}
bind:editor={s3Editor as any}
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
bind:value={static_asset_config}
/>
@@ -456,183 +514,257 @@
</div>
{/if}
</Section>
{/if}
<RouteEditorConfigSection
initialTriggerPath={initialPath}
bind:route_path
bind:isValid
bind:dirtyRoutePath
bind:http_method
bind:workspaced_route
{can_write}
capture_mode={false}
bind:static_asset_config
/>
<RouteEditorConfigSection
initialTriggerPath={initialPath}
bind:route_path
bind:isValid
bind:dirtyRoutePath
bind:http_method
bind:workspaced_route
{can_write}
bind:static_asset_config
showTestingBadge={isEditor}
{isDraftOnly}
/>
{#if !is_static_website}
<Section label="Advanced">
<div class="flex flex-col gap-4">
{#if !static_asset_config}
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
selected={is_async ? 'async' : 'sync'}
on:selected={({ detail }) => {
is_async = detail === 'async'
}}
disabled={!can_write || !!static_asset_config}
let:item
>
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
{item}
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
{item}
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
{#if !is_static_website}
<Section label="Advanced" collapsable>
<div class="flex flex-col gap-4">
{#if !static_asset_config}
<div class="flex flex-row justify-between">
<Label label="Request type" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
selected={is_async ? 'async' : 'sync'}
on:selected={({ detail }) => {
is_async = detail === 'async'
}}
disabled={!can_write || !!static_asset_config}
let:item
let:disabled
>
<ToggleButton
label="Async"
value="async"
tooltip="The returning value is the uuid of the job assigned to execute the job."
{item}
{disabled}
/>
<ToggleButton
label="Sync"
value="sync"
tooltip="Triggers the execution, wait for the job to complete and return it as a response."
{item}
{disabled}
/>
</ToggleButtonGroup>
</svelte:fragment>
</Label>
</div>
{/if}
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={authentication_method}
on:selected={(e) => {
if (e.detail === 'signature' && signature_options_type === 'custom_script') {
raw_string = true
}
}}
disabled={!can_write}
let:item
let:disabled
>
{#each authentication_options as option}
{#if option.value === 'signature'}
<Popover placement="top-end" usePointerDownOutside>
<svelte:fragment slot="trigger">
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
{disabled}
/>
</svelte:fragment>
<svelte:fragment slot="content">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={signature_options_type}
on:selected={(e) => {
if (e.detail === 'custom_script') {
if (!raw_string) {
raw_string = true
}
}
}}
disabled={!can_write}
let:item
let:disabled
>
<ToggleButton
label="Signature validation"
value="custom_signature"
tooltip="Use a predefined or custom signature-based authentication scheme"
{item}
{disabled}
/>
<ToggleButton
label="Custom script"
value="custom_script"
tooltip="Use your own script logic"
{item}
{disabled}
/>
</ToggleButtonGroup>
</svelte:fragment>
</Popover>
{:else}
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
{disabled}
/>
{/if}
{/each}
</ToggleButtonGroup>
</svelte:fragment>
</Label>
{#each authentication_options as option}
{#if option.resource_type && authentication_method === option.value}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={option.resource_type}
disabled={!can_write}
/>
{/if}
{/each}
{#if authentication_method === 'signature'}
{#if signature_options_type === 'custom_signature'}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={'signature_auth'}
disabled={!can_write}
/>
{:else if signature_options_type === 'custom_script'}
<p class="text-xs mt-3 mb-1 text-tertiary">
Pick a secret variable or create one which will be used as a secret key for your
custom script/flow<Required required={true} /><br />
</p>
<div class="flex flex-row gap-2">
<div class="flex flex-row gap-2 w-full">
<input
type="text"
autocomplete="off"
bind:value={variable_path}
readonly
disabled={true}
/>
<Button
title="Add variable"
on:click={() => variablePicker?.openDrawer()}
size="xs"
color="dark"
disabled={!can_write}
>
Pick variable
</Button>
</div>
<Button
disabled={emptyString(variable_path) || !can_write}
color="dark"
size="xs"
href={itemKind === 'flow'
? `/flows/add?${SECRET_KEY_PATH}=${encodeURIComponent(variable_path)}&hub=${
HubFlow.SIGNATURE_TEMPLATE
}`
: `/scripts/add?${SECRET_KEY_PATH}=${encodeURIComponent(
variable_path
)}&hub=hub%2F${HUB_SCRIPT_ID}`}
target="_blank">Create from template</Button
>
</div>
{/if}
<Label label="Authentication" class="w-full">
<svelte:fragment slot="action">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={authentication_method}
on:selected={(e) => {
if (e.detail === 'signature' && signature_options_type === 'custom_script') {
raw_string = true
}
}}
disabled={!can_write}
let:item
>
{#each authentication_options as option}
{#if option.value === 'signature'}
<Popover placement="top-end" usePointerDownOutside>
<svelte:fragment slot="trigger">
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
/>
</svelte:fragment>
<svelte:fragment slot="content">
<ToggleButtonGroup
class="w-auto h-full"
bind:selected={signature_options_type}
on:selected={(e) => {
if (e.detail === 'custom_script') {
if (!raw_string) {
raw_string = true
}
}
}}
disabled={!can_write}
let:item
>
<ToggleButton
label="Signature validation"
value="custom_signature"
tooltip="Use a predefined or custom signature-based authentication scheme"
{item}
/>
<ToggleButton
label="Custom script"
value="custom_script"
tooltip="Use your own script logic"
{item}
/>
</ToggleButtonGroup>
</svelte:fragment>
</Popover>
{:else}
<ToggleButton
label={option.label}
value={option.value}
tooltip={option.tooltip}
{item}
/>
{/if}
{/each}
</ToggleButtonGroup>
</svelte:fragment>
</Label>
{/if}
{#each authentication_options as option}
{#if option.resource_type && authentication_method === option.value}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={option.resource_type}
/>
{/if}
{/each}
<RouteBodyTransformerOption
bind:raw_string
bind:wrap_body
disabled={!can_write}
{testingBadge}
/>
</div>
</Section>
{/if}
</div>
{/if}
{/snippet}
{#if authentication_method === 'signature'}
{#if signature_options_type === 'custom_signature'}
<ResourcePicker
bind:value={authentication_resource_path}
resourceType={'signature_auth'}
/>
{:else if signature_options_type === 'custom_script'}
<p class="text-xs mt-3 mb-1 text-tertiary">
Pick a secret variable or create one which will be used as a secret key for your
custom script/flow<Required required={true} /><br />
</p>
<div class="flex flex-row gap-2">
<div class="flex flex-row gap-2 w-full">
<input
type="text"
autocomplete="off"
bind:value={variable_path}
readonly
disabled={true}
/>
<Button
title="Add variable"
on:click={variablePicker.openDrawer}
size="xs"
color="dark"
>
Pick variable
</Button>
</div>
<Button
disabled={emptyString(variable_path)}
color="dark"
size="xs"
href={itemKind === 'flow'
? `/flows/add?${SECRET_KEY_PATH}=${encodeURIComponent(variable_path)}&hub=${
HubFlow.SIGNATURE_TEMPLATE
}`
: `/scripts/add?${SECRET_KEY_PATH}=${encodeURIComponent(
variable_path
)}&hub=hub%2F${HUB_SCRIPT_ID}`}
target="_blank">Create from template</Button
>
</div>
{/if}
{/if}
{#snippet testingBadge()}
{#if isEditor}
<TestingBadge />
{/if}
{/snippet}
{#if !static_asset_config}
<RouteBodyTransformerOption bind:raw_string bind:wrap_body />
{/if}
</div>
</Section>
{/if}
</div>
{#snippet saveButton()}
{#if !drawerLoading}
<TriggerEditorToolbar
{isDraftOnly}
{hasDraft}
permissions={drawerLoading || !can_write ? 'none' : can_write && isAdmin ? 'create' : 'write'}
{saveDisabled}
enabled={undefined}
{allowDraft}
{edit}
isLoading={deploymentLoading}
onUpdate={triggerScript}
{onReset}
{onDelete}
{isDeployed}
/>
{/if}
{/snippet}
{#if useDrawer}
<Drawer size="700px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit route ${initialPath}`
: `Route ${initialPath}`
: 'New route'}
on:close={() => drawer?.closeDrawer()}
>
<svelte:fragment slot="actions">
{@render saveButton()}
</svelte:fragment>
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'HTTP Route' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
<svelte:fragment slot="action">
{@render saveButton()}
</svelte:fragment>
{#if description}
{@render description()}
{/if}
</DrawerContent>
</Drawer>
{@render config()}
</Section>
{/if}
<ItemPicker
bind:this={variablePicker}
@@ -644,7 +776,7 @@
itemName="Variable"
extraField="path"
loadItems={loadVariables}
buttons={{ 'Edit/View': (x) => variableEditor.editVariable(x) }}
buttons={{ 'Edit/View': (x) => variableEditor?.editVariable(x) }}
>
<div slot="submission" class="flex flex-row">
<Button
@@ -653,7 +785,7 @@
size="sm"
startIcon={{ icon: Plus }}
on:click={() => {
variableEditor.initNew()
variableEditor?.initNew()
}}
>
New variable
@@ -661,4 +793,4 @@
</div>
</ItemPicker>
<VariableEditor bind:this={variableEditor} on:create={variablePicker.openDrawer} />
<VariableEditor bind:this={variableEditor} on:create={(e) => variablePicker?.openDrawer()} />
@@ -1,123 +1,57 @@
<script lang="ts">
import { userStore, workspaceStore } from '$lib/stores'
import { HttpTriggerService, type HttpTrigger } from '$lib/gen'
import RouteEditor from './RouteEditor.svelte'
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import Section from '$lib/components/Section.svelte'
import { Alert } from '$lib/components/common'
import RouteEditorInner from './RouteEditorInner.svelte'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
import { userStore } from '$lib/stores'
import { Alert } from '$lib/components/common'
import { onMount } from 'svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
export let args: Record<string, any> = {}
let routeEditor = $state<RouteEditorInner | null>(null)
let {
selectedTrigger,
isFlow,
path,
defaultValues = undefined,
isEditor = false,
customLabel = undefined,
...restProps
} = $props()
let routeEditor: RouteEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'routes' &&
Object.keys($defaultValues ?? {}).length > 0
) {
routeEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let httpTriggers: (HttpTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
httpTriggers = (
await HttpTriggerService.listHttpTriggers({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), http_routes_count: httpTriggers?.length }
openForm = httpTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load http routes', e)
async function openRouteEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
routeEditor?.openNew(isFlow, path, defaultValues)
} else {
routeEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
if (routeEditor) {
openRouteEditor(isFlow, selectedTrigger.isDraft ?? false)
}
})
</script>
<RouteEditor
on:update={() => {
loadTriggers()
}}
<RouteEditorInner
useDrawer={false}
bind:this={routeEditor}
/>
hideTarget
{isEditor}
{customLabel}
isDraftOnly={selectedTrigger.isDraft}
allowDraft
hasDraft={!!selectedTrigger.draftConfig}
{...restProps}
>
{#snippet description()}
<div class="flex flex-col gap-2 pb-4">
<Description link="https://www.windmill.dev/docs/core_concepts/http_routing"
>Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a
specific HTTP method and path.</Description
>
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/http_routing">
Routes expose your scripts and flows as HTTP endpoints. Each route can be configured with a
specific HTTP method and path.
</Description>
{#if !newItem && httpTriggers && httpTriggers.length > 0}
<Section label="Routes">
{#if !$userStore?.is_admin && !$userStore?.is_super_admin}
<Alert title="Only workspace admins can create routes" type="warning" size="xs" />
{#if !$userStore?.is_admin && !$userStore?.is_super_admin && selectedTrigger.isDraft}
<Alert title="Only workspace admins can create routes" type="info" size="xs" />
{/if}
<div class="flex flex-col divide-y pt-2">
{#each httpTriggers as httpTriggers (httpTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{httpTriggers.path}</div>
<div class="col-span-2 truncate">
{httpTriggers.http_method.toUpperCase()} /{httpTriggers.route_path}
</div>
<div class="flex justify-end">
<button
on:click={() => routeEditor?.openEdit(httpTriggers.path, isFlow)}
class="px-2"
>
{#if httpTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
routeEditor?.openNew(isFlow, path, e.detail.config)
}}
on:applyArgs
on:addPreprocessor
on:updateSchema
on:testWithArgs
bind:showCapture={dontCloseOnLoad}
cloudDisabled={false}
triggerType="http"
{isFlow}
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
data={{ args }}
bind:openForm
/>
</div>
</div>
{/snippet}
</RouteEditorInner>
@@ -1,5 +1,9 @@
import { base } from '$lib/base'
import { isCloudHosted } from '$lib/cloud'
import { HttpTriggerService } from '$lib/gen/services.gen'
import { sendUserToast } from '$lib/toast'
import type { Writable } from 'svelte/store'
import { get } from 'svelte/store'
export const SECRET_KEY_PATH = 'secret_key_path'
export const HUB_SCRIPT_ID = 19670
@@ -25,3 +29,50 @@ export function replacePlaceholderForSignatureScriptTemplate(content: string) {
`$1${secret_key_path}$2`
)
}
export async function saveHttpRouteFromCfg(
initialPath: string,
routeCfg: Record<string, any>,
edit: boolean,
workspace: string,
isAdmin: boolean,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
const requestBody = {
path: routeCfg.path,
script_path: routeCfg.script_path,
is_flow: routeCfg.is_flow,
is_async: routeCfg.is_async,
authentication_method: routeCfg.authentication_method,
route_path: isAdmin || !edit ? routeCfg.route_path : undefined,
http_method: routeCfg.http_method,
is_static_website: routeCfg.is_static_website,
workspaced_route: routeCfg.workspaced_route,
authentication_resource_path: routeCfg.authentication_resource_path,
wrap_body: routeCfg.wrap_body,
raw_string: routeCfg.raw_string
}
try {
if (edit) {
await HttpTriggerService.updateHttpTrigger({
workspace: workspace,
path: initialPath,
requestBody: requestBody
})
sendUserToast(`Route ${routeCfg.path} updated`)
} else {
await HttpTriggerService.createHttpTrigger({
workspace: workspace,
requestBody: requestBody
})
sendUserToast(`Route ${routeCfg.path} created`)
}
if (!get(usedTriggerKinds).includes('http')) {
usedTriggerKinds.update((t) => [...t, 'http'])
}
return true
} catch (error) {
sendUserToast(error.body || error.message, true)
return false
}
}
@@ -0,0 +1,58 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
interface Props {
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
triggerDeployed?: boolean
}
let {
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
triggerDeployed = false
}: Props = $props()
</script>
{#if captureInfo}
<CaptureSection
captureType="kafka"
disabled={isValid === false}
{captureInfo}
{captureLoading}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
displayAlert={triggerDeployed}
>
{#snippet description()}
{#if captureInfo.active}
{#if captureInfo.connectionInfo?.connected}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to Kafka events...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Connecting to kafka...
</p>
{/if}
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to Kafka events.
</p>
{/if}
{/snippet}
</CaptureSection>
{/if}
@@ -2,7 +2,9 @@
import { tick } from 'svelte'
import KafkaTriggerEditorInner from './KafkaTriggerEditorInner.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: KafkaTriggerEditorInner
let drawer: KafkaTriggerEditorInner | undefined = $state()
</script>
{#if open}
<KafkaTriggerEditorInner on:update bind:this={drawer} />
<KafkaTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -8,35 +8,107 @@
import { KafkaTriggerService } 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 Toggle from '$lib/components/Toggle.svelte'
import KafkaTriggersConfigSection from './KafkaTriggersConfigSection.svelte'
import type { Snippet } from 'svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import { saveKafkaTriggerFromCfg } 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<string, any> | undefined = undefined
let args: Record<string, any> = {}
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<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, 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,
customLabel,
isDeployed = false,
cloudDisabled = false,
onConfigChange = undefined,
onCaptureConfigChange = undefined,
onUpdate = undefined,
onDelete = undefined,
onReset = undefined
}: Props = $props()
$: is_flow = itemKind === 'flow'
let drawer: Drawer | undefined = $state()
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 initialConfig: Record<string, any> | undefined = undefined
let extra_perms = $state<Record<string, any> | undefined>(undefined)
let kafkaCfgValid = $state(false)
let kafkaResourcePath = $state('')
let kafkaCfg: Record<string, any> = $state({})
let deploymentLoading = $state(false)
export async function openEdit(ePath: string, isFlow: boolean) {
const isValid = $derived(
!!kafkaResourcePath &&
kafkaCfgValid &&
kafkaCfg.topics &&
kafkaCfg.topics.length > 0 &&
kafkaCfg.topics.every((b) => /^[a-zA-Z0-9-_.]+$/.test(b))
)
const saveDisabled = $derived(
pathError !== '' ||
!isValid ||
drawerLoading ||
!can_write ||
emptyString(script_path) ||
emptyString(kafkaResourcePath) ||
kafkaCfg.topics.length === 0 ||
kafkaCfg.topics.some((t) => emptyString(t))
)
const kafkaConfig = $derived.by(getSaveCfg)
const captureConfig = $derived.by(isEditor ? getCaptureConfig : () => ({}))
$effect(() => {
is_flow = itemKind === 'flow'
})
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>
) {
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
drawerLoading = true
try {
drawer?.openDrawer()
@@ -44,11 +116,16 @@
itemKind = isFlow ? 'flow' : 'script'
edit = true
dirtyPath = false
await loadTrigger()
await loadTrigger(defaultConfig)
} catch (err) {
sendUserToast(`Could not load Kafka trigger: ${err}`, true)
} finally {
if (!defaultConfig) {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
}
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
}
}
@@ -57,164 +134,209 @@
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>
) {
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
drawerLoading = true
try {
drawer?.openDrawer()
is_flow = nis_flow
edit = false
itemKind = nis_flow ? 'flow' : 'script'
args.kafka_resource_path = nDefaultValues?.kafka_resource_path ?? ''
args.group_id = nDefaultValues?.group_id ?? ''
args.topics = nDefaultValues?.topics ?? ['']
kafkaResourcePath = nDefaultValues?.kafka_resource_path ?? ''
kafkaCfg = {
group_id: nDefaultValues?.group_id ?? '',
topics: nDefaultValues?.topics ?? ['']
}
initialScriptPath = ''
fixedScriptPath = fixedScriptPath_ ?? ''
script_path = fixedScriptPath
path = ''
path = nDefaultValues?.path ?? ''
initialPath = ''
dirtyPath = false
defaultValues = nDefaultValues
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
showLoading = false
}
}
async function loadTrigger(): Promise<void> {
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath
})
script_path = s.script_path
initialScriptPath = s.script_path
function loadTriggerConfig(cfg?: Record<string, any>): void {
script_path = cfg?.script_path
initialScriptPath = cfg?.script_path
is_flow = cfg?.is_flow
path = cfg?.path
kafkaResourcePath = cfg?.kafka_resource_path
kafkaCfg = {
group_id: cfg?.group_id,
topics: cfg?.topics
}
enabled = cfg?.enabled
extra_perms = cfg?.extra_perms
can_write = canWrite(path, cfg?.extra_perms, $userStore)
}
is_flow = s.is_flow
path = s.path
args.kafka_resource_path = s.kafka_resource_path
args.group_id = s.group_id
args.topics = s.topics
enabled = s.enabled
async function loadTrigger(defaultConfig?: Record<string, any>): Promise<void> {
if (defaultConfig) {
loadTriggerConfig(defaultConfig)
return
} else {
const s = await KafkaTriggerService.getKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath
})
loadTriggerConfig(s)
}
}
can_write = canWrite(s.path, s.extra_perms, $userStore)
function getSaveCfg(): Record<string, any> {
return {
path,
script_path,
is_flow,
kafka_resource_path: kafkaResourcePath,
group_id: kafkaCfg.group_id,
topics: kafkaCfg.topics,
enabled,
extra_perms: extra_perms
}
}
async function updateTrigger(): Promise<void> {
if (edit) {
await KafkaTriggerService.updateKafkaTrigger({
workspace: $workspaceStore!,
path: initialPath,
requestBody: {
path,
script_path,
is_flow,
kafka_resource_path: args.kafka_resource_path,
group_id: args.group_id,
topics: args.topics
}
})
sendUserToast(`Kafka trigger ${path} updated`)
} else {
await KafkaTriggerService.createKafkaTrigger({
workspace: $workspaceStore!,
requestBody: {
path,
script_path,
is_flow,
enabled: true,
kafka_resource_path: args.kafka_resource_path,
group_id: args.group_id,
topics: args.topics
}
})
sendUserToast(`Kafka trigger ${path} created`)
}
if (!$usedTriggerKinds.includes('kafka')) {
$usedTriggerKinds = [...$usedTriggerKinds, 'kafka']
}
dispatch('update')
drawer.closeDrawer()
}
function useDefaultValues() {
if (args.kafka_resource_path && args.kafka_resource_path != '') {
return false
}
if (!defaultValues) {
return false
}
return (
defaultValues.brokers &&
defaultValues.brokers.length > 0 &&
defaultValues.brokers.some((broker: string) => broker.trim() !== '')
deploymentLoading = true
const cfg = getSaveCfg()
const isSaved = await saveKafkaTriggerFromCfg(
initialPath,
cfg,
edit,
$workspaceStore!,
usedTriggerKinds
)
if (isSaved) {
onUpdate?.(cfg.path)
drawer?.closeDrawer()
}
deploymentLoading = false
}
let isValid = false
function getCaptureConfig(): Record<string, any> {
return {
kafka_resource_path: kafkaResourcePath,
group_id: kafkaCfg.group_id,
topics: structuredClone($state.snapshot(kafkaCfg.topics)),
path
}
}
async function handleToggleEnabled(nEnabled: boolean) {
await KafkaTriggerService.setKafkaTriggerEnabled({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { enabled: nEnabled }
})
sendUserToast(`${nEnabled ? 'enabled' : 'disabled'} Kafka trigger ${initialPath}`)
}
$effect(() => {
onCaptureConfigChange?.(captureConfig, isValid)
})
$effect(() => {
if (!drawerLoading) {
handleConfigChange(kafkaConfig, initialConfig, saveDisabled, edit, onConfigChange)
}
})
</script>
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit Kafka trigger ${initialPath}`
: `Kafka trigger ${initialPath}`
: 'New Kafka trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{#if !drawerLoading}
{#if edit}
<div class="mr-8 center-center -mt-1">
<Toggle
disabled={!can_write}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={async (e) => {
await KafkaTriggerService.setKafkaTriggerEnabled({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} Kafka trigger ${initialPath}`)
}}
/>
</div>
{/if}
{#if can_write}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || emptyString(script_path) || !can_write || !isValid}
on:click={updateTrigger}
>
Save
</Button>
{/if}
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit Kafka trigger ${initialPath}`
: `Kafka trigger ${initialPath}`
: 'New Kafka trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{@render actionsButtons('sm')}
</svelte:fragment>
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'Kafka trigger' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
{#if drawerLoading}
<Loader2 class="animate-spin" />
{:else}
<Alert title="Info" type="info">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
Kafka consumers can take up to 30 seconds to start.
{/if}
</Alert>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="kafka_trigger"
kind="kafka_trigger"
disabled={!can_write}
/>
</Label>
</div>
<svelte:fragment slot="action">
{@render actionsButtons('xs')}
</svelte:fragment>
{@render config()}
</Section>
{/if}
{#snippet actionsButtons(size: 'xs' | 'sm' = 'sm')}
{#if !drawerLoading}
<TriggerEditorToolbar
{isDraftOnly}
{hasDraft}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{enabled}
{allowDraft}
{edit}
isLoading={deploymentLoading}
{isDeployed}
{saveDisabled}
onUpdate={updateTrigger}
{onReset}
{onDelete}
onToggleEnabled={handleToggleEnabled}
{cloudDisabled}
/>
{/if}
{/snippet}
{#snippet config()}
{#if drawerLoading}
{#if showLoading}
<Loader2 class="animate-spin" />
{/if}
{:else}
<div class="flex flex-col gap-4">
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info" size="xs">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
Kafka consumers can take up to 30 seconds to start.
{/if}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="kafka_trigger"
kind="kafka_trigger"
disabled={!can_write}
disableEditing={!can_write}
/>
</Label>
</div>
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} />
@@ -235,20 +357,23 @@
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
disabled={!can_write}
href={itemKind === 'flow' ? '/flows/add?hub=65' : '/scripts/add?hub=hub%2F19659'}
target="_blank">Create from template</Button
>
{/if}
</div>
</Section>
{/if}
<KafkaTriggersConfigSection
bind:args
bind:isValid
{path}
defaultValues={useDefaultValues() ? defaultValues : undefined}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
<KafkaTriggersConfigSection
bind:kafkaCfgValid
bind:kafkaResourcePath
bind:kafkaCfg
{path}
{can_write}
showTestingBadge={isEditor}
/>
</div>
{/if}
{/snippet}
@@ -1,162 +1,31 @@
<script lang="ts">
import ResourcePicker from '../../ResourcePicker.svelte'
import { Boxes } from 'lucide-svelte'
import Section from '$lib/components/Section.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import ToggleButton from '../../common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '../../common/toggleButton-v2/ToggleButtonGroup.svelte'
import SchemaForm from '../../SchemaForm.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import { workspaceStore } from '$lib/stores'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
export let path: string
export let defaultValues: Record<string, any> | undefined = undefined
export let headless: boolean = false
export let args: Record<string, any> = {}
export let staticInputDisabled: boolean = true
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
export let isValid: boolean = false
import TestingBadge from '../testingBadge.svelte'
let selected: 'resource' | 'static' = staticInputDisabled ? 'resource' : 'static'
const connnectionSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
brokers: {
type: 'array',
items: {
type: 'string'
},
nullable: false,
title: 'Brokers',
default: ['']
},
security: {
type: 'object',
title: 'Security',
oneOf: [
{
type: 'object',
title: 'PLAINTEXT',
properties: {
label: {
enum: ['PLAINTEXT'],
type: 'string'
}
}
},
{
type: 'object',
order: ['mechanism', 'username', 'password'],
title: 'SASL_PLAINTEXT',
required: ['mechanism', 'username', 'password'],
properties: {
label: {
enum: ['SASL_PLAINTEXT'],
type: 'string'
},
password: {
type: 'string',
password: true
},
username: {
type: 'string'
},
mechanism: {
enum: ['PLAIN', 'SCRAM-SHA-256', 'SCRAM-SHA-512'],
type: 'string',
disableCreate: true
}
}
},
{
type: 'object',
title: 'SSL',
order: ['ca', 'certificate', 'key', 'key_password'],
properties: {
label: {
enum: ['SSL'],
type: 'string'
},
ca: {
type: 'string',
description: 'CA certificate to verify the server certificate, in PEM format'
},
certificate: {
type: 'string',
description: 'Client certificate for authentication to the server, in PEM format'
},
key: {
type: 'string',
description: 'Client key for authentication to the server, in PEM format'
},
key_password: {
type: 'string',
description: 'Password to decrypt the client key, if encrypted',
password: true
}
}
},
{
type: 'object',
order: [
'mechanism',
'username',
'password',
'ca',
'certificate',
'key',
'key_password'
],
title: 'SASL_SSL',
required: ['mechanism', 'username', 'password'],
properties: {
label: {
enum: ['SASL_SSL'],
type: 'string'
},
password: {
type: 'string',
password: true
},
username: {
type: 'string'
},
mechanism: {
enum: ['PLAIN', 'SCRAM-SHA-256', 'SCRAM-SHA-512'],
type: 'string',
disableCreate: true
},
ca: {
type: 'string',
description: 'CA certificate to verify the server certificate, in PEM format'
},
certificate: {
type: 'string',
description: 'Client certificate for authentication to the server, in PEM format'
},
key: {
type: 'string',
description: 'Client key for authentication to the server, in PEM format'
},
key_password: {
type: 'string',
description: 'Password to decrypt the client key, if encrypted',
password: true
}
}
}
]
}
},
required: ['brokers', 'security']
interface Props {
path: string
kafkaCfgValid?: boolean
kafkaResourcePath?: string
kafkaCfg?: Record<string, any>
can_write?: boolean
showTestingBadge?: boolean
}
const argsSchema = {
let {
path,
kafkaCfgValid = $bindable(false),
kafkaResourcePath = $bindable(''),
kafkaCfg = $bindable({}),
can_write = true,
showTestingBadge = false
}: Props = $props()
const kafkaConfigSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
@@ -179,105 +48,39 @@
required: ['topics', 'group_id']
}
let isStaticConnectionValid = false
let otherArgsValid = false
$: isConnectionValid =
selected === 'resource'
? !!args.kafka_resource_path
: isStaticConnectionValid &&
args.brokers &&
args.brokers.length > 0 &&
args.brokers.every((b) => b.length > 0)
$: isValid =
isConnectionValid &&
otherArgsValid &&
args.topics &&
args.topics.length > 0 &&
args.topics.every((b) => /^[a-zA-Z0-9-_.]+$/.test(b))
$: usingResource = !!args.kafka_resource_path
$: usingResource && (selected = 'resource')
function setGroupId() {
if (!args.group_id) {
args.group_id = `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`
if (!kafkaCfg.group_id) {
kafkaCfg.group_id = `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`
}
}
$: path && setGroupId()
$effect(() => {
path && setGroupId()
})
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
captureType="kafka"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
bind:captureTable
/>
{/if}
<Section label="Kafka" {headless}>
<Section label="Kafka">
{#snippet header()}
{#if showTestingBadge}
<TestingBadge />
{/if}
{/snippet}
<div class="flex flex-col w-full gap-4">
<div class="block grow w-full">
<Subsection label="Connection">
<svelte:fragment slot="header">
{#if !staticInputDisabled}
<ToggleButtonGroup
bind:selected
class="h-full"
on:selected={(ev) => {
if (ev.detail === 'static') {
delete args.kafka_resource_path
args.brokers = ['']
args.security = {
label: 'PLAINTEXT'
}
} else {
delete args.brokers
delete args.security
}
}}
let:item
>
<ToggleButton value="static" label="Static" small={true} {item} />
<ToggleButton value="resource" label="Resource" icon={Boxes} small={true} {item} />
</ToggleButtonGroup>
{/if}
</svelte:fragment>
{#if selected === 'resource'}
<ResourcePicker
resourceType="kafka"
bind:value={args.kafka_resource_path}
{defaultValues}
/>
{:else}
<SchemaForm
schema={connnectionSchema}
bind:args={
() => args,
(v) => {
args = {
...args,
...v
}
}
}
bind:isValid={isStaticConnectionValid}
lightHeader={true}
/>
{/if}
{#if isConnectionValid}
<ResourcePicker
resourceType="kafka"
bind:value={kafkaResourcePath}
disabled={!can_write}
/>
{#if !!kafkaResourcePath}
<TestTriggerConnection
kind="kafka"
args={{
connection: args
connection: {
kafka_resource_path: kafkaResourcePath
}
}}
/>
{/if}
@@ -287,18 +90,11 @@
<div class="block grow w-full">
<Subsection headless={true}>
<SchemaForm
schema={argsSchema}
bind:args={
() => args,
(v) => {
args = {
...args,
...v
}
}
}
bind:isValid={otherArgsValid}
schema={kafkaConfigSchema}
bind:args={kafkaCfg}
bind:isValid={kafkaCfgValid}
lightHeader={true}
disabled={!can_write}
/>
</Subsection>
</div>
@@ -1,137 +1,69 @@
<script lang="ts">
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { KafkaTriggerService, type KafkaTrigger } from '$lib/gen'
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import KafkaTriggerEditor from './KafkaTriggerEditor.svelte'
import KafkaTriggerEditorInner from './KafkaTriggerEditorInner.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import Description from '$lib/components/Description.svelte'
import { Alert } from '$lib/components/common'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
import Description from '$lib/components/Description.svelte'
import { enterpriseLicense } from '$lib/stores'
import { onMount } from 'svelte'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
let {
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
newDraft = false,
customLabel = undefined,
...restProps
} = $props()
let kafkaTriggerEditor: KafkaTriggerEditorInner | undefined = $state(undefined)
let kafkaTriggerEditor: KafkaTriggerEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
async function openKafkaTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
kafkaTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
kafkaTriggerEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
}
}
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'kafka' &&
Object.keys($defaultValues ?? {}).length > 0
) {
kafkaTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
openKafkaTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
})
let kafkaTriggers: (KafkaTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
kafkaTriggers = (
await KafkaTriggerService.listKafkaTriggers({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), kafka_count: kafkaTriggers?.length }
openForm = kafkaTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load Kafka triggers', e)
}
}
let data = {
kafkaTriggers: [],
newItem
}
function saveTrigger(path: string, args?: Record<string, any>) {
kafkaTriggerEditor?.openNew(isFlow, path, args)
}
const cloudDisabled = $derived(isCloudHosted())
</script>
<KafkaTriggerEditor
on:update={() => {
loadTriggers()
}}
bind:this={kafkaTriggerEditor}
/>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Kafka triggers are an enterprise only feature.
</Alert>
{:else if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Kafka triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/kafka_triggers">
Kafka triggers execute scripts and flows in response to messages published to Kafka topics.
</Description>
{#if !newItem && kafkaTriggers && kafkaTriggers.length > 0}
<Section label="Kafka triggers">
<div class="flex flex-col divide-y pt-2">
{#each kafkaTriggers as kafkaTrigger (kafkaTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{kafkaTrigger.path}</div>
<div class="col-span-2 truncate">
{kafkaTrigger.kafka_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => kafkaTriggerEditor?.openEdit(kafkaTrigger.path, isFlow)}
class="px-2"
>
{#if kafkaTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
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}
/>
<KafkaTriggerEditorInner
bind:this={kafkaTriggerEditor}
useDrawer={false}
hideTarget
hideTooltips={!isDeployed || cloudDisabled}
allowDraft={true}
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
{customLabel}
{isDeployed}
{cloudDisabled}
{...restProps}
>
{#snippet description()}
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Kafka triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<Description link="https://www.windmill.dev/docs/core_concepts/kafka_triggers">
Kafka triggers execute scripts and flows in response to messages published to Kafka
topics.
</Description>
{/if}
{/snippet}
</KafkaTriggerEditorInner>
</div>
{/if}
@@ -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<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
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
}
}
@@ -0,0 +1,58 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
interface Props {
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
triggerDeployed?: boolean
}
let {
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
triggerDeployed = false
}: Props = $props()
</script>
{#if captureInfo}
<CaptureSection
captureType="mqtt"
disabled={isValid === false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
{captureLoading}
displayAlert={triggerDeployed}
>
{#snippet description()}
{#if captureInfo.active}
{#if captureInfo.connectionInfo?.connected}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to MQTT messages...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Connecting to mqtt...
</p>
{/if}
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to MQTT messages.
</p>
{/if}
{/snippet}
</CaptureSection>
{/if}
@@ -1,7 +1,5 @@
<script lang="ts">
import Section from '$lib/components/Section.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import Required from '$lib/components/Required.svelte'
import { Plus, X } from 'lucide-svelte'
import Subsection from '$lib/components/Subsection.svelte'
@@ -17,19 +15,18 @@
import TestTriggerConnection from '../TestTriggerConnection.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import TestingBadge from '../testingBadge.svelte'
export let can_write: boolean = false
export let headless: boolean = false
export let showCapture: boolean = false
export let mqtt_resource_path: string = ''
export let subscribe_topics: MqttSubscribeTopic[] = []
export let captureTable: CaptureTable | undefined = undefined
export let captureInfo: CaptureInfo | undefined = undefined
export let v3_config: MqttV3Config = DEFAULT_V3_CONFIG
export let v5_config: MqttV5Config = DEFAULT_V5_CONFIG
export let client_version: MqttClientVersion = 'v5'
export let isValid: boolean = false
export let client_id: string = ''
export let showTestingBadge: boolean = false
const activateV5Options = {
topic_alias: Boolean(v5_config.topic_alias),
@@ -50,20 +47,12 @@
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
captureType="mqtt"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
bind:captureTable
/>
{/if}
<Section label="MQTT" {headless}>
<svelte:fragment slot="header">
{#if showTestingBadge}
<TestingBadge />
{/if}
</svelte:fragment>
<div class="flex flex-col w-full gap-4">
<Subsection label="Connection setup">
<ResourcePicker resourceType="mqtt" disabled={!can_write} bind:value={mqtt_resource_path} />
@@ -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()
</script>
{#if open}
<MqttTriggerEditorInner on:update bind:this={drawer} />
<MqttTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -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<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, 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<string, any> | 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<string, any>
) {
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<string, any>
) {
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<void> {
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
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<string, any>): Promise<void> {
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<void> {
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<string, any> {
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<string, any> {
return {
mqtt_resource_path,
subscribe_topics,
client_version,
v3_config,
v5_config,
client_id,
path
}
}
async function updateTrigger(): Promise<void> {
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)
}
})
</script>
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit MQTT trigger ${initialPath}`
: `MQTT trigger ${initialPath}`
: 'New MQTT trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{#if !drawerLoading && can_write}
{#if edit}
<div class="mr-8 center-center -mt-1">
<Toggle
disabled={!can_write}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={async (e) => {
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} mqtt trigger ${initialPath}`)
}}
/>
</div>
{/if}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || emptyString(script_path) || !can_write || !isValid}
on:click={updateTrigger}
>
Save
</Button>
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit MQTT trigger ${initialPath}`
: `MQTT trigger ${initialPath}`
: 'New MQTT trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{@render actions()}
</svelte:fragment>
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'MQTT trigger' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
{#if drawerLoading}
<div class="flex flex-col items-center justify-center h-full w-full">
<Loader2 size="50" class="animate-spin" />
<p>Loading...</p>
</div>
{:else}
<div class="flex flex-col gap-5">
<Alert title="Info" type="info">
<svelte:fragment slot="action">
{@render actions()}
</svelte:fragment>
{@render config()}
</Section>
{/if}
{#snippet actions()}
{#if !drawerLoading}
<TriggerEditorToolbar
{isDraftOnly}
{hasDraft}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{enabled}
{allowDraft}
{edit}
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
{onReset}
{onDelete}
onToggleEnabled={handleToggleEnabled}
{cloudDisabled}
/>
{/if}
{/snippet}
{#snippet config()}
{#if drawerLoading}
{#if showLoading}
<Loader2 class="animate-spin" />
{/if}
{:else}
<div class="flex flex-col gap-4">
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info" size="xs">
{#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}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="mqtt_trigger"
kind="mqtt_trigger"
disabled={!can_write}
disableEditing={!can_write}
/>
</Label>
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="mqtt_trigger"
kind="mqtt_trigger"
disabled={!can_write}
/>
</Label>
</div>
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} />
@@ -232,32 +360,37 @@
allowFlow={true}
bind:itemKind
bind:scriptPath={script_path}
allowRefresh
allowRefresh={can_write}
allowEdit={!$userStore?.operator}
/>
{#if emptyString(script_path)}
<Button
btnClasses="ml-4 mt-2"
color="dark"
size="xs"
disabled={!can_write}
href={itemKind === 'flow' ? '/flows/add?hub=61' : '/scripts/add?hub=hub%2F19655'}
target="_blank">Create from template</Button
target="_blank"
>
Create from template
</Button>
{/if}
</div>
</Section>
{/if}
<MqttEditorConfigSection
bind:mqtt_resource_path
bind:subscribe_topics
bind:can_write
bind:client_version
bind:v3_config
bind:v5_config
bind:isValid
bind:client_id
headless={true}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
<MqttEditorConfigSection
bind:mqtt_resource_path
bind:subscribe_topics
{can_write}
bind:client_version
bind:v3_config
bind:v5_config
bind:isValid
bind:client_id
headless={true}
showTestingBadge={isEditor}
/>
</div>
{/if}
{/snippet}
@@ -1,126 +1,62 @@
<script lang="ts">
import { userStore, workspaceStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import MqttTriggerEditorInner from './MqttTriggerEditorInner.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
import MqttTriggerEditor from './MqttTriggerEditor.svelte'
import { MqttTriggerService, type MqttTrigger } from '$lib/gen'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
import { onMount } from 'svelte'
let mqttTriggerEditor: MqttTriggerEditor
let openForm = true
let dontCloseOnLoad = false
let {
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
newDraft = false,
customLabel = undefined,
...props
} = $props()
let mqttTriggerEditor: MqttTriggerEditorInner | undefined = $state(undefined)
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'mqtt' &&
Object.keys($defaultValues ?? {}).length > 0
) {
mqttTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
})
let mqttTriggers: (MqttTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
mqttTriggers = (
await MqttTriggerService.listMqttTriggers({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), mqtt_count: mqttTriggers?.length }
openForm = mqttTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load mqtt triggers', e)
async function openMqttTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
mqttTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
mqttTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
mqttTriggerEditor && openMqttTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
})
const cloudDisabled = $derived(isCloudHosted())
</script>
<MqttTriggerEditor
on:update={() => {
loadTriggers()
}}
bind:this={mqttTriggerEditor}
/>
{#if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
MQTT triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://mqtt.org/">
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
</Description>
{#if !newItem && mqttTriggers && mqttTriggers.length > 0}
<Section label="MQTT">
<div class="flex flex-col gap-4">
<div class="flex flex-col divide-y pt-2">
{#each mqttTriggers as mqttTriggers (mqttTriggers.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{mqttTriggers.path}</div>
<div class="flex justify-end">
<button
on:click={() => mqttTriggerEditor?.openEdit(mqttTriggers.path, isFlow)}
class="px-2"
>
{#if mqttTriggers.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</div>
</Section>
{/if}
<TriggersEditorSection
on:applyArgs
on:saveTrigger={(e) => {
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}
/>
</div>
{/if}
<div class="flex flex-col gap-4">
<MqttTriggerEditorInner
bind:this={mqttTriggerEditor}
useDrawer={false}
hideTarget
hideTooltips={!isDeployed || cloudDisabled}
allowDraft={true}
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
{customLabel}
{isDeployed}
{cloudDisabled}
{...props}
>
{#snippet description()}
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
MQTT triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<Description link="https://www.windmill.dev/docs/core_concepts/mqtt_triggers">
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.
</Description>
{/if}
{/snippet}
</MqttTriggerEditorInner>
</div>
@@ -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<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
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
}
}
@@ -0,0 +1,58 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
interface Props {
captureInfo?: CaptureInfo | undefined
isValid?: boolean | undefined
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
triggerDeployed?: boolean
}
let {
captureInfo = undefined,
isValid = undefined,
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
triggerDeployed = false
}: Props = $props()
</script>
{#if captureInfo}
<CaptureSection
captureType="nats"
disabled={isValid === false}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
{captureLoading}
displayAlert={triggerDeployed}
>
{#snippet description()}
{#if captureInfo.active}
{#if captureInfo.connectionInfo?.connected}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to NATS messages...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Connecting to nats...
</p>
{/if}
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to NATS messages.
</p>
{/if}
{/snippet}
</CaptureSection>
{/if}
@@ -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()
</script>
{#if open}
<NatsTriggerEditorInner on:update bind:this={drawer} />
<NatsTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}
@@ -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<string, any> | undefined = undefined
let args: Record<string, any> = {}
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<string, any>, saveDisabled: boolean, updated: boolean) => void
onCaptureConfigChange?: (cfg: Record<string, any>, 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<string, any> | undefined = $state(undefined)
let natsResourcePath = $state('')
let subjects = $state([''])
let useJetstream = $state(false)
let streamName = $state('')
let consumerName = $state('')
let initialConfig: Record<string, any> | 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<string, any>
) {
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<string, any>
) {
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<void> {
const s = await NatsTriggerService.getNatsTrigger({
workspace: $workspaceStore!,
path: initialPath
})
script_path = s.script_path
initialScriptPath = s.script_path
async function loadTriggerConfig(cfg?: Record<string, any>): Promise<void> {
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<string, any>): Promise<void> {
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<void> {
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)
})
</script>
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit NATS trigger ${initialPath}`
: `NATS trigger ${initialPath}`
: 'New NATS trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{#if !drawerLoading}
{#if edit}
<div class="mr-8 center-center -mt-1">
<Toggle
disabled={!can_write}
checked={enabled}
options={{ right: 'enable', left: 'disable' }}
on:change={async (e) => {
await NatsTriggerService.setNatsTriggerEnabled({
path: initialPath,
workspace: $workspaceStore ?? '',
requestBody: { enabled: e.detail }
})
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} NATS trigger ${initialPath}`)
}}
/>
</div>
{/if}
{#if can_write}
<Button
startIcon={{ icon: Save }}
disabled={pathError != '' || emptyString(script_path) || !can_write || !isValid}
on:click={updateTrigger}
>
Save
</Button>
{/if}
{#if useDrawer}
<Drawer size="800px" bind:this={drawer}>
<DrawerContent
title={edit
? can_write
? `Edit NATS trigger ${initialPath}`
: `NATS trigger ${initialPath}`
: 'New NATS trigger'}
on:close={drawer.closeDrawer}
>
<svelte:fragment slot="actions">
{@render actions()}
</svelte:fragment>
{@render config()}
</DrawerContent>
</Drawer>
{:else}
<Section label={!customLabel ? 'NATS trigger' : ''} headerClass="grow min-w-0 h-[30px]">
<svelte:fragment slot="header">
{#if customLabel}
{@render customLabel()}
{/if}
</svelte:fragment>
{#if drawerLoading}
<svelte:fragment slot="action">
{@render actions()}
</svelte:fragment>
{@render config()}
</Section>
{/if}
{#snippet actions()}
{#if !drawerLoading}
<TriggerEditorToolbar
{isDraftOnly}
{hasDraft}
permissions={drawerLoading || !can_write ? 'none' : 'create'}
{saveDisabled}
{enabled}
{allowDraft}
{edit}
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
{onReset}
{onDelete}
onToggleEnabled={handleToggleEnabled}
{cloudDisabled}
/>
{/if}
{/snippet}
{#snippet config()}
{#if drawerLoading}
{#if showLoading}
<Loader2 class="animate-spin" />
{:else}
<Alert title="Info" type="info">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
NATS consumers can take up to 30 seconds to start.
{/if}
</Alert>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="nats_trigger"
kind="nats_trigger"
disabled={!can_write}
/>
</Label>
</div>
{/if}
{:else}
<div class="flex flex-col gap-4">
{#if description}
{@render description()}
{/if}
{#if !hideTooltips}
<Alert title="Info" type="info" size="xs">
{#if edit}
Changes can take up to 30 seconds to take effect.
{:else}
NATS consumers can take up to 30 seconds to start.
{/if}
</Alert>
{/if}
</div>
<div class="flex flex-col gap-12 mt-6">
<div class="flex flex-col gap-4">
<Label label="Path">
<Path
bind:dirty={dirtyPath}
bind:error={pathError}
bind:path
{initialPath}
checkInitialPathExistence={!edit}
namePlaceholder="nats_trigger"
kind="nats_trigger"
disabled={!can_write}
/>
</Label>
</div>
{#if !hideTarget}
<Section label="Runnable">
<p class="text-xs mb-1 text-tertiary">
Pick a script or flow to be triggered<Required required={true} />
@@ -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</Button
target="_blank"
>
Create from template
</Button>
{/if}
</div>
</Section>
{/if}
<NatsTriggersConfigSection
{path}
bind:args
bind:isValid
defaultValues={useDefaultValues() ? defaultValues : undefined}
/>
</div>
{/if}
</DrawerContent>
</Drawer>
<NatsTriggersConfigSection
{path}
bind:natsResourcePath
bind:subjects
bind:useJetstream
bind:streamName
bind:consumerName
on:valid-config={({ detail }) => {
isValid = detail
}}
defaultValues={useDefaultValues() ? defaultValues : undefined}
{can_write}
showTestingBadge={isEditor}
/>
</div>
{/if}
{/snippet}
@@ -1,135 +1,49 @@
<script lang="ts">
import { Boxes } from 'lucide-svelte'
import Section from '$lib/components/Section.svelte'
import Subsection from '$lib/components/Subsection.svelte'
import { workspaceStore } from '$lib/stores'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
export let defaultValues: Record<string, any> | undefined = undefined
export let headless: boolean = false
export let args: Record<string, any> = {}
export let staticInputDisabled: boolean = true
export let showCapture: boolean = false
export let captureInfo: CaptureInfo | undefined = undefined
export let captureTable: CaptureTable | undefined = undefined
export let isValid: boolean = false
export let path: string
let selected: 'resource' | 'static' = staticInputDisabled ? 'resource' : 'static'
import TestingBadge from '../testingBadge.svelte'
import { createEventDispatcher } from 'svelte'
const connnectionSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
servers: {
type: 'array',
items: {
type: 'string'
},
title: 'Servers',
default: ['']
},
require_tls: {
type: 'boolean',
title: 'Require TLS',
default: false
},
auth: {
type: 'object',
title: 'Authentication',
default: {
label: 'NO_AUTH'
},
oneOf: [
{
type: 'object',
title: 'NO_AUTH',
properties: {
label: {
enum: ['NO_AUTH'],
type: 'string'
}
}
},
{
type: 'object',
order: ['token'],
title: 'TOKEN',
required: ['token'],
properties: {
label: {
enum: ['TOKEN'],
type: 'string'
},
token: {
type: 'string',
password: true
}
}
},
{
type: 'object',
title: 'USER_PASSWORD',
required: ['user', 'password'],
properties: {
label: {
enum: ['USER_PASSWORD'],
type: 'string'
},
user: {
type: 'string'
},
password: {
type: 'string',
password: true
}
}
},
{
type: 'object',
order: ['nkey'],
title: 'NKEY',
required: ['nkey'],
properties: {
label: {
enum: ['NKEY'],
type: 'string'
},
seed: {
type: 'string',
password: true
}
}
},
{
type: 'object',
title: 'JWT',
required: ['jwt', 'seed'],
properties: {
label: {
enum: ['JWT'],
type: 'string'
},
jwt: {
type: 'string',
password: true
},
seed: {
type: 'string',
password: true
}
}
}
]
}
},
required: ['servers', 'auth', 'require_tls']
interface Props {
defaultValues?: Record<string, any> | undefined
headless?: boolean
natsResourcePath: string
subjects: string[]
useJetstream: boolean
streamName: string
consumerName: string
path: string
can_write?: boolean
showTestingBadge?: boolean
}
const dispatch = createEventDispatcher()
let {
defaultValues = undefined,
headless = false,
natsResourcePath = $bindable(),
subjects = $bindable(),
useJetstream = $bindable(),
streamName = $bindable(),
consumerName = $bindable(),
path,
can_write = true,
showTestingBadge = false
}: Props = $props()
let otherArgsValid = $state(false)
let globalError = $derived(
!useJetstream && subjects && subjects.length > 1
? 'Only one subject is supported if not using JetStream.'
: ''
)
let isConnectionValid = $derived(!!natsResourcePath)
const argsSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
@@ -173,116 +87,71 @@
required: ['subjects', 'use_jetstream', 'stream_name', 'consumer_name']
}
let isStaticConnectionValid = false
let otherArgsValid = false
let globalError = ''
$: globalError =
!args.use_jetstream && args.subjects && args.subjects.length > 1
? 'Only one subject is supported if not using JetStream.'
: ''
$: isConnectionValid =
selected === 'resource'
? !!args.nats_resource_path
: isStaticConnectionValid &&
args.servers &&
args.servers.length > 0 &&
args.servers.every((b) => b.length > 0) &&
args.require_tls !== undefined &&
args.require_tls !== null
$: isValid =
isConnectionValid &&
otherArgsValid &&
args.subjects &&
args.subjects.length > 0 &&
args.subjects.every((b) => /^[a-zA-Z0-9-_.*>]+$/.test(b)) &&
globalError === ''
$: usingResource = !!args.nats_resource_path
$: usingResource && (selected = 'resource')
$effect(() => {
const valid =
isConnectionValid &&
otherArgsValid &&
!!subjects &&
subjects.length > 0 &&
subjects.every((b) => /^[a-zA-Z0-9-_.*>]+$/.test(b)) &&
globalError === ''
dispatch('valid-config', valid)
})
function setStreamAndConsumerNames() {
if (!args.stream_name) {
args.stream_name = `windmill_stream-${$workspaceStore}-${path.replaceAll('/', '__')}`
if (!streamName) {
streamName = `windmill_stream-${$workspaceStore}-${path.replaceAll('/', '__')}`
}
if (!args.consumer_name) {
args.consumer_name = `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`
if (!consumerName) {
consumerName = `windmill_consumer-${$workspaceStore}-${path.replaceAll('/', '__')}`
}
}
function setNewArgs(args: Record<string, any>) {
subjects = args.subjects
useJetstream = args.use_jetstream
streamName = args.stream_name
consumerName = args.consumer_name
if (args.use_jetstream) {
setStreamAndConsumerNames()
}
}
function getNatsArgsCfg() {
return {
subjects,
use_jetstream: useJetstream,
stream_name: streamName,
consumer_name: consumerName
}
}
$: useJetStream = args.use_jetstream
$: useJetStream && setStreamAndConsumerNames()
</script>
<div>
{#if showCapture && captureInfo}
<CaptureSection
captureType="nats"
disabled={!isValid}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
bind:captureTable
/>
{/if}
<Section label="NATS" {headless}>
{#snippet badge()}
{#if showTestingBadge}
<TestingBadge />
{/if}
{/snippet}
<div class="flex flex-col w-full gap-4">
<div class="block grow w-full">
<Subsection label="Connection">
<svelte:fragment slot="header">
{#if !staticInputDisabled}
<ToggleButtonGroup
bind:selected
class="h-full"
on:selected={(ev) => {
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
>
<ToggleButton value="static" label="Static" small={true} {item} />
<ToggleButton value="resource" label="Resource" icon={Boxes} small={true} {item} />
</ToggleButtonGroup>
{/if}
</svelte:fragment>
{#if selected === 'resource'}
<ResourcePicker
resourceType="nats"
bind:value={args.nats_resource_path}
{defaultValues}
/>
{:else}
<SchemaForm
schema={connnectionSchema}
bind:args={
() => args,
(v) => {
args = {
...args,
...v
}
}
}
bind:isValid={isStaticConnectionValid}
lightHeader={true}
/>
{/if}
<ResourcePicker
resourceType="nats"
bind:value={natsResourcePath}
{defaultValues}
disabled={!can_write}
/>
{#if isConnectionValid}
<TestTriggerConnection kind="nats" args={{ connection: args }} />
<TestTriggerConnection
kind="nats"
args={{
connection: {
nats_resource_path: natsResourcePath
}
}}
/>
{/if}
</Subsection>
</div>
@@ -291,17 +160,10 @@
<Subsection headless={true}>
<SchemaForm
schema={argsSchema}
bind:args={
() => args,
(v) => {
args = {
...args,
...v
}
}
}
bind:args={getNatsArgsCfg, (args) => setNewArgs(args)}
bind:isValid={otherArgsValid}
lightHeader={true}
disabled={!can_write}
/>
</Subsection>
<span class="text-xs text-red-500">{globalError}</span>
@@ -1,137 +1,69 @@
<script lang="ts">
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { NatsTriggerService, type NatsTrigger } from '$lib/gen'
import { canWrite } from '$lib/utils'
import { getContext, onMount } from 'svelte'
import NatsTriggerEditor from './NatsTriggerEditor.svelte'
import NatsTriggerEditorInner from './NatsTriggerEditorInner.svelte'
import { isCloudHosted } from '$lib/cloud'
import Section from '$lib/components/Section.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import type { TriggerContext } from '$lib/components/triggers'
import TriggersEditorSection from '../TriggersEditorSection.svelte'
import { onMount } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
export let isFlow: boolean
export let path: string
export let newItem: boolean = false
export let isEditor: boolean = false
export let canHavePreprocessor: boolean = false
export let hasPreprocessor: boolean = false
let {
selectedTrigger,
isFlow,
path,
isDeployed = false,
defaultValues = undefined,
customLabel = undefined,
...props
} = $props()
let natsTriggerEditor: NatsTriggerEditorInner | undefined = $state(undefined)
let natsTriggerEditor: NatsTriggerEditor
let openForm = true
let dontCloseOnLoad = false
$: path && loadTriggers()
const { triggersCount, selectedTrigger, defaultValues } =
getContext<TriggerContext>('TriggerContext')
async function openNatsTriggerEditor(isFlow: boolean, isDraft: boolean) {
if (isDraft) {
natsTriggerEditor?.openNew(isFlow, path, defaultValues)
} else {
natsTriggerEditor?.openEdit(selectedTrigger.path, isFlow, selectedTrigger.draftConfig)
}
}
onMount(() => {
if (
defaultValues &&
$selectedTrigger === 'nats' &&
Object.keys($defaultValues ?? {}).length > 0
) {
natsTriggerEditor.openNew(isFlow, path, $defaultValues)
defaultValues.set(undefined)
}
natsTriggerEditor && openNatsTriggerEditor(isFlow, selectedTrigger.isDraft ?? false)
})
let natsTriggers: (NatsTrigger & { canWrite: boolean })[] | undefined = undefined
export async function loadTriggers() {
try {
natsTriggers = (
await NatsTriggerService.listNatsTriggers({
workspace: $workspaceStore ?? '',
path,
isFlow
})
).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
})
$triggersCount = { ...($triggersCount ?? {}), nats_count: natsTriggers?.length }
openForm = natsTriggers?.length === 0 || dontCloseOnLoad
} catch (e) {
console.error('impossible to load nats triggers', e)
}
}
let data = {
natsTriggers: [],
newItem
}
function saveTrigger(path: string, args?: Record<string, any>) {
natsTriggerEditor?.openNew(isFlow, path, args)
}
const cloudDisabled = $derived(isCloudHosted())
</script>
<NatsTriggerEditor
on:update={() => {
loadTriggers()
}}
bind:this={natsTriggerEditor}
/>
{#if !$enterpriseLicense}
<Alert title="EE Only" type="warning" size="xs">
Nats triggers are an enterprise only feature.
</Alert>
{:else if isCloudHosted()}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
Nats triggers are disabled in the multi-tenant cloud.
NATS triggers are an enterprise only feature.
</Alert>
{:else}
<div class="flex flex-col gap-4">
<Description link="https://www.windmill.dev/docs/core_concepts/nats_triggers">
NATS triggers execute scripts and flows in response to messages published to NATS subjects.
</Description>
{#if !newItem && natsTriggers && natsTriggers.length > 0}
<Section label="NATS Triggers">
<div class="flex flex-col divide-y pt-2">
{#each natsTriggers as natsTrigger (natsTrigger.path)}
<div class="grid grid-cols-5 text-2xs items-center py-2">
<div class="col-span-2 truncate">{natsTrigger.path}</div>
<div class="col-span-2 truncate">
{natsTrigger.nats_resource_path}
</div>
<div class="flex justify-end">
<button
on:click={() => natsTriggerEditor?.openEdit(natsTrigger.path, isFlow)}
class="px-2"
>
{#if natsTrigger.canWrite}
Edit
{:else}
View
{/if}
</button>
</div>
</div>
{/each}
</div>
</Section>
{/if}
<TriggersEditorSection
on:saveTrigger={(e) => {
saveTrigger(path, e.detail.config)
}}
on:applyArgs
on:addPreprocessor
cloudDisabled={false}
triggerType="nats"
{isFlow}
{data}
{path}
{isEditor}
{canHavePreprocessor}
{hasPreprocessor}
{newItem}
{openForm}
bind:showCapture={dontCloseOnLoad}
/>
<NatsTriggerEditorInner
bind:this={natsTriggerEditor}
useDrawer={false}
hideTarget
hideTooltips={!isDeployed}
useEditButton
allowDraft={true}
hasDraft={!!selectedTrigger.draftConfig}
isDraftOnly={selectedTrigger.isDraft}
{customLabel}
{isDeployed}
{cloudDisabled}
{...props}
>
{#snippet description()}
{#if cloudDisabled}
<Alert title="Not compatible with multi-tenant cloud" type="warning" size="xs">
NATS triggers are disabled in the multi-tenant cloud.
</Alert>
{:else}
<Description link="https://www.windmill.dev/docs/core_concepts/nats_triggers">
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.
</Description>
{/if}
{/snippet}
</NatsTriggerEditorInner>
</div>
{/if}
@@ -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<string, any>,
edit: boolean,
workspace: string,
usedTriggerKinds: Writable<string[]>
): Promise<boolean> {
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
}
}
@@ -0,0 +1,58 @@
<script lang="ts">
import { fade } from 'svelte/transition'
import type { CaptureInfo } from '../CaptureSection.svelte'
import CaptureSection from '../CaptureSection.svelte'
interface Props {
captureInfo?: CaptureInfo | undefined
postgres_resource_path?: string
hasPreprocessor?: boolean
isFlow?: boolean
captureLoading?: boolean
triggerDeployed?: boolean
}
let {
captureInfo = undefined,
postgres_resource_path = '',
hasPreprocessor = false,
isFlow = false,
captureLoading = false,
triggerDeployed = false
}: Props = $props()
</script>
{#if captureInfo}
<CaptureSection
captureType="postgres"
disabled={!postgres_resource_path}
{captureInfo}
on:captureToggle
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
{hasPreprocessor}
{isFlow}
{captureLoading}
displayAlert={triggerDeployed}
>
{#snippet description()}
{#if captureInfo.active}
{#if captureInfo.connectionInfo?.connected}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Listening to Postgres database transactions...
</p>
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Connecting to Postgres database...
</p>
{/if}
{:else}
<p in:fade={{ duration: 100, delay: 50 }} out:fade={{ duration: 50 }}>
Start capturing to listen to Postgres database transactions.
</p>
{/if}
{/snippet}
</CaptureSection>
{/if}
@@ -1,141 +0,0 @@
<script lang="ts">
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import CaptureSection, { type CaptureInfo } from '../CaptureSection.svelte'
import CaptureTable from '../CaptureTable.svelte'
import TestTriggerConnection from '../TestTriggerConnection.svelte'
import Required from '$lib/components/Required.svelte'
import MultiSelect from 'svelte-multiselect'
import RelationPicker from './RelationPicker.svelte'
import type { PublicationData } from '$lib/gen'
import { emptyString } from '$lib/utils'
import CheckPostgresRequirement from './CheckPostgresRequirement.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { X } from 'lucide-svelte'
const DEFAULT_PUBLICATION: PublicationData = {
transaction_to_track: ['Insert', 'Update', 'Delete'],
table_to_track: [
{
schema_name: 'public',
table_to_track: []
}
]
}
let transactionType: string[] = ['Insert', 'Update', 'Delete']
let isValid: boolean = false
export let headless: boolean = false
export let can_write: boolean = false
export let showCapture: boolean = false
export let captureTable: CaptureTable | undefined = undefined
export let captureInfo: CaptureInfo | undefined = undefined
export let postgres_resource_path: string = ''
export let publication: PublicationData = DEFAULT_PUBLICATION
function updateValidity(publication: PublicationData) {
isValid =
!emptyString(postgres_resource_path) &&
(!publication.table_to_track || publication.table_to_track.length !== 0)
}
$: updateValidity(publication)
let testTriggerConnection: TestTriggerConnection | undefined = undefined
</script>
<div class="h-full">
{#if showCapture && captureInfo}
<CaptureSection
disabled={!isValid}
on:captureToggle
captureType="postgres"
{captureInfo}
on:applyArgs
on:updateSchema
on:addPreprocessor
on:testWithArgs
bind:captureTable
/>
{/if}
<Section label="Postgres config" {headless}>
<div class="flex flex-col gap-4">
<div class="mb-2">
<p class="text-xs mb-1 text-tertiary">
Pick a database to connect to <Required required={true} />
</p>
<ResourcePicker
disabled={!can_write}
bind:value={postgres_resource_path}
resourceType={'postgresql'}
on:change={() => {
if (emptyString(postgres_resource_path)) {
publication = { ...DEFAULT_PUBLICATION }
}
}}
/>
{#if postgres_resource_path}
<TestTriggerConnection
kind="postgres"
args={{ postgres_resource_path }}
noButton
bind:this={testTriggerConnection}
/>
<CheckPostgresRequirement
bind:postgres_resource_path
bind:can_write
checkConnection={testTriggerConnection?.testTriggerConnection}
/>
{/if}
</div>
{#if postgres_resource_path}
<Label label="Transactions">
<svelte:fragment slot="header">
<Tooltip small>
Choose the types of database transactions that should trigger a script or flow. You
can select from <strong>Insert</strong>, <strong>Update</strong>,
<strong>Delete</strong>, or any combination of these operations to define when the
trigger should activate.
</Tooltip>
</svelte:fragment>
<MultiSelect
noMatchingOptionsMsg=""
createOptionMsg={null}
duplicates={false}
options={transactionType}
allowUserOptions="append"
bind:selected={publication.transaction_to_track}
ulOptionsClass={'!bg-surface !text-sm'}
ulSelectedClass="!text-sm"
outerDivClass="!bg-surface !min-h-[38px] !border-[#d1d5db]"
placeholder="Select transactions"
--sms-options-margin="4px"
>
<svelte:fragment slot="remove-icon">
<div class="hover:text-primary p-0.5">
<X size={12} />
</div>
</svelte:fragment>
</MultiSelect>
</Label>
<Label label="Table tracking">
<svelte:fragment slot="header">
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/postgres_triggers#define-what-to-track"
small
>
Select the tables to track. You can choose to track
<strong>all tables in your database</strong>,
<strong>all tables within a specific schema</strong>,
<strong>specific tables in a schema</strong>, or even
<strong>specific columns of a table</strong>. Additionally, you can apply a
<strong>filter</strong> to retrieve only rows that do not match the specified criteria.
</Tooltip>
</svelte:fragment>
<RelationPicker bind:relations={publication.table_to_track} />
</Label>
{/if}
</div>
</Section>
</div>
@@ -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<string, any>) {
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
defaultValues?: Record<string, any>
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
let drawer: PostgresTriggerEditorInner
let drawer: PostgresTriggerEditorInner | undefined = $state()
</script>
{#if open}
<PostgresTriggerEditorInner on:update bind:this={drawer} />
<PostgresTriggerEditorInner {onUpdate} bind:this={drawer} />
{/if}

Some files were not shown because too many files have changed in this diff Show More