feat: configure pipeline triggers and outputs as drafts in the editor

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-30 11:10:51 +02:00
co-authored by Claude Opus 5.5
parent cf5c49c3dc
commit f138e53b80
48 changed files with 2290 additions and 944 deletions
@@ -15,7 +15,7 @@
contentClasses="p-4"
>
{#snippet trigger()}
<Button variant="accent" size="xs" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
<Button variant="default" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Clock }}>
Cron builder
</Button>
{/snippet}
@@ -17,6 +17,9 @@
selectInputClass?: string
class?: string
onClear?: () => void
/** Size the input to its content, like Path's folder picker. */
useContentEditable?: boolean
RightIcon?: any
}
let {
@@ -27,7 +30,9 @@
placeholder = undefined,
selectInputClass = '',
class: className = '',
onClear = undefined
onClear = undefined,
useContentEditable = false,
RightIcon = undefined
}: Props = $props()
let datatables = usePromise(() =>
@@ -47,6 +52,8 @@
{placeholder}
inputClass={selectInputClass}
{onClear}
{useContentEditable}
{RightIcon}
/>
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'datatable', path: value })}
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'datatable', path: value }} />
@@ -17,6 +17,9 @@
selectInputClass?: string
class?: string
onClear?: () => void
/** Size the input to its content, like Path's folder picker. */
useContentEditable?: boolean
RightIcon?: any
}
let {
@@ -27,7 +30,9 @@
placeholder = undefined,
selectInputClass = '',
class: className = '',
onClear = undefined
onClear = undefined,
useContentEditable = false,
RightIcon = undefined
}: Props = $props()
let ducklakes = usePromise(() =>
@@ -45,6 +50,8 @@
{placeholder}
inputClass={selectInputClass}
{onClear}
{useContentEditable}
{RightIcon}
/>
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'ducklake', path: value })}
<ExploreAssetButton class="mt-1 w-fit" asset={{ kind: 'ducklake', path: value }} />
@@ -28,6 +28,13 @@
editable?: boolean
onSaved?: (newPath: string) => void
kind?: 'flow' | 'script'
/** Saves instead of the API, for an item that exists only locally (a draft).
* Returns an error message to keep the popover open. */
saveOverride?: (next: {
path: string
summary: string
labels: string[] | undefined
}) => string | undefined
}
let {
@@ -37,7 +44,8 @@
inheritedLabels = undefined,
editable = false,
onSaved,
kind = 'flow'
kind = 'flow',
saveOverride = undefined
}: Props = $props()
let editSummary = $state('')
@@ -47,7 +55,8 @@
let ownPath = $state<string | undefined>(undefined)
// Derived: ownership answers about the operating workspace, whose user resolves asynchronously.
const own = $derived(
ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace)
saveOverride !== undefined ||
(ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace))
)
let onBehalfOfEmail = $state<string | undefined>(undefined)
let summaryInput: ReturnType<typeof TextInput> | undefined = $state()
@@ -72,6 +81,17 @@
async function save(close: () => void) {
const initialPath = path ?? ''
const newPath = own ? editPath : initialPath
if (saveOverride) {
const error = saveOverride({ path: newPath, summary: editSummary, labels })
if (error) {
sendUserToast(error, true)
return
}
labelsDirty = false
close()
onSaved?.(newPath)
return
}
try {
await updateItemPathAndSummary({
@@ -31,7 +31,8 @@
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
pathPrefix: string
defaultPathSuffix: string
@@ -51,7 +52,8 @@
pick.path,
{ kind: kindId, path: undefined },
outputKind,
pick.aiPrompt
pick.aiPrompt,
{ schedule: pick.schedule, outputAsset: pick.outputAsset }
)
}
</script>
@@ -78,48 +80,56 @@
},
{
id: 'email',
configuredAfterCreate: true,
label: 'On email',
description: 'Triggered by incoming email',
icon: Mail
},
{
id: 'kafka',
configuredAfterCreate: true,
label: 'On Kafka',
description: 'Triggered by a Kafka message',
icon: Zap
},
{
id: 'mqtt',
configuredAfterCreate: true,
label: 'On MQTT',
description: 'Triggered by an MQTT message',
icon: Radio
},
{
id: 'amqp',
configuredAfterCreate: true,
label: 'On AMQP',
description: 'Triggered by an AMQP (RabbitMQ) message',
icon: Radio
},
{
id: 'nats',
configuredAfterCreate: true,
label: 'On NATS',
description: 'Triggered by a NATS message',
icon: MessageSquare
},
{
id: 'postgres',
configuredAfterCreate: true,
label: 'On Postgres',
description: 'Triggered by a Postgres event',
icon: Database
},
{
id: 'sqs',
configuredAfterCreate: true,
label: 'On SQS',
description: 'Triggered by an SQS message',
icon: Send
},
{
id: 'gcp',
configuredAfterCreate: true,
label: 'On GCP Pub/Sub',
description: 'Triggered by a Pub/Sub message',
icon: CloudCog
@@ -49,7 +49,8 @@
language: import('$lib/gen').ScriptLang,
scriptPath: string,
outputKind: import('./pipelineTemplates').PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
// Pipeline-wide + node shown at the top of the graph. Picking any
// kind from the menu invokes this one callback with the chosen
@@ -65,7 +66,8 @@
path: string | undefined
},
outputKind: import('./pipelineTemplates').PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
// Folder-scoped prefix shown as a read-only chip in the insert menu
// path input (e.g. `f/{folder}/`). Shared across top + and per-asset +.
@@ -526,6 +528,7 @@
data: {
runnable_kind: r.usage_kind,
path: r.path,
summary: r.summary,
in_pipeline: r.in_pipeline ?? false,
partition_kind: r.partition_kind,
freshness: r.freshness,
@@ -724,6 +727,7 @@
kind: TriggerNodeKind
ref: string
missing: boolean
draft: boolean
// First target script (drives the per-script create/edit flows).
runnable_path?: string
// Every target script: a single (kind, ref) — e.g. one schedule —
@@ -739,6 +743,7 @@
ref: string,
unsaved: boolean,
missing: boolean,
draft: boolean,
runnable_path?: string
) {
const prev = triggerSourceNodes.get(id)
@@ -748,6 +753,7 @@
kind,
ref,
missing,
draft,
runnable_path,
runnable_paths: runnable_path ? [runnable_path] : []
})
@@ -796,6 +802,7 @@
ref,
!!t.unsaved,
isMissing,
t.draft === true,
// Always thread the target script so the trigger node can
// reach back to it — drives both the missing-trigger "create"
// flow and the attached-trigger "edit" flow's script-path
@@ -830,6 +837,7 @@
ref: info.ref,
unsaved: info.allUnsaved,
missing: info.missing,
draft: info.draft,
runnable_path: info.runnable_path,
runnable_unsaved: info.runnable_path
? unsavedRunnablePaths.has(info.runnable_path)
@@ -22,8 +22,6 @@
X,
Pencil
} from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { tick } from 'svelte'
import { inferArgs } from '$lib/infer'
import { emptySchema, sendUserToast } from '$lib/utils'
import type { Schema } from '$lib/common'
@@ -41,6 +39,7 @@
import { extractDraftMacros } from './resolveGraph'
import { assetColumnNodes, type ColumnLineageGraph } from './columnLineageGraph'
import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import S3FilePreview from '$lib/components/S3FilePreview.svelte'
import DataTablePreview from './DataTablePreview.svelte'
import DucklakeAssetPanel from './DucklakeAssetPanel.svelte'
@@ -215,15 +214,12 @@
// the edges. Counter rather than boolean so back-to-back runs
// re-fire the effect even if no other prop changed.
requestRunSignal?: number
// Folder-scoped non-editable prefix shown next to the suffix
// editor when the user renames a draft (e.g. `f/<folder>/`). The
// new path = pathPrefix + suffix.
pathPrefix?: string
// Called when the user renames a draft — the parent reseats the
// path key in its drafts map and updates activeDraftPath. Returns
// true on success; false on collision/validation failure so the
// popover can keep itself open and surface the error inline.
onDraftPathChange?: (oldPath: string, newPath: string) => boolean | string
onDraftMetaChange?: (path: string, meta: { summary: string; labels: string[] | undefined }) => void
// Bumped by the parent (e.g. from the runnable-node action menu) to
// auto-open the archive/delete confirmation modal for the currently
// loaded persisted script. No-ops while the pane is showing a draft
@@ -315,8 +311,8 @@
activeRunnable,
onTestStateChange,
requestRunSignal,
pathPrefix = '',
onDraftPathChange,
onDraftMetaChange,
requestRemoveSignal,
downstreamSubscribers = 0,
onStartBoundedRun,
@@ -910,49 +906,6 @@
JSON.stringify(script.schema ?? null) === JSON.stringify(orig.schema ?? null)
)
})
// Suffix editor for the draft-path popover. Seeded from the current
// path each time the popover opens so the user starts with what they
// see, not stale state from an earlier rename.
let draftPathSuffix = $state('')
let draftPathError = $state<string | undefined>(undefined)
let draftPathInput: HTMLInputElement | undefined = $state(undefined)
function suffixOf(fullPath: string): string {
return fullPath.startsWith(pathPrefix) ? fullPath.slice(pathPrefix.length) : fullPath
}
async function openDraftPathEditor() {
draftPathSuffix = script ? suffixOf(script.path) : ''
draftPathError = undefined
await tick()
draftPathInput?.focus()
draftPathInput?.select()
}
function confirmDraftPath(close: () => void) {
if (!script) return
const suffix = draftPathSuffix.trim()
if (!suffix) {
draftPathError = 'Path cannot be empty'
return
}
const newPath = pathPrefix + suffix
if (newPath === script.path) {
close()
return
}
const result = onDraftPathChange?.(script.path, newPath)
if (result === true || result === undefined) {
script.path = newPath
draftPathError = undefined
close()
} else if (typeof result === 'string') {
draftPathError = result
} else {
draftPathError = 'Path already in use'
}
}
</script>
<div class="flex flex-col h-full bg-surface" bind:this={paneEl}>
@@ -964,80 +917,31 @@
{@const draftScriptPath = script.path}
<Code2 size={16} class="shrink-0 text-tertiary" />
{#if onDraftPathChange && !readOnly}
<!-- Inline rename popover for drafts. The persisted-script
branch uses SummaryPathDisplay which round-trips through
updateItemPathAndSummary; drafts have no server row yet,
so we just rekey the parent's drafts map locally. -->
<Popover
placement="bottom-start"
contentClasses="p-3"
usePointerDownOutside
on:openChange={(e) => {
if (e.detail) openDraftPathEditor()
<!-- Same summary/path popover as a deployed script, saved into the
drafts map instead of through the API: a draft has no row yet. -->
<SummaryPathDisplay
summary={script.summary ?? ''}
path={draftScriptPath}
labels={script.labels ?? []}
kind="script"
onSaved={() => {}}
saveOverride={({ path, summary, labels }) => {
// Before the rename, which carries the draft entry over as it is.
onDraftMetaChange?.(draftScriptPath, { summary, labels })
if (script) {
script.summary = summary
script.labels = labels
}
if (path === draftScriptPath) return undefined
const result = onDraftPathChange(draftScriptPath, path)
if (result !== true && result !== undefined) {
return typeof result === 'string' ? result : 'Path already in use'
}
if (script) script.path = path
return undefined
}}
>
{#snippet trigger()}
<button
type="button"
class="flex flex-col min-w-0 text-left px-2 py-1 rounded-md hover:bg-surface-hover transition-colors group"
title="Edit draft path"
>
<span
class="text-3xs uppercase tracking-wide text-tertiary flex items-center gap-1"
>
Draft pipeline script
<Pencil size={9} class="opacity-0 group-hover:opacity-60 transition-opacity" />
</span>
<span class="text-xs font-mono truncate" title={draftScriptPath}
>{draftScriptPath}</span
>
</button>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-2 w-[420px]">
<span class="text-2xs font-normal text-secondary">Path</span>
<div
class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-blue-400"
>
{#if pathPrefix}
<span
class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none"
>
{pathPrefix}
</span>
{/if}
<input
bind:this={draftPathInput}
bind:value={draftPathSuffix}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmDraftPath(close)
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}}
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
placeholder="my_script"
/>
</div>
{#if draftPathError}
<span class="text-2xs text-red-500">{draftPathError}</span>
{/if}
<div class="flex justify-end">
<Button
variant="accent"
unifiedSize="sm"
disabled={!draftPathSuffix.trim()}
onClick={() => confirmDraftPath(close)}
>
Rename
</Button>
</div>
</div>
{/snippet}
</Popover>
/>
<Badge color="gray">Draft</Badge>
{:else}
<div class="flex flex-col min-w-0">
<span class="text-3xs uppercase tracking-wide text-tertiary">
@@ -1,6 +1,7 @@
<script lang="ts">
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { FlaskConical, Columns3, SquareFunction, BellOff } from 'lucide-svelte'
import type { ColumnLineage, DataTest } from './parsePipelineAnnotations'
@@ -103,7 +104,7 @@
// straight under the nodes in between — the child-and-grandchild-of-the-
// same-node case, where the tidy-tree layout puts all three on one
// column. Detour those through the gutter beside the column.
const SKIP_DY = NODE.gap.vertical * 2 + NODE.height
const SKIP_DY = NODE.gap.vertical * 2 + PIPELINE_NODE_HEIGHT
const NEAR_VERTICAL_DX = NODE.width / 4
// Lane offset: half a node plus a margin that stays inside the
// inter-column gutter (gap.horizontal / 2).
@@ -2,8 +2,9 @@
import { Handle, Position } from '@xyflow/svelte'
import { twMerge } from 'tailwind-merge'
import AssetGenericIcon from '$lib/components/icons/AssetGenericIcon.svelte'
import { formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import { formatAssetKind, formatShortAssetPath, type AssetKind } from '$lib/components/assets/lib'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
import {
ArrowUpRight,
@@ -65,7 +66,8 @@
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
pathPrefix?: string
defaultPathSuffix?: string
@@ -147,7 +149,8 @@
pick.language as ScriptLang,
pick.path,
(pick.outputKind ?? 'none') as PipelineOutputKind,
pick.aiPrompt
pick.aiPrompt,
{ outputAsset: pick.outputAsset }
)
}
}
@@ -244,7 +247,7 @@
'bg-surface-secondary border-gray-400 dark:border-gray-600 hover:border-gray-500 dark:hover:border-gray-500 transition-colors',
selected && 'bg-surface-accent-selected border-border-selected'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={data.path}
>
<!-- Data identity carries the accent (luminance blue), pairing with
@@ -288,9 +291,12 @@
</span>
{/if}
{/if}
<span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate">
{formatShortAssetPath(asset)}
</span>
<div class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{formatAssetKind(asset)}
</span>
<span class="text-2xs font-mono text-emphasis truncate">{formatShortAssetPath(asset)}</span>
</div>
<!-- Fork data-environment chip: in a fork every asset shares its parent's
name, so the env it resolves to must read at a glance. Labeled + tinted
(amber "parent" = deferred read of the parent's current table via a
@@ -2,6 +2,7 @@
import { useSvelteFlow, type Node } from '@xyflow/svelte'
import { untrack } from 'svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
// Smoothly pans the viewport so a freshly-created node lands in the middle,
// keeping the current zoom. Lives inside <SvelteFlow> so useSvelteFlow() has
@@ -28,7 +29,7 @@
const node = ns.find((n) => n.id === id)
if (!node) return // not laid out yet — re-runs when `nodes` updates
const cx = node.position.x + NODE.width / 2
const cy = node.position.y + NODE.height / 2
const cy = node.position.y + PIPELINE_NODE_HEIGHT / 2
const key = `${id}:${cx}:${cy}`
if (key === lastKey) return
lastKey = key
@@ -1,40 +0,0 @@
<script lang="ts">
import { base } from '$lib/base'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { BookOpen } from 'lucide-svelte'
const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt'
</script>
<Alert type="info" title="Prefer using dbt? Windmill also runs dbt" size="xs">
<div class="flex flex-col gap-2">
<p>
A pipeline is Windmill's own abstraction; an existing dbt project stays a dbt project, and
runs here unchanged as its own kind of script, with dbt still owning its models, refs and
tests. All it needs is a warehouse configured under
<a class="underline" href="{base}/workspace_settings?tab=dbt">Settings → dbt</a>. Its models
show up in the asset graph as <span class="font-mono">dbt://</span> nodes, visible next to whatever
pipelines you do build, so you can adopt pipelines later, or never.
</p>
<div class="flex flex-row flex-wrap items-center gap-2">
<!-- The dbt mark goes in as a child rather than `startIcon`: that slot
sizes its icon with lucide's `size`, which DbtIcon has no prop for. -->
<Button variant="default" unifiedSize="sm" href="{base}/scripts/add?lang=dbt">
<DbtIcon width={14} height={14} />
New dbt script
</Button>
<Button
variant="subtle"
unifiedSize="sm"
href={DBT_DOCS}
target="_blank"
rel="noreferrer"
startIcon={{ icon: BookOpen }}
>
dbt documentation
</Button>
</div>
</div>
</Alert>
@@ -18,11 +18,18 @@
mode?: 'view' | 'edit'
// Called right before navigating — lets a wrapping modal close itself.
onOpen?: () => void
// Only the folder picker, for hosts that don't list pipelines.
hideExisting?: boolean
}
let { currentFolder = undefined, mode = 'view', onOpen = undefined }: Props = $props()
let {
currentFolder = undefined,
mode = 'view',
onOpen = undefined,
hideExisting = false
}: Props = $props()
let pipelines = resource(
() => $workspaceStore,
() => (hideExisting ? undefined : $workspaceStore),
async (ws) => {
if (!ws) return [] as ListPipelineFoldersResponse
return await AssetService.listPipelineFolders({ workspace: ws })
@@ -49,50 +56,52 @@
</script>
<div class="flex flex-col gap-6 w-full min-w-0">
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Existing pipelines
</h3>
{#if pipelines.loading && !pipelines.current}
<div class="text-tertiary text-sm flex items-center gap-2">
<Loader2 size={14} class="animate-spin" />
Loading…
</div>
{:else if pipelines.error}
<div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div>
{:else if visiblePipelines.length === 0}
<div class="text-tertiary text-sm">
{currentFolder
? 'No other pipelines in this workspace.'
: 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'}
</div>
{:else}
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
{#each visiblePipelines as p (p.folder)}
<button
type="button"
class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left"
onclick={() => openExistingPipeline(p.folder)}
>
<span class="font-mono text-sm">f/{p.folder}</span>
<span class="text-2xs text-tertiary">
{p.script_count}
{p.script_count === 1 ? 'script' : 'scripts'}
</span>
</button>
{/each}
</div>
{/if}
</section>
{#if !hideExisting}
<section class="flex flex-col gap-2">
<h3 class="text-sm font-semibold text-emphasis">Existing pipelines</h3>
{#if pipelines.loading && !pipelines.current}
<div class="text-tertiary text-sm flex items-center gap-2">
<Loader2 size={14} class="animate-spin" />
Loading…
</div>
{:else if pipelines.error}
<div class="text-red-500 text-sm">Failed: {pipelines.error.message}</div>
{:else if visiblePipelines.length === 0}
<div class="text-tertiary text-sm">
{currentFolder
? 'No other pipelines in this workspace.'
: 'No pipelines yet. A pipeline is any folder whose scripts carry pipeline annotations.'}
</div>
{:else}
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
{#each visiblePipelines as p (p.folder)}
<button
type="button"
class="flex items-center justify-between px-3 py-2 border-b last:border-b-0 bg-surface hover:bg-surface-hover transition-colors text-left"
onclick={() => openExistingPipeline(p.folder)}
>
<span class="font-mono text-sm">f/{p.folder}</span>
<span class="text-2xs text-tertiary">
{p.script_count}
{p.script_count === 1 ? 'script' : 'scripts'}
</span>
</button>
{/each}
</div>
{/if}
</section>
{/if}
{#if !$userStore?.operator}
<!-- Operators only view existing pipelines — opening an arbitrary
folder is a build-a-new-pipeline affordance. -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
Pick or create a folder
</h3>
<div class="flex items-center gap-2">
<section class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">Pick or create a folder</h3>
<p class="text-xs text-secondary">
Each folder holds one pipeline, made of its pipeline scripts. Pick a folder to open its
pipeline, or create one to start a new pipeline.
</p>
<div class="flex items-center gap-2 mt-1">
<div class="flex-1 min-w-0">
<FolderPicker bind:folderName={pickedFolder} />
</div>
@@ -18,8 +18,10 @@
AssetGraphSelection,
NativeTriggerKind,
PipelineMode,
PipelineTriggerDraft,
DbtAssetProvenance
} from './types'
import { isDraftableTriggerKind, triggerDraftKey } from './pipelineTriggerDrafts'
import type { AssetKind, Script, ScriptLang } from '$lib/gen'
import type { RunnableRunState, PipelineEvent } from './activeRunnables.svelte'
import type { PipelineOutputKind } from './pipelineTemplates'
@@ -153,14 +155,16 @@
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
onAddPipelineScript?: (
language: ScriptLang,
path: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: import('./PipelineInsertMenu.svelte').PipelineInsertOptions
) => void
onRunnableMenuRemove?: (...args: any[]) => void
onRunProducer?: (producer: RunProducer) => Promise<string | undefined>
@@ -280,7 +284,11 @@
// FlowBuilder's autosave analogue — gated by `persistDrafts`.
let pipelineDraftPath = $derived(pipelineBundlePath(folder))
let storageKey = $derived(`pipeline-${folder}`)
type PipelineDraftBundle = { drafts: Array<[string, PipelineDraft]>; activeDraftPath?: string }
type PipelineDraftBundle = {
drafts: Array<[string, PipelineDraft]>
activeDraftPath?: string
triggerDrafts?: PipelineTriggerDraft[]
}
// Hydration is tracked on the editor instance (`editor.hydratedFromDb`), not a
// component flag: the in-session preview reuses one instance across editor
// hide/show, and it must hydrate ONCE per instance, not on every remount.
@@ -300,6 +308,18 @@
if (loaded.size > 0) editor.drafts = loaded
}
if (typeof bundle.activeDraftPath === 'string') editor.activeDraftPath = bundle.activeDraftPath
if (Array.isArray(bundle.triggerDrafts)) {
editor.triggerDrafts = new Map(
bundle.triggerDrafts
.filter(
(d) =>
isDraftableTriggerKind(d?.kind) &&
typeof d.config?.path === 'string' &&
typeof d.config?.script_path === 'string'
)
.map((d) => [triggerDraftKey(d.kind, d.config.path), d])
)
}
}
function readLocalBundle(): PipelineDraftBundle | undefined {
@@ -311,7 +331,8 @@
if (s && (Array.isArray(s.drafts) || typeof s.activeDraftPath === 'string')) {
return {
drafts: Array.isArray(s.drafts) ? s.drafts : [],
activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined
activeDraftPath: typeof s.activeDraftPath === 'string' ? s.activeDraftPath : undefined,
triggerDrafts: Array.isArray(s.triggerDrafts) ? s.triggerDrafts : undefined
}
}
} catch (e) {
@@ -413,24 +434,26 @@
return [p, { ...d, script, outputAssets, inputAssets }] as [string, PipelineDraft]
})
const activePath = editor.activeDraftPath
const triggerDrafts = [...editor.triggerDrafts.values()]
const key = storageKey
const ws = workspace
const path = pipelineDraftPath
const hydrated = editor.hydratedFromDb
untrack(() => {
if (!hydrated) return
const isEmpty = serialized.length === 0 && !activePath
const isEmpty = serialized.length === 0 && !activePath && triggerDrafts.length === 0
const bundle: PipelineDraftBundle | undefined = isEmpty
? undefined
: { drafts: serialized, activeDraftPath: activePath }
: {
drafts: serialized,
activeDraftPath: activePath,
...(triggerDrafts.length > 0 ? { triggerDrafts } : {})
}
try {
if (typeof localStorage !== 'undefined') {
if (isEmpty) localStorage.removeItem(key)
else
localStorage.setItem(
key,
encodeState({ drafts: serialized, activeDraftPath: activePath })
)
localStorage.setItem(key, encodeState(bundle))
}
} catch (e) {
console.warn('failed to mirror pipeline state', e)
@@ -552,8 +575,8 @@
draftScript={activeDraft?.script}
draftOutputAssets={activeDraft?.outputAssets}
draftInputAssets={activeDraft?.inputAssets}
{pathPrefix}
{onDraftPathChange}
onDraftMetaChange={editor.setDraftMeta}
{workspace}
onAnnotationsChange={editor.handleAnnotationsChange}
onAssetsChange={editor.handleAssetsChange}
@@ -1,6 +1,7 @@
<script lang="ts" module>
import type { ComponentType } from 'svelte'
import type { SupportedLanguage } from '$lib/common'
import type { AssetKind, NewSchedule } from '$lib/gen'
export type PipelineInsertKind = {
// Machine-readable id; drives which right-column panel renders.
@@ -8,6 +9,9 @@
label: string
description?: string
icon?: ComponentType
// Its trigger is configured in its own editor right after the script is
// created, so the wizard's last button reads "Configure".
configuredAfterCreate?: boolean
}
export type PipelineInsertPick = {
@@ -22,7 +26,18 @@
// from this prompt via AI (using language + outputKind + the
// upstream input as context) instead of using the seeded template.
aiPrompt?: string
// Set by the schedule step when the picked kind is `schedule`.
schedule?: PipelineInsertSchedule
// Set by the asset step for the table output kinds, replacing the
// generated name.
outputAsset?: { kind: AssetKind; path: string }
}
export type PipelineInsertOptions = Pick<PipelineInsertPick, 'schedule' | 'outputAsset'>
// Everything but the target, which is the script being created. An empty
// `path` means the default `<script>_schedule`.
export type PipelineInsertSchedule = Omit<NewSchedule, 'script_path'>
</script>
<script lang="ts">
@@ -36,10 +51,27 @@
PIPELINE_OUTPUT_KINDS,
type PipelineOutputKind
} from './pipelineTemplates'
import CronInput from '$lib/components/CronInput.svelte'
import type { ScriptLang } from '$lib/gen'
import Label from '$lib/components/Label.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { CornerDownLeft, Sparkles } from 'lucide-svelte'
import TextInput, {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '$lib/components/text_input/TextInput.svelte'
import DucklakePicker from '$lib/components/DucklakePicker.svelte'
import DatatablePicker from '$lib/components/DatatablePicker.svelte'
import Path from '$lib/components/Path.svelte'
import Section from '$lib/components/Section.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import ScheduleAdvancedOptions, {
emptyScheduleAdvanced,
scheduleAdvancedCfg
} from '$lib/components/triggers/schedules/ScheduleAdvancedOptions.svelte'
import { workspaceStore } from '$lib/stores'
import { ArrowLeft, ChevronDown, CornerDownLeft, Sparkles } from 'lucide-svelte'
import { tick } from 'svelte'
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
import { selectAndAdvanceTo } from '$lib/attachments/selectAndAdvanceTo'
@@ -75,6 +107,73 @@
aiPrompt: undefined as undefined | string
})
let selected = $state(buildEmptySelected())
let selectedKind = $derived(kinds.find((k) => k.id === selected.triggerId))
// Steps after the first, each configuring something the picks call for.
type ConfigStep = 'schedule' | 'asset'
const NAMED_OUTPUT_KINDS: PipelineOutputKind[] = ['materialize', 'datatable', 'ducklake']
let configSteps = $derived.by<ConfigStep[]>(() => {
const steps: ConfigStep[] = []
if (selected.triggerId === 'schedule') steps.push('schedule')
if (selected.outputId && NAMED_OUTPUT_KINDS.includes(selected.outputId)) steps.push('asset')
return steps
})
// 0 is the trigger/language/output/path step; `n` is `configSteps[n - 1]`.
let step = $state(0)
let currentConfigStep = $derived(step > 0 ? configSteps[step - 1] : undefined)
let isLastStep = $derived(step >= configSteps.length)
const buildEmptyConfig = () => ({
schedule: '0 0 12 * *',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cronVersion: 'v2',
validCron: true,
// Follows the script path until the Path field is edited.
schedulePath: '',
schedulePathDirty: false,
schedulePathError: '',
summary: '',
description: '',
pauseUntil: false,
pausedUntil: undefined as string | undefined,
advanced: emptyScheduleAdvanced(),
// `store` is the data table or DuckLake catalog name. The table name
// follows the script name until the user types in it.
asset: undefined as
| undefined
| { kind: 'ducklake' | 'datatable'; store: string | undefined; table: string },
tableEdited: false
})
let config = $state(buildEmptyConfig())
function resetWizard() {
selected = buildEmptySelected()
config = buildEmptyConfig()
step = 0
}
const TABLE_NAME_RE = /^[^/\s]+$/
let assetValid = $derived(
!!config.asset?.store && TABLE_NAME_RE.test(config.asset.table.trim())
)
let stepValid = $derived(
currentConfigStep === 'schedule'
? config.validCron && !!config.schedule.trim() && !config.schedulePathError
: currentConfigStep === 'asset'
? assetValid
: !!selected.scriptPath.trim()
)
function enterAssetStep() {
const kind = selected.outputId === 'datatable' ? 'datatable' : 'ducklake'
if (config.asset?.kind !== kind) {
config.asset = { kind, store: 'main', table: '' }
config.tableEdited = false
}
if (!config.tableEdited && config.asset) {
config.asset.table = selected.scriptPath.trim().split('/').pop() ?? ''
}
}
// Refs to the column containers so Enter inside one column can hand
// focus off to the first tabbable in the next column.
@@ -104,16 +203,64 @@
return out
}
// A step change re-renders the popover body, dropping focus to <body>, where
// keystrokes (Enter, Cmd+Enter) no longer reach the step's key handlers.
let contentEl: HTMLElement | undefined = $state()
async function focusCurrentStep() {
await tick()
const target =
step === 0
? pathEl?.querySelector('input')
: contentEl?.querySelector<HTMLElement>('[data-autofocus] input')
target?.focus()
}
function goBack() {
step -= 1
void focusCurrentStep()
}
function confirm(close: () => void) {
const suffix = selected.scriptPath.trim()
if (!suffix || !selected.triggerId || !selected.language || !selected.outputId) return
if (!stepValid) return
if (!isLastStep) {
step += 1
if (configSteps[step - 1] === 'asset') enterAssetStep()
if (configSteps[step - 1] === 'schedule' && !config.schedulePathDirty) {
config.schedulePath = `${pathPrefix}${suffix}_schedule`
}
void focusCurrentStep()
return
}
const trimmedPrompt = selected.aiPrompt?.trim()
onPick({
kindId: selected.triggerId,
language: selected.language,
path: pathPrefix + suffix,
outputKind: selected.outputId,
aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined
aiPrompt: trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined,
schedule: configSteps.includes('schedule')
? {
path: config.schedulePath,
schedule: config.schedule.trim(),
timezone: config.timezone,
cron_version: config.cronVersion,
summary: config.summary.trim() || undefined,
description: config.description,
paused_until: config.pauseUntil ? config.pausedUntil : undefined,
is_flow: false,
args: {},
...scheduleAdvancedCfg($state.snapshot(config.advanced))
}
: undefined,
outputAsset:
configSteps.includes('asset') && config.asset
? {
kind: config.asset.kind as AssetKind,
path: `${config.asset.store}/${config.asset.table.trim()}`
}
: undefined
})
close()
}
@@ -123,7 +270,15 @@
enableFlyTransition
contentClasses={twMerge(
'p-0 bg-surface overflow-hidden relative transition-height',
showBottomPanel ? 'h-[26rem]' : 'h-[22rem]'
// The first step's columns (w-56 + w-48 + w-80), held across steps.
singleKind ? 'w-[32rem]' : 'w-[46rem]',
currentConfigStep === 'schedule'
? 'h-[27rem]'
: currentConfigStep === 'asset'
? 'h-[14rem]'
: showBottomPanel
? 'h-[26rem]'
: 'h-[22rem]'
)}
class="inline-block"
usePointerDownOutside
@@ -136,24 +291,30 @@
fitViewport: true,
overlap: false
}}
onClose={() => (selected = buildEmptySelected())}
onClose={resetWizard}
>
{#snippet trigger()}
{@render triggerSnippet?.()}
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
<div class="h-full" bind:this={contentEl}>
{#if currentConfigStep}
{@render configStepSection(currentConfigStep, close)}
{:else}
<div class="flex flex-col h-full">
<div class={'flex flex-row transition-height divide-x overflow-y-scroll'}>
{@render topSection()}
</div>
<div
class={twMerge(
'flex flex-col gap-5 grow transition-height px-4 border-t',
showBottomPanel ? 'h-[14rem] py-4' : 'h-0'
)}
>
{@render bottomSection(close)}
</div>
</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -292,15 +453,198 @@
</div>
</Label>
{/if}
{@const hasAiPrompt = !!selected.aiPrompt?.trim()}
<div class="ml-auto" bind:this={saveEl}>
<Button
variant="accent"
btnClasses="w-fit"
disabled={!selected.scriptPath.trim()}
onClick={() => confirm(close)}
startIcon={hasAiPrompt ? { icon: Sparkles } : undefined}
shortCut={{ Icon: CornerDownLeft }}>{hasAiPrompt ? 'Generate' : 'Create'}</Button
>
{@render confirmButton(close)}
</div>
{/snippet}
{#snippet confirmButton(close: () => void)}
{@const hasAiPrompt = !!selected.aiPrompt?.trim()}
<Button
variant="accent"
btnClasses="w-fit"
disabled={!stepValid}
onClick={() => confirm(close)}
startIcon={isLastStep && hasAiPrompt ? { icon: Sparkles } : undefined}
shortCut={{ Icon: CornerDownLeft }}
>{!isLastStep
? step === 0
? 'Configure'
: 'Next'
: hasAiPrompt
? 'Generate'
: selectedKind?.configuredAfterCreate
? 'Configure'
: 'Create'}</Button
>
{/snippet}
{#snippet configStepSection(configStep: ConfigStep, close: () => void)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col h-full"
onkeydown={(e) => {
if (e.key !== 'Enter') return
const target = e.target as HTMLElement | null
// Cmd/Ctrl+Enter confirms from anywhere; bare Enter only from the step's own
// inputs, not from the Advanced fields (a textarea, pickers with their own Enter).
const bareEnterSubmits = target?.tagName === 'INPUT' && !target.closest('[data-advanced]')
if (e.metaKey || e.ctrlKey || bareEnterSubmits) {
e.preventDefault()
e.stopPropagation()
confirm(close)
}
}}
>
<div class="flex items-center gap-2 px-2 py-2 border-b">
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ArrowLeft }}
onClick={goBack}
title="Back"
/>
<span class="text-xs font-semibold text-emphasis">
{configStep === 'schedule' ? 'Schedule' : 'Output asset'}
</span>
<span class="text-2xs text-hint ml-auto pr-2"
>Step {step + 1} of {configSteps.length + 1}</span
>
</div>
<div class="flex flex-col gap-2 grow overflow-auto p-4">
{#if configStep === 'schedule'}
<span class="text-2xs text-secondary">
Saved as a draft schedule and deployed along with the pipeline.
</span>
<div data-autofocus class="contents">
<CronInput
bind:schedule={config.schedule}
bind:timezone={config.timezone}
bind:validCRON={config.validCron}
bind:cronVersion={config.cronVersion}
/>
</div>
<div data-advanced class="mt-4">
<Section label="Advanced" collapsable initiallyCollapsed>
<div class="flex flex-col gap-6">
<Label label="Summary">
<TextInput
bind:value={config.summary}
inputProps={{ placeholder: 'Short summary to be displayed when listed' }}
/>
</Label>
<Label label="Path">
<Path
bind:path={config.schedulePath}
bind:dirty={config.schedulePathDirty}
bind:error={config.schedulePathError}
initialPath=""
checkInitialPathExistence
namePlaceholder="schedule"
kind="schedule"
/>
</Label>
<Label label="Description">
<TextInput
underlyingInputEl="textarea"
bind:value={config.description}
inputProps={{ placeholder: 'What this schedule does and how to use it' }}
/>
</Label>
<div class="flex flex-col gap-1">
<Toggle
options={{
right: 'Pause schedule until...',
rightTooltip:
'Pausing the schedule will program the next job to run as if the schedule starts at the time the pause is lifted, instead of now.'
}}
bind:checked={config.pauseUntil}
/>
{#if config.pauseUntil}
<DateTimeInput bind:value={config.pausedUntil} />
{/if}
</div>
<ScheduleAdvancedOptions
wsId={$workspaceStore}
itemKind="script"
canWrite
bind:errorHandlerSelected={config.advanced.errorHandlerSelected}
bind:errorHandlerPath={config.advanced.errorHandlerPath}
bind:errorHandleritemKind={config.advanced.errorHandleritemKind}
bind:errorHandlerExtraArgs={config.advanced.errorHandlerExtraArgs}
bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted}
bind:failedTimes={config.advanced.failedTimes}
bind:failedExact={config.advanced.failedExact}
bind:recoveryHandlerSelected={config.advanced.recoveryHandlerSelected}
bind:recoveryHandlerPath={config.advanced.recoveryHandlerPath}
bind:recoveryHandlerItemKind={config.advanced.recoveryHandlerItemKind}
bind:recoveryHandlerExtraArgs={config.advanced.recoveryHandlerExtraArgs}
bind:recoveredTimes={config.advanced.recoveredTimes}
bind:successHandlerSelected={config.advanced.successHandlerSelected}
bind:successHandlerPath={config.advanced.successHandlerPath}
bind:successHandlerItemKind={config.advanced.successHandlerItemKind}
bind:successHandlerExtraArgs={config.advanced.successHandlerExtraArgs}
bind:retry={config.advanced.retry}
bind:dynamicSkipPath={config.advanced.dynamicSkipPath}
bind:tag={config.advanced.tag}
/>
</div>
</Section>
</div>
{:else if configStep === 'asset' && config.asset}
<Label label="Name">
<div
class={twMerge(
inputBaseClass,
inputBorderClass({ error: !assetValid }),
inputSizeClasses.md,
'flex flex-row items-center gap-0 py-0 pl-2 pr-0'
)}
>
<span class="text-xs text-secondary shrink-0">{config.asset.kind}://</span>
{#if config.asset.kind === 'datatable'}
<DatatablePicker
bind:value={config.asset.store}
class="shrink-0"
selectInputClass="!border-none"
placeholder="data table"
useContentEditable
RightIcon={ChevronDown}
/>
{:else}
<DucklakePicker
bind:value={config.asset.store}
class="shrink-0"
selectInputClass="!border-none"
placeholder="catalog"
useContentEditable
RightIcon={ChevronDown}
/>
{/if}
<span class="text-sm text-secondary">/</span>
<div data-autofocus class="grow flex">
<TextInput
bind:value={config.asset.table}
class="!border-none grow"
inputProps={{
placeholder: 'table',
oninput: () => (config.tableEdited = true)
}}
/>
</div>
</div>
</Label>
<span class="text-2xs text-hint">
{config.asset.kind === 'datatable' ? 'Data table' : 'DuckLake catalog'}, then the table
name
</span>
{/if}
</div>
<div class="flex px-4 py-3 border-t">
<div class="ml-auto">
{@render confirmButton(close)}
</div>
</div>
</div>
{/snippet}
@@ -3,6 +3,7 @@
import { WorkspaceService } from '$lib/gen'
import { base } from '$lib/base'
import { CircleCheck, CircleAlert, Database, HardDrive, ArrowRight } from 'lucide-svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
interface Props {
workspace: string
@@ -73,48 +74,41 @@
</script>
{#if show}
<div
class="flex flex-col gap-3 rounded-lg border border-amber-300 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/30 p-4"
>
<div class="flex items-start gap-2">
<CircleAlert size={18} class="text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div class="flex flex-col gap-0.5">
<h3 class="text-sm font-semibold text-emphasis">Finish setting up pipelines</h3>
<p class="text-xs text-tertiary">
Pipelines materialize data into DuckLake tables backed by object storage. Configure the
following before your first pipeline can run.
</p>
</div>
<Alert type="warning" title="Finish setting up pipelines">
<div class="flex flex-col gap-3">
<p>
Pipelines materialize data into DuckLake tables backed by object storage. Configure the
following before your first pipeline can run.
</p>
<ul class="flex flex-col gap-2">
{#each steps as step (step.title)}
{@const Icon = step.icon}
<li class="flex items-center gap-3 rounded-md border bg-surface-tertiary px-3 py-2">
{#if step.done === true}
<CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" />
{:else if step.done === false}
<CircleAlert size={16} class="text-yellow-600 dark:text-yellow-400 shrink-0" />
{:else}
<Icon size={16} class="text-secondary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-semibold text-emphasis">{step.title}</span>
<span class="text-2xs text-secondary">{step.description}</span>
</div>
{#if step.done !== true}
<a
href={step.href}
class="shrink-0 inline-flex items-center gap-1 text-xs text-accent hover:underline whitespace-nowrap"
>
{step.cta}
<ArrowRight size={12} />
</a>
{:else}
<span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span>
{/if}
</li>
{/each}
</ul>
</div>
<ul class="flex flex-col gap-2">
{#each steps as step (step.title)}
{@const Icon = step.icon}
<li class="flex items-center gap-3 rounded-md border bg-surface px-3 py-2">
{#if step.done === true}
<CircleCheck size={16} class="text-green-600 dark:text-green-400 shrink-0" />
{:else if step.done === false}
<CircleAlert size={16} class="text-amber-600 dark:text-amber-400 shrink-0" />
{:else}
<Icon size={16} class="text-tertiary shrink-0" />
{/if}
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-semibold text-primary">{step.title}</span>
<span class="text-2xs text-tertiary">{step.description}</span>
</div>
{#if step.done !== true}
<a
href={step.href}
class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-500 hover:underline whitespace-nowrap"
>
{step.cta}
<ArrowRight size={12} />
</a>
{:else}
<span class="shrink-0 text-2xs text-green-700 dark:text-green-400">Configured</span>
{/if}
</li>
{/each}
</ul>
</div>
</Alert>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { sendUserToast } from '$lib/utils'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { NativeTriggerKind } from './types'
import type { NativeTriggerKind, PipelineTriggerDraftKind } from './types'
import {
EmailTriggerService,
GcpTriggerService,
@@ -11,7 +11,8 @@
NatsTriggerService,
PostgresTriggerService,
ScheduleService,
SqsTriggerService
SqsTriggerService,
type Schedule
} from '$lib/gen'
import KafkaTriggerEditor from '$lib/components/triggers/kafka/KafkaTriggerEditor.svelte'
import MqttTriggerEditor from '$lib/components/triggers/mqtt/MqttTriggerEditor.svelte'
@@ -110,6 +111,40 @@
}
}
/** The kind's full editor over a pipeline-local draft; Save hands the config back.
* `saved` is the draft being edited (absent for a new one), applied over `defaults`. */
export function openTriggerDraft(
kind: PipelineTriggerDraftKind,
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSave: (cfg: Record<string, any>) => void
) {
switch (kind) {
case 'schedule':
return scheduleEditor?.openDraft(
{ is_flow: false, args: {}, ...defaults, ...saved, script_path: scriptPath } as Schedule,
onSave
)
case 'kafka':
return kafkaEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'mqtt':
return mqttEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'amqp':
return amqpEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'nats':
return natsEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'postgres':
return postgresEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'sqs':
return sqsEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'gcp':
return gcpEditor?.openDraft(scriptPath, defaults, saved, onSave)
case 'email':
return emailEditor?.openDraft(scriptPath, defaults, saved, onSave)
}
}
// Open the webhook drawer (endpoint URLs + token flow). Caller owns the
// draft guard.
export function openWebhook(scriptPath: string) {
@@ -25,6 +25,7 @@
import type { RunnableRunState } from './activeRunnables.svelte'
import { parseDurationSecs } from './parsePipelineAnnotations'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import type { Item } from '$lib/utils'
@@ -37,6 +38,7 @@
data: {
runnable_kind: GraphUsageKind
path: string
summary?: string
in_pipeline?: boolean
partition_kind?: 'daily' | 'hourly' | 'weekly' | 'monthly' | 'dynamic'
freshness?: string
@@ -101,10 +103,11 @@
}
let { data, selected = false }: Props = $props()
// The icon alone conveys "script" vs "flow"; the uppercase kind label was
// visually noisy and redundant. Tooltip on hover surfaces the path in
// full when truncated.
let Icon = $derived(data.runnable_kind === 'flow' ? GitBranch : Code2)
let kindLabel = $derived(
data.runnable_kind === 'flow' ? 'Flow' : data.dbt ? 'dbt project' : 'Script'
)
// The title shows the summary when there is one, so the tooltip keeps the path.
let nodeTooltip = $derived(
data.unsaved
? `${data.path} (unsaved draft)`
@@ -227,13 +230,16 @@
computingNow &&
'bg-amber-50 dark:bg-amber-900/30 border-amber-400 dark:border-amber-600 animate-pulse'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={nodeTooltip}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${selected ? 'text-accent' : 'text-secondary'}`} />
<span class="flex-1 min-w-0 pr-1 py-0.5 text-2xs font-mono text-emphasis truncate">
{data.path}
</span>
<div class="flex flex-col min-w-0 flex-1 pr-1 py-1 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{kindLabel}{data.unsaved ? ' · draft' : ''}
</span>
<span class="text-xs text-emphasis truncate">{data.summary || data.path}</span>
</div>
<!-- Annotation chips share one neutral treatment — the icon carries
the meaning (colors are reserved for feedback, per the brand
guidelines). Only the freshness chip (when it has a verdict)
@@ -60,6 +60,7 @@
<script lang="ts">
import { Handle, Position } from '@xyflow/svelte'
import { NODE } from '$lib/components/graph/util'
import { PIPELINE_NODE_HEIGHT } from './assetGraphLayout'
import { twMerge } from 'tailwind-merge'
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
@@ -78,6 +79,10 @@
ref: string
unsaved?: boolean
missing?: boolean
// A pipeline-local schedule draft: `ref` is the path it deploys to.
// Edit/delete go through the same callbacks, which the page routes
// to the draft instead of a trigger row.
draft?: boolean
runnable_path?: string
// True iff the target script is still a draft (no DB row yet).
// Drives the "(after draft save)" hint on the missing
@@ -200,7 +205,7 @@
...(canDelete
? [
{
displayName: 'Delete…',
displayName: data.draft ? 'Discard draft' : 'Delete…',
icon: Trash2,
type: 'delete' as const,
action: () => {
@@ -247,7 +252,7 @@
'bg-red-50 dark:bg-red-900/30 outline-dashed outline-red-400 dark:outline-red-500',
'hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={missingTitle}
>
<Icon size={14} class="shrink-0 ml-2 mr-2 text-red-600 dark:text-red-400" />
@@ -256,7 +261,7 @@
{style.label} · missing
</span>
<span class="text-2xs font-mono truncate text-red-700 dark:text-red-400">
{data.runnable_unsaved ? 'Click to create (after draft save)' : 'Click to create'}
Click to create
</span>
</div>
</button>
@@ -273,13 +278,15 @@
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
title={`Edit ${style.label} trigger: ${data.ref}`}
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={data.draft
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
: `Edit ${style.label} trigger: ${data.ref}`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
<div class="flex flex-col min-w-0 flex-1 pr-2 py-0.5 leading-tight">
<span class="text-3xs uppercase tracking-wide truncate text-tertiary">
{style.label}{data.unsaved ? ' · unsaved' : ''}
{style.label}{data.draft ? ' · draft' : data.unsaved ? ' · unsaved' : ''}
</span>
<span class="text-2xs font-mono truncate text-emphasis">
{data.ref}
@@ -300,7 +307,7 @@
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={`Webhook endpoint for ${data.runnable_path ?? ''} — click to view URLs and create a token`}
>
<Icon size={14} class={`shrink-0 ml-2 mr-2 ${style.iconText}`} />
@@ -329,7 +336,7 @@
dataUploadReady ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border,
'hover:brightness-95 dark:hover:brightness-110 transition-[filter]'
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={dataUploadReady
? `Data upload for ${data.runnable_path ?? ''} — a file is staged; the pipeline is ready to run. Click to change it.`
: `Data upload for ${data.runnable_path ?? ''} — click to open the run form and upload a file`}
@@ -366,7 +373,7 @@
: style.bg,
displayMissing ? '' : data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
)}
style="width: {NODE.width}px; min-height: {NODE.height}px;"
style="width: {NODE.width}px; min-height: {PIPELINE_NODE_HEIGHT}px;"
title={missingTitle ??
(data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${data.ref}`)}
>
@@ -7,6 +7,9 @@ import { NODE } from '$lib/components/graph/util'
// breathing room.
const NODE_WIDTH = NODE.width
const NODE_HEIGHT = NODE.height + 30
/** Rendered height of a pipeline node: a kind label over a title. Fits inside
* the `NODE_HEIGHT` row the layout reserves. */
export const PIPELINE_NODE_HEIGHT = 44
const LAYER_GAP = NODE.gap.vertical
const SIBLING_GAP = NODE.gap.horizontal
// Horizontal gutter between two disjoint subgraphs. Wider than the
@@ -1,6 +1,7 @@
import type { AssetKind, Script } from '$lib/gen'
import type { AssetWithAltAccessType } from '$lib/components/assets/lib'
import type { AssetGraphSelection } from './types'
import type { AssetGraphSelection, PipelineTriggerDraft } from './types'
import { defaultTriggerPath, triggerDraftKey } from './pipelineTriggerDrafts'
import {
parsePipelineAnnotations,
type ColumnLineage,
@@ -31,6 +32,8 @@ type LiveContent = { scriptPath: string | undefined; content: string }
export class PipelineEditorState {
/** In-flight drafts keyed by script path (manual + AI-staged). */
drafts = $state<Map<string, PipelineDraft>>(new Map())
/** Undeployed triggers keyed by `triggerDraftKey`, persisted in the same bundle. */
triggerDrafts = $state<Map<string, PipelineTriggerDraft>>(new Map())
/** Draft open in the details pane (mutually exclusive with `selection`). */
activeDraftPath = $state<string | undefined>(undefined)
/** The persisted node/asset selected on the canvas. */
@@ -68,6 +71,7 @@ export class PipelineEditorState {
* different pipeline folder (a same-folder remount keeps the drafts). */
reset = () => {
this.drafts = new Map()
this.triggerDrafts = new Map()
this.activeDraftPath = undefined
this.selection = undefined
this.clearLiveOverlays()
@@ -125,6 +129,64 @@ export class PipelineEditorState {
next.delete(path)
this.drafts = next
this.forgetPath(path)
this.discardTriggerDraftsFor(path)
}
/** Summary and labels of a script draft, edited from the pane header. */
setDraftMeta = (path: string, meta: { summary: string; labels: string[] | undefined }) => {
const d = this.drafts.get(path)
if (!d) return
this.drafts = new Map(this.drafts).set(path, {
...d,
script: { ...d.script, summary: meta.summary, labels: meta.labels }
})
}
/** Add or replace a trigger draft; `previousKey` drops the entry it was saved
* under when an edit renamed it. */
setTriggerDraft = (d: PipelineTriggerDraft, previousKey?: string) => {
const next = new Map(this.triggerDrafts)
if (previousKey) next.delete(previousKey)
next.set(triggerDraftKey(d.kind, d.config.path), d)
this.triggerDrafts = next
}
discardTriggerDraft = (key: string) => {
if (!this.triggerDrafts.has(key)) return
const next = new Map(this.triggerDrafts)
next.delete(key)
this.triggerDrafts = next
}
/** A trigger draft cannot outlive the script draft it was configured with:
* deploying it would target a path nothing is deployed at. */
discardTriggerDraftsFor = (scriptPath: string) => {
const next = new Map(
[...this.triggerDrafts].filter(([, d]) => d.config.script_path !== scriptPath)
)
if (next.size !== this.triggerDrafts.size) this.triggerDrafts = next
}
/** Follow a script draft rename, keeping default `<script>_<kind>` names in step. */
retargetTriggerDrafts = (oldScriptPath: string, newScriptPath: string) => {
let changed = false
const next = new Map<string, PipelineTriggerDraft>()
for (const [k, d] of this.triggerDrafts) {
if (d.config.script_path !== oldScriptPath) {
next.set(k, d)
continue
}
changed = true
const path =
d.config.path === defaultTriggerPath(oldScriptPath, d.kind)
? defaultTriggerPath(newScriptPath, d.kind)
: d.config.path
next.set(triggerDraftKey(d.kind, path), {
...d,
config: { ...d.config, path, script_path: newScriptPath }
})
}
if (changed) this.triggerDrafts = next
}
/** Commit body edits + inferred outputs back into the drafts Map on pane
@@ -0,0 +1,38 @@
import { usedTriggerKinds } from '$lib/stores'
import { formatCron } from '$lib/utils'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import { saveEmailTriggerFromCfg } from '$lib/components/triggers/email/utils'
import { saveKafkaTriggerFromCfg } from '$lib/components/triggers/kafka/utils'
import { saveMqttTriggerFromCfg } from '$lib/components/triggers/mqtt/utils'
import { saveAmqpTriggerFromCfg } from '$lib/components/triggers/amqp/utils'
import { saveNatsTriggerFromCfg } from '$lib/components/triggers/nats/utils'
import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils'
import { saveSqsTriggerFromCfg } from '$lib/components/triggers/sqs/utils'
import { saveGcpTriggerFromCfg } from '$lib/components/triggers/gcp/utils'
import type { PipelineTriggerDraft } from './types'
/** Creates the trigger through the same helper its editor saves with; that helper
* reports the outcome as a toast and resolves to whether it succeeded. */
export function deployTriggerDraft(d: PipelineTriggerDraft, workspace: string): Promise<boolean> {
const cfg = d.config
switch (d.kind) {
case 'schedule':
return saveScheduleFromCfg({ ...cfg, schedule: formatCron(cfg.schedule) }, false, workspace)
case 'email':
return saveEmailTriggerFromCfg('', cfg, false, workspace, false, usedTriggerKinds)
case 'kafka':
return saveKafkaTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'mqtt':
return saveMqttTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'amqp':
return saveAmqpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'nats':
return saveNatsTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'postgres':
return savePostgresTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'sqs':
return saveSqsTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
case 'gcp':
return saveGcpTriggerFromCfg('', cfg, false, workspace, usedTriggerKinds)
}
}
@@ -0,0 +1,26 @@
import type { PipelineTriggerDraftKind } from './types'
export const DRAFTABLE_TRIGGER_KINDS: readonly PipelineTriggerDraftKind[] = [
'schedule',
'email',
'kafka',
'mqtt',
'amqp',
'nats',
'postgres',
'sqs',
'gcp'
]
export function isDraftableTriggerKind(kind: string): kind is PipelineTriggerDraftKind {
return (DRAFTABLE_TRIGGER_KINDS as readonly string[]).includes(kind)
}
/** Drafts are keyed by kind too: two kinds may share a trigger path. */
export function triggerDraftKey(kind: string, path: string): string {
return `${kind}:${path}`
}
export function defaultTriggerPath(scriptPath: string, kind: PipelineTriggerDraftKind): string {
return `${scriptPath}_${kind}`
}
@@ -492,6 +492,49 @@ describe('resolveGraph', () => {
expect(r.edges.some((e) => e.asset_path === 'main/out' && e.access_type === 'w')).toBe(true)
})
it('drops a persisted asset the open script renamed away, unless something still uses it', () => {
const write = (runnable_path: string, asset_path: string) => ({
runnable_path,
runnable_kind: 'script' as const,
asset_kind: 's3object' as const,
asset_path,
access_type: 'w' as const
})
const base = baseGraph({
assets: [
{ kind: 's3object', path: '/old.json' },
{ kind: 's3object', path: '/shared.json' },
{ kind: 's3object', path: '/unreferenced.json' }
],
runnables: [
{ path: 'f/x/prod', usage_kind: 'script' },
{ path: 'f/x/other', usage_kind: 'script' }
],
edges: [write('f/x/prod', '/old.json'), write('f/x/prod', '/shared.json')],
triggers: [
{
trigger_kind: 'asset',
asset_kind: 's3object',
asset_path: '/shared.json',
runnable_kind: 'script',
runnable_path: 'f/x/other'
}
]
})
const r = resolveGraph(
input({
base,
liveAnnotations: { scriptPath: 'f/x/prod', annotations: ann() },
liveBodyAssets: { scriptPath: 'f/x/prod', assets: [s3('/new.json', 'w')] }
})
)
expect(r.assets.map((a) => a.path).sort()).toEqual([
'/new.json',
'/shared.json',
'/unreferenced.json'
])
})
it('editing a saved scd2 producer keeps both the base and _current persisted write edges', () => {
// Deploy persists a write to both `main/dim` and `main/dim_current`.
// Opening the producer for editing must not judge the companion `_current`
@@ -656,6 +699,31 @@ describe('resolveGraph', () => {
})
})
it('a trigger draft stands in for its draft script’s missing trigger of that kind', () => {
const drafts = new Map([['f/x/new', { script: { content: '// on schedule\n// on kafka' } }]])
const r = resolveGraph(
input({
drafts,
triggerDrafts: [
{ kind: 'schedule', config: { path: 'f/x/new_schedule', script_path: 'f/x/new' } }
]
})
)
expect(r.triggers.filter((t) => t.trigger_kind === 'kafka')).toEqual([
expect.objectContaining({ runnable_path: 'f/x/new', missing: true })
])
expect(r.triggers.filter((t) => t.trigger_kind === 'schedule')).toEqual([
{
trigger_kind: 'schedule',
path: 'f/x/new_schedule',
runnable_kind: 'script',
runnable_path: 'f/x/new',
unsaved: true,
draft: true
}
])
})
it('macro edges: base passes through; live `// use` adds an unsaved via_use edge', () => {
const base = baseGraph({
runnables: [
@@ -884,11 +952,11 @@ describe('live buffer overlays (open script)', () => {
access_type: 'w',
unsaved: true
})
// …the stale write edge is dropped, but the deployed dataset node stays.
// …the stale write edge is dropped, and with nothing else using it, so is the node.
expect(
r.edges.filter((e) => e.asset_path === 'main/orders' && e.runnable_path === 'f/x/prod')
).toEqual([])
expect(r.assets).toContainEqual({ kind: 'ducklake', path: 'main/orders' })
expect(r.assets).not.toContainEqual({ kind: 'ducklake', path: 'main/orders' })
})
it('open saved script: unchanged materialize target dedups against the persisted write edge', () => {
@@ -1083,9 +1151,7 @@ describe('dbtAssociations', () => {
it('counts only what the project materializes as its transforms', () => {
const { writesByOwner } = dbtAssociations(runnables, edges)
// Hovering the project highlights the models it builds, not its inputs.
expect([...(writesByOwner.get('script:f/a/dbtproj') ?? [])]).toEqual([
'asset:dbt:wh/s/model_a'
])
expect([...(writesByOwner.get('script:f/a/dbtproj') ?? [])]).toEqual(['asset:dbt:wh/s/model_a'])
})
it('ignores runnables that are not dbt', () => {
@@ -1162,8 +1228,6 @@ describe('dbtAssociations with two writers of one relation', () => {
writeBy('f/a/downstream')
])
expect(writesByOwner.get('script:f/a/upstream')).toEqual(new Set(['asset:dbt:wh/s/shared']))
expect(writesByOwner.get('script:f/a/downstream')).toEqual(
new Set(['asset:dbt:wh/s/shared'])
)
expect(writesByOwner.get('script:f/a/downstream')).toEqual(new Set(['asset:dbt:wh/s/shared']))
})
})
@@ -1,4 +1,9 @@
import type { AssetGraphMacroEdge, AssetGraphResponse, NativeTriggerKind } from './types'
import type {
AssetGraphMacroEdge,
AssetGraphResponse,
NativeTriggerKind,
PipelineTriggerDraft
} from './types'
import {
mergeColumnLineage,
parsePipelineAnnotations,
@@ -46,6 +51,8 @@ export type ResolveGraphInput = {
* annotation has no matching trigger row in `base.triggers`.
*/
annotatedNativeKindsByPath: Map<string, Set<NativeTriggerKind>>
/** Undeployed triggers; each stands in for its script's missing trigger of that kind. */
triggerDrafts?: Iterable<PipelineTriggerDraft>
}
/** Mutable bag the sequential passes accumulate the resolved graph into. */
@@ -264,16 +271,83 @@ export function resolveGraph(input: ResolveGraphInput): AssetGraphResponse {
// there is nothing to show (also keeps the no-macros response shape
// byte-identical to before the feature).
const macroEdges = resolveMacroEdges(input)
const triggers = withTriggerDrafts(
[...baseTriggers, ...acc.extraTriggers],
input.triggerDrafts ?? []
)
return {
...base,
assets: acc.assets,
assets: dropOrphanedAssets(acc.assets, base, acc.edges, triggers),
runnables: acc.runnables,
edges: acc.edges,
triggers: [...baseTriggers, ...acc.extraTriggers],
triggers,
...(macroEdges.length > 0 || base.macro_edges ? { macro_edges: macroEdges } : {})
}
}
function withTriggerDrafts(
triggers: AssetGraphResponse['triggers'],
triggerDrafts: Iterable<PipelineTriggerDraft>
): AssetGraphResponse['triggers'] {
const drafts = [...triggerDrafts]
if (drafts.length === 0) return triggers
const drafted = new Set(drafts.map((d) => `${d.kind}:${d.config.script_path}`))
return [
...triggers.filter(
(t) =>
!(
t.trigger_kind !== 'asset' &&
t.missing &&
t.runnable_kind === 'script' &&
drafted.has(`${t.trigger_kind}:${t.runnable_path}`)
)
),
...drafts.map((d) => ({
trigger_kind: d.kind,
path: d.config.path,
runnable_kind: 'script' as const,
runnable_path: d.config.script_path,
unsaved: true,
draft: true
}))
]
}
function referencedAssetKeys(
graph: Pick<AssetGraphResponse, 'edges' | 'triggers' | 'test_edges' | 'dbt_edges'>
): Set<string> {
const keys = new Set<string>()
for (const e of graph.edges) keys.add(`${e.asset_kind}:${e.asset_path}`)
for (const t of graph.triggers)
if (t.trigger_kind === 'asset') keys.add(`${t.asset_kind}:${t.asset_path}`)
for (const e of graph.test_edges ?? []) keys.add(`${e.asset_kind}:${e.asset_path}`)
for (const e of graph.dbt_edges ?? []) {
keys.add(`dbt:${e.from_asset_path}`)
keys.add(`dbt:${e.to_asset_path}`)
}
return keys
}
/**
* The backend only returns assets something references, so a deployed asset
* whose every edge and trigger the draft/live overlays stripped — an output the
* open script renamed — would linger as an edgeless node until the deploy
* lands. Assets that were already unreferenced in `base` are left alone.
*/
function dropOrphanedAssets(
assets: AssetGraphResponse['assets'],
base: AssetGraphResponse,
edges: AssetGraphResponse['edges'],
triggers: AssetGraphResponse['triggers']
): AssetGraphResponse['assets'] {
const before = referencedAssetKeys(base)
const after = referencedAssetKeys({ ...base, edges, triggers })
return assets.filter((a) => {
const key = `${a.kind}:${a.path}`
return !before.has(key) || after.has(key)
})
}
/**
* Macro-library → consumer edges: the deployed base edges, with `// use`
* declarations of overlaid scripts (drafts + the open buffer) taking over
@@ -848,7 +922,6 @@ function overlayInferredLineage(acc: Accumulator, input: ResolveGraphInput) {
overlayLineage(inferredReadsByPath, 'r')
}
/** dbt project ↔ relation association, for a graph that does NOT draw it.
*
* A dbt script owns every relation of its project, so drawing one edge per
@@ -70,6 +70,9 @@ export interface DbtDataTest {
export interface AssetGraphRunnableNode {
path: string
usage_kind: GraphUsageKind
// The script's summary, filled in client-side (not sent by the backend);
// the canvas titles the node with it over the path.
summary?: string
// Script has `// pipeline` annotation. Drives the pipeline-member
// visual state; unrelated to what the script actually writes (that's
// parsed separately into lineage edges).
@@ -170,6 +173,17 @@ export type NativeTriggerKind =
| 'gcp'
| 'data_upload'
export type PipelineTriggerDraftKind = Exclude<NativeTriggerKind, 'webhook' | 'data_upload'>
/** A trigger configured in the pipeline editor but not deployed yet. It lives only
* in the pipeline's draft bundle, never as a workspace trigger draft, and is
* created when the pipeline is saved. `config` is what the kind's editor saves
* (for a schedule, a `NewSchedule`). */
export type PipelineTriggerDraft = {
kind: PipelineTriggerDraftKind
config: Record<string, any> & { path: string; script_path: string }
}
export type AssetGraphTrigger =
| {
trigger_kind: 'asset'
@@ -192,6 +206,9 @@ export type AssetGraphTrigger =
// the canvas renders a red placeholder with a "Create trigger"
// affordance instead of a fully-wired source.
missing?: boolean
// A pipeline-local `PipelineTriggerDraft`: `path` is the trigger it
// will be deployed as, and no row exists yet.
draft?: boolean
}
// Macro-library → consumer edge: the consumer calls `macro_names` of
@@ -19,6 +19,7 @@
PanelLeftClose
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons'
import { HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
@@ -51,6 +52,23 @@
label: string
icon: typeof PythonIcon
onSelect: () => void
badge?: { label: string; class: string }
/** opened in a new tab from a link beside the variant */
docHref?: string
/** shown in the doc panel while the variant is hovered, instead of its option's */
doc?: Doc
}
/** what the doc panel shows */
type Doc = {
label: string
/** a lucide icon or a brand mark; rendered at 26px through both size APIs */
icon: any
accent: string
tagline: string
description: string
bullets: string[]
badge?: { label: string; class: string }
}
/** an importable artifact kind handled by the shared YAML/JSON import drawer */
@@ -83,6 +101,9 @@
const badgeAdvanced = 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
const badgeLegacy = 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
const badgeAlpha = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
const badgeRecommended = 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt'
const allOptions: Option[] = [
{
@@ -181,7 +202,34 @@
'Asset-aware lineage'
],
onSelect: () => goto(`${base}/pipeline`),
badge: { label: 'Alpha', class: badgeAlpha }
badge: { label: 'Alpha', class: badgeAlpha },
variants: [
{
label: 'Windmill pipelines',
icon: Workflow,
badge: { label: 'Recommended', class: badgeRecommended },
onSelect: () => goto(`${base}/pipeline`)
},
{
label: 'dbt',
icon: DbtIcon,
docHref: DBT_DOCS,
onSelect: () => goto(`${base}/scripts/add?lang=dbt`),
doc: {
label: 'dbt',
icon: DbtIcon,
accent: 'orange',
tagline: 'Run an existing dbt project',
description:
'A pipeline is Windmill’s own abstraction; an existing dbt project stays a dbt project, and runs here unchanged as its own kind of script, with dbt still owning its models, refs and tests. All it needs is a warehouse configured under Settings → dbt. Its models show up in the asset graph as dbt:// nodes, next to whatever pipelines you do build, so you can adopt pipelines later, or never.',
bullets: [
'Your dbt project, unchanged',
'Needs a warehouse under Settings → dbt',
'Models appear as dbt:// assets'
]
}
}
]
}
] as Option[])
: []),
@@ -274,18 +322,25 @@
// per-row fan-out submenus: open to the right when there's room. The popover hugs
// the right viewport edge on most screens, so the default flip (right → left) would
// cover the doc panel — fall back below the trigger row instead.
const variantSubmenu = () =>
builders.createSubmenu({
positioning: {
placement: 'right-start',
gutter: 4,
flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] },
fitViewport: true,
overflowPadding: 8
}
})
const {
elements: { subTrigger: wacSubTrigger, subMenu: wacSubMenu },
states: { subOpen: wacSubOpen }
} = builders.createSubmenu({
positioning: {
placement: 'right-start',
gutter: 4,
flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] },
fitViewport: true,
overflowPadding: 8
}
})
} = variantSubmenu()
const {
elements: { subTrigger: pipelineSubTrigger, subMenu: pipelineSubMenu },
states: { subOpen: pipelineSubOpen }
} = variantSubmenu()
let variantSubOpen = $derived($wacSubOpen || $pipelineSubOpen)
const {
elements: { subTrigger: importSubTrigger, subMenu: importSubMenu },
states: { subOpen: importSubOpen }
@@ -351,7 +406,10 @@
if (isOpen && !wasOpen) {
logFeatureUsage('home', 'new_menu_open', { key: source })
}
if (!isOpen) activeKey = undefined
if (!isOpen) {
activeKey = undefined
activeVariantDoc = undefined
}
wasOpen = isOpen
})
@@ -362,17 +420,25 @@
// only persist the non-default (hidden) state, so a cleared key means "shown"
storeLocalSetting(SHOW_DOC_SETTING, value ? undefined : 'false')
}
// The pointer crosses the gutter outside the menu on its way into the Workflow-as-Code
// submenu, so leaving the menu keeps the panel while that submenu is open; it clears
// once the submenu closes with neither pointer nor focus left on the menu.
// The pointer crosses the gutter outside the menu on its way into a variant submenu,
// so leaving the menu keeps the panel while that submenu is open; it clears once the
// submenu closes with neither pointer nor focus left on the menu.
let menuEl: HTMLDivElement | undefined = $state(undefined)
$effect(() => {
if ($wacSubOpen || !menuEl) return
if (variantSubOpen || !menuEl) return
if (!menuEl.matches(':hover') && !menuEl.contains(document.activeElement)) {
activeKey = undefined
}
})
let active = $derived(allOptions.find((o) => o.key === activeKey))
// A hovered variant with its own doc (dbt under Data pipelines) takes over the panel.
let activeVariantDoc: Doc | undefined = $state(undefined)
function highlightOption(key: string | undefined) {
activeKey = key
activeVariantDoc = undefined
}
let active: Doc | undefined = $derived(
activeVariantDoc ?? allOptions.find((o) => o.key === activeKey)
)
let activeAc = $derived(active ? accentClasses[active.accent] : undefined)
// shared YAML/JSON import drawer, reused by every "Import …" extra
@@ -449,7 +515,7 @@
style={showDoc ? 'width: 780px;' : ''}
bind:this={menuEl}
onpointerleave={() => {
if (!$wacSubOpen) activeKey = undefined
if (!variantSubOpen) highlightOption(undefined)
}}
>
<div
@@ -465,7 +531,7 @@
<div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
>
<active.icon size={26} class={activeAc.iconText} />
<active.icon size={26} width={26} height={26} class={activeAc.iconText} />
</div>
<div class="min-w-0">
<div class="flex flex-row items-center gap-2">
@@ -524,48 +590,92 @@
</span>
{/if}
{/snippet}
{#snippet variantRow(
option: Option,
ac: (typeof accentClasses)[string],
rowClass: string,
subTrigger: any,
subMenu: any,
subOpen: boolean
)}
<button
use:melt={subTrigger}
class={rowClass}
onfocusin={() => highlightOption(option.key)}
onpointerenter={() => highlightOption(option.key)}
>
{@render rowBody(option, ac)}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if subOpen}
<!-- The invisible `before:` margin bridges the gutter to the trigger row: when
the submenu falls below its row, that gutter lies over the next row, and
crossing it highlights that item, which closes the submenu. -->
<div
use:melt={subMenu}
use:hugViewportRight
class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 min-w-52 w-max rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']"
>
{#each option.variants ?? [] as variant (variant.label)}
{@const VariantIcon = variant.icon}
<div class="flex flex-row items-center gap-1">
<button
use:melt={$item}
class="flex flex-row flex-1 items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => variant.onSelect()}
onfocusin={() => (activeVariantDoc = variant.doc)}
onpointerenter={() => (activeVariantDoc = variant.doc)}
>
<VariantIcon width={14} height={14} size={14} />
<span class="text-xs font-medium text-primary whitespace-nowrap">
{variant.label}
</span>
{#if variant.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {variant
.badge.class}"
>
{variant.badge.label}
</span>
{/if}
</button>
{#if variant.docHref}
<a
href={variant.docHref}
target="_blank"
rel="noreferrer"
class="px-2 text-[10px] text-tertiary hover:text-secondary hover:underline whitespace-nowrap"
onpointerenter={() => (activeVariantDoc = variant.doc)}
>
See documentation
</a>
{/if}
</div>
{/each}
</div>
{/if}
{/snippet}
{#each allOptions as option (option.key)}
{@const ac = accentClasses[option.accent]}
{@const rowClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
{#if option.variants}
<button
use:melt={$wacSubTrigger}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
>
{@render rowBody(option, ac)}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $wacSubOpen}
<!-- The invisible `before:` margin bridges the gutter to the trigger row: when
the submenu falls below its row, that gutter lies over the next row, and
crossing it highlights that item, which closes the submenu. -->
<div
use:melt={$wacSubMenu}
use:hugViewportRight
class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']"
>
{#each option.variants ?? [] as variant (variant.label)}
{@const VariantIcon = variant.icon}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => variant.onSelect()}
>
<VariantIcon width={14} height={14} />
<span class="text-xs font-medium text-primary">{variant.label}</span>
</button>
{/each}
</div>
{/if}
{#if option.variants && option.key === 'pipeline'}
{@render variantRow(
option,
ac,
rowClass,
$pipelineSubTrigger,
$pipelineSubMenu,
$pipelineSubOpen
)}
{:else if option.variants}
{@render variantRow(option, ac, rowClass, $wacSubTrigger, $wacSubMenu, $wacSubOpen)}
{:else}
<button
use:melt={$item}
class={rowClass}
onfocusin={() => (activeKey = option.key)}
onpointerenter={() => (activeKey = option.key)}
onfocusin={() => highlightOption(option.key)}
onpointerenter={() => highlightOption(option.key)}
onclick={() => option.onSelect()}
>
{@render rowBody(option, ac)}
@@ -578,8 +688,8 @@
<button
use:melt={$importSubTrigger}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onfocusin={() => (activeKey = undefined)}
onpointerenter={() => (activeKey = undefined)}
onfocusin={() => highlightOption(undefined)}
onpointerenter={() => highlightOption(undefined)}
>
<div
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
@@ -40,6 +40,7 @@
* editor has to pass it: every other editor renders with a `trigger`,
* whose `type` is the same value. */
triggerKind?: TriggerType
saveLabel?: string
}
let {
@@ -60,7 +61,8 @@
suspendedJobsModal,
disableSuspendedMode = false,
triggerPath,
triggerKind
triggerKind,
saveLabel = 'Save'
}: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const wsId = $derived($operatingWorkspace)
@@ -120,7 +122,7 @@
}}
loading={isLoading}
>
Save
{saveLabel}
</Button>
{/if}
{:else}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: AmqpTriggerEditorInner | undefined = $state()
</script>
@@ -172,12 +172,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -212,8 +217,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100)
@@ -243,6 +254,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -335,6 +347,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
@@ -431,7 +448,9 @@
? can_write
? `Edit AMQP trigger ${initialPath}`
: `AMQP trigger ${initialPath}`
: 'New AMQP trigger'}
: saveDraftHandler
? 'Draft AMQP trigger'
: 'New AMQP trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -489,6 +508,7 @@
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
onToggleMode={handleToggleMode}
@@ -27,6 +27,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: EmailTriggerEditorInner | undefined = $state()
</script>
@@ -139,12 +139,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Partial<NewEmailTrigger>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -188,8 +193,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Partial<EmailTrigger>
defaultValues?: Partial<EmailTrigger>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
drawerLoading = true
let loader = setTimeout(() => {
showLoader = true
@@ -218,6 +229,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig as Partial<EmailTrigger>)
} finally {
clearTimeout(loader)
drawerLoading = false
@@ -276,6 +288,11 @@
}
async function triggerScript(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getEmailTriggerConfig()))
drawer?.closeDrawer()
return
}
if (customSaveBehavior) {
customSaveBehavior(emailConfig)
drawer?.closeDrawer()
@@ -501,6 +518,7 @@
{edit}
isLoading={deploymentLoading}
onUpdate={triggerScript}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
{isDeployed}
@@ -520,7 +538,9 @@
? can_write
? `Edit email trigger ${initialPath}`
: `Email trigger ${initialPath}`
: 'New email trigger'}
: saveDraftHandler
? 'Draft email trigger'
: 'New email trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -22,6 +22,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: GcpTriggerEditorInner | undefined = $state()
</script>
@@ -163,12 +163,17 @@
)
const captureConfig = $derived.by(untrack(() => isEditor) ? getGcpCaptureConfig : () => ({}))
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultValues?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
drawerLoading = true
@@ -201,8 +206,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
drawerLoading = true
try {
drawer?.openDrawer()
@@ -244,6 +255,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
drawerLoading = false
}
@@ -307,6 +319,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getGcpConfig()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = gcpConfig
@@ -441,7 +458,9 @@
? can_write
? `Edit GCP Pub/Sub trigger ${initialPath}`
: `GCP Pub/Sub trigger ${initialPath}`
: 'New GCP Pub/Sub trigger'}
: saveDraftHandler
? 'Draft GCP Pub/Sub trigger'
: 'New GCP Pub/Sub trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -498,6 +517,7 @@
{allowDraft}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
onToggleMode={handleToggleMode}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: KafkaTriggerEditorInner | undefined = $state()
</script>
@@ -174,12 +174,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -217,8 +222,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>
nDefaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -252,6 +263,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -334,6 +346,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
@@ -453,7 +470,9 @@
? can_write
? `Edit Kafka trigger ${initialPath}`
: `Kafka trigger ${initialPath}`
: 'New Kafka trigger'}
: saveDraftHandler
? 'Draft Kafka trigger'
: 'New Kafka trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -511,6 +530,7 @@
{isDeployed}
{saveDisabled}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
onToggleMode={handleToggleMode}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: MqttTriggerEditorInner | undefined = $state()
</script>
@@ -169,12 +169,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -212,8 +217,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100)
@@ -248,6 +259,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -347,6 +359,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
@@ -427,7 +444,9 @@
? can_write
? `Edit MQTT trigger ${initialPath}`
: `MQTT trigger ${initialPath}`
: 'New MQTT trigger'}
: saveDraftHandler
? 'Draft MQTT trigger'
: 'New MQTT trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -485,6 +504,7 @@
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
onToggleMode={handleToggleMode}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: NatsTriggerEditorInner | undefined = $state()
</script>
@@ -157,12 +157,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -206,8 +211,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
nDefaultValues?: Record<string, any>
nDefaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100)
@@ -241,6 +252,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -326,6 +338,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = natsConfig
@@ -424,7 +441,9 @@
? can_write
? `Edit NATS trigger ${initialPath}`
: `NATS trigger ${initialPath}`
: 'New NATS trigger'}
: saveDraftHandler
? 'Draft NATS trigger'
: 'New NATS trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -481,6 +500,7 @@
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
{cloudDisabled}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, undefined, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: PostgresTriggerEditorInner | undefined = $state()
</script>
@@ -258,12 +258,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -307,8 +312,14 @@
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>,
newDraft?: boolean
newDraft?: boolean,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -352,6 +363,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
clearTimeout(loadingTimeout)
drawerLoading = false
@@ -477,6 +489,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
const cfg = postgresConfig
if (!cfg) {
return
@@ -608,7 +625,9 @@
? can_write
? `Edit Postgres trigger ${initialPath}`
: `Postgres trigger ${initialPath}`
: 'New Postgres trigger'}
: saveDraftHandler
? 'Draft Postgres trigger'
: 'New Postgres trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}{@render actionsSnippet()}{/snippet}
@@ -663,6 +682,7 @@
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
{mode}
@@ -0,0 +1,597 @@
<script lang="ts" module>
import { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte'
import type { ErrorHandler, Retry } from '$lib/gen'
export type ScheduleAdvancedState = {
errorHandlerSelected: ErrorHandler
errorHandlerPath: string | undefined
errorHandleritemKind: 'flow' | 'script'
errorHandlerExtraArgs: Record<string, any>
wsErrorHandlerMuted: boolean
failedTimes: number
failedExact: boolean
recoveryHandlerSelected: ErrorHandler
recoveryHandlerPath: string | undefined
recoveryHandlerItemKind: 'flow' | 'script'
recoveryHandlerExtraArgs: Record<string, any>
recoveredTimes: number
successHandlerSelected: ErrorHandler
successHandlerPath: string | undefined
successHandlerItemKind: 'flow' | 'script'
successHandlerExtraArgs: Record<string, any>
retry: Retry | undefined
dynamicSkipPath: string | undefined
tag: string | undefined
}
export function emptyScheduleAdvanced(): ScheduleAdvancedState {
return {
errorHandlerSelected: 'slack',
errorHandlerPath: undefined,
errorHandleritemKind: 'script',
errorHandlerExtraArgs: {},
wsErrorHandlerMuted: false,
failedTimes: 1,
failedExact: false,
recoveryHandlerSelected: 'slack',
recoveryHandlerPath: undefined,
recoveryHandlerItemKind: 'script',
recoveryHandlerExtraArgs: {},
recoveredTimes: 1,
successHandlerSelected: 'slack',
successHandlerPath: undefined,
successHandlerItemKind: 'script',
successHandlerExtraArgs: {},
retry: undefined,
dynamicSkipPath: undefined,
tag: undefined
}
}
/** The schedule fields the Advanced tabs own, as the schedule API takes them. */
export function scheduleAdvancedCfg(a: ScheduleAdvancedState) {
return {
on_failure: a.errorHandlerPath
? handlerFullPath(a.errorHandlerSelected, a.errorHandleritemKind, a.errorHandlerPath)
: undefined,
on_failure_times: a.failedTimes,
on_failure_exact: a.failedExact,
on_failure_extra_args: a.errorHandlerPath ? a.errorHandlerExtraArgs : undefined,
on_recovery: a.recoveryHandlerPath
? handlerFullPath(a.recoveryHandlerSelected, a.recoveryHandlerItemKind, a.recoveryHandlerPath)
: undefined,
on_recovery_times: a.recoveredTimes,
on_recovery_extra_args: a.recoveryHandlerPath ? a.recoveryHandlerExtraArgs : {},
on_success: a.successHandlerPath
? handlerFullPath(a.successHandlerSelected, a.successHandlerItemKind, a.successHandlerPath)
: undefined,
on_success_extra_args: a.successHandlerPath ? a.successHandlerExtraArgs : {},
ws_error_handler_muted: a.wsErrorHandlerMuted,
retry: a.retry,
tag: a.tag,
dynamic_skip: a.dynamicSkipPath
}
}
</script>
<script lang="ts">
import { Alert, Button, Tab, Tabs } from '$lib/components/common'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte'
import { ScheduleService } from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { emptyString, sendUserToast } from '$lib/utils'
import { Loader2, Save } from 'lucide-svelte'
// The schedule drawer's Advanced tabs, shared with editors that configure a
// schedule before it exists (the pipeline editor's create wizard).
interface Props {
wsId: string | undefined
itemKind: 'flow' | 'script'
canWrite: boolean
loading?: boolean
errorHandlerSelected: ErrorHandler
errorHandlerPath: string | undefined
errorHandleritemKind: 'flow' | 'script'
errorHandlerExtraArgs: Record<string, any>
wsErrorHandlerMuted: boolean
failedTimes: number
failedExact: boolean
recoveryHandlerSelected: ErrorHandler
recoveryHandlerPath: string | undefined
recoveryHandlerItemKind: 'flow' | 'script'
recoveryHandlerExtraArgs: Record<string, any>
recoveredTimes: number
successHandlerSelected: ErrorHandler
successHandlerPath: string | undefined
successHandlerItemKind: 'flow' | 'script'
successHandlerExtraArgs: Record<string, any>
retry: Retry | undefined
dynamicSkipPath: string | undefined
tag: string | undefined
}
let {
wsId,
itemKind,
canWrite,
loading = false,
errorHandlerSelected = $bindable(),
errorHandlerPath = $bindable(),
errorHandleritemKind = $bindable(),
errorHandlerExtraArgs = $bindable(),
wsErrorHandlerMuted = $bindable(),
failedTimes = $bindable(),
failedExact = $bindable(),
recoveryHandlerSelected = $bindable(),
recoveryHandlerPath = $bindable(),
recoveryHandlerItemKind = $bindable(),
recoveryHandlerExtraArgs = $bindable(),
recoveredTimes = $bindable(),
successHandlerSelected = $bindable(),
successHandlerPath = $bindable(),
successHandlerItemKind = $bindable(),
successHandlerExtraArgs = $bindable(),
retry = $bindable(),
dynamicSkipPath = $bindable(),
tag = $bindable()
}: Props = $props()
let optionTabSelected:
| 'error_handler'
| 'recovery_handler'
| 'success_handler'
| 'retries'
| 'dynamic_skip'
| 'tag' = $state('error_handler')
// Carry the acting workspace onto "create from template" routes, so the script is created
// in the workspace this schedule lives in.
const wsParam = $derived(wsId ? `&workspace=${encodeURIComponent(wsId)}` : '')
async function saveAsDefaultErrorHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default error handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'error',
override_existing: overrideExisting,
path:
errorHandlerPath == undefined
? undefined
: handlerFullPath(errorHandlerSelected, errorHandleritemKind, errorHandlerPath),
extra_args: errorHandlerExtraArgs,
number_of_occurence: failedTimes,
number_of_occurence_exact: failedExact,
workspace_handler_muted: wsErrorHandlerMuted
}
})
if (errorHandlerPath !== undefined) {
sendUserToast(`Default error handler saved to ${errorHandlerPath}`, false)
} else {
sendUserToast(`Default error handler reset`, false)
}
}
}
async function saveAsDefaultRecoveryHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default recovery handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'recovery',
override_existing: overrideExisting,
path:
recoveryHandlerPath === undefined
? undefined
: handlerFullPath(
recoveryHandlerSelected,
recoveryHandlerItemKind,
recoveryHandlerPath
),
extra_args: recoveryHandlerExtraArgs,
number_of_occurence: recoveredTimes
}
})
if (recoveryHandlerPath !== undefined) {
sendUserToast(`Default recovery handler saved to ${recoveryHandlerPath}`, false)
} else {
sendUserToast(`Default recovery handler reset`, false)
}
}
}
async function saveAsDefaultSuccessHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default success handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'success',
override_existing: overrideExisting,
path:
successHandlerPath === undefined
? undefined
: handlerFullPath(successHandlerSelected, successHandlerItemKind, successHandlerPath),
extra_args: successHandlerExtraArgs,
number_of_occurence: recoveredTimes
}
})
if (successHandlerPath !== undefined) {
sendUserToast(`Default success handler saved to ${successHandlerPath}`, false)
} else {
sendUserToast(`Default success handler reset`, false)
}
}
}
</script>
<div class="flex flex-col gap-2 min-h-96">
{#if !loading}
<Tabs bind:selected={optionTabSelected}>
<Tab value="error_handler" label="Error Handler" />
<Tab value="recovery_handler" label="Recovery Handler" />
<Tab value="success_handler" label="Success Handler" />
<Tab value="retries" label="Retries" />
<Tab value="dynamic_skip" label="Dynamic skip" />
{#if itemKind === 'script'}
<Tab value="tag" label="Custom tag" />
{/if}
</Tabs>
{#if optionTabSelected === 'error_handler'}
<Section label="Error handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center gap-1 text-xs text-secondary">
<Dropdown
disabled={!canWrite}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultErrorHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultErrorHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<div class="flex flex-row py-2">
<Toggle
size="xs"
disabled={!canWrite || !$enterpriseLicense}
bind:checked={wsErrorHandlerMuted}
options={{ right: 'Mute workspace error handler for this schedule' }}
/>
</div>
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={canWrite}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerPath}
toggleText="Alert channel on error"
customScriptTemplate="/scripts/add?hub=hub%2F19743%2Fwindmill%2Fschedule_error_handler_template"
bind:customHandlerKind={errorHandleritemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the error handler:
<ul class="mt-1 ml-2">
<li
><b>workspace_id</b>: The ID of the workspace that the schedule belongs to.</li
>
<li><b>job_id</b>: The UUID of the job that errored.</li>
<li><b>path</b>: The path of the script or flow that failed.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>error</b>: The error details.</li>
<li
><b>failed_times</b>: Minimum number of times the schedule failed before
calling the error handler.</li
>
<li><b>started_at</b>: The start datetime of the latest job that failed.</li>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center mt-4 font-semibold text-xs gap-2">
<p class={emptyString(errorHandlerPath) ? 'text-primary' : ''}>
Triggered when schedule failed</p
>
<select
class="!w-14"
bind:value={failedExact}
disabled={!$enterpriseLicense || emptyString(errorHandlerPath)}
>
<option value={false}>&gt;=</option>
<option value={true}>==</option>
</select>
<input
type="number"
class="!w-14 text-center {emptyString(errorHandlerPath) ? 'text-primary' : ''}"
bind:value={failedTimes}
disabled={!$enterpriseLicense}
min="1"
/>
<p class={emptyString(errorHandlerPath) ? 'text-primary' : ''}
>time{failedTimes > 1 ? 's in a row' : ''}</p
>
</div>
</div>
</Section>
{:else if optionTabSelected === 'recovery_handler'}
{@const disabled = !canWrite || emptyString($enterpriseLicense)}
<Section label="Recovery handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center text-secondary text-xs gap-2">
defaults
<Dropdown
{disabled}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultRecoveryHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultRecoveryHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="recovery"
bind:handlerSelected={recoveryHandlerSelected}
bind:handlerPath={recoveryHandlerPath}
toggleText="Alert channel when error recovered"
customScriptTemplate="/scripts/add?hub=hub%2F9082%2Fwindmill%2Fschedule_recovery_handler_template"
bind:customHandlerKind={recoveryHandlerItemKind}
bind:handlerExtraArgs={recoveryHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the recovery handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that recovered.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>error</b>: The error of the last job that errored</li>
<li
><b>error_started_at</b>: The start datetime of the last job that errored</li
>
<li
><b>success_times</b>: The number of times the schedule succeeded before
calling the recovery handler.</li
>
<li><b>success_result</b>: The result of the latest successful job</li>
<li
><b>success_started_at</b>: The start datetime of the latest successful job</li
>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center mt-5 font-semibold text-xs {emptyString(
recoveryHandlerPath
)
? 'text-primary'
: ''}"
>
<p>Triggered when schedule recovered</p>
<input
type="number"
class="!w-14 mx-2 text-center"
bind:value={recoveredTimes}
min="1"
{disabled}
/>
<p>time{recoveredTimes > 1 ? 's in a row' : ''}</p>
</div>
</div>
</Section>
{:else if optionTabSelected === 'success_handler'}
{@const disabled = !canWrite || emptyString($enterpriseLicense)}
<Section label="Success handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center text-secondary text-xs gap-2">
defaults
<Dropdown
{disabled}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultSuccessHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultSuccessHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="success"
bind:handlerSelected={successHandlerSelected}
bind:handlerPath={successHandlerPath}
toggleText="Alert channel when successful"
customScriptTemplate="/scripts/add?hub=hub%2F9071%2Fwindmill%2Fschedule_success_handler_template"
bind:customHandlerKind={successHandlerItemKind}
bind:handlerExtraArgs={successHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the success handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that succeeded.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>success_result</b>: The result of the successful job</li>
<li><b>success_started_at</b>: The start datetime of the successful job</li>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
</Section>
{:else if optionTabSelected === 'retries'}
{@const disabled = !canWrite || emptyString($enterpriseLicense)}
<Section label="Retries">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
<Tooltip>
If defined, upon error this schedule will be retried with a delay and a maximum number
of attempts as defined below.
<br />
This is only available for individual script. For flows, retries can be set on each flow
step in the flow editor.
</Tooltip>
{/snippet}
{#if itemKind !== 'script'}
<Alert type="info" title="Only available for scripts" class="mb-2">
Error Handler and Retries are only available for scripts. For flows, use the built-in <a
href="https://www.windmill.dev/docs/flows/flow_error_handler"
target="_blank">error handler</a
>
and <a href="https://www.windmill.dev/docs/flows/retries" target="_blank">retries</a>.
</Alert>
{:else}
<FlowRetries
bind:flowModuleRetry={retry}
disabled={itemKind !== 'script' || disabled}
/>
{/if}
</Section>
{:else if optionTabSelected === 'dynamic_skip'}
<Section label="Dynamic skip">
{#snippet header()}
<Tooltip>
Optional script to filter scheduled dates. Receives the proposed datetime and returns
boolean. True = run on this date, False = skip to next occurrence.
</Tooltip>
{/snippet}
<div class="flex flex-col gap-6">
<Label label="Dynamic skip script">
<div class="flex flex-row">
<ScriptPicker
workspace={wsId}
disabled={!canWrite}
bind:scriptPath={dynamicSkipPath}
kinds={['script']}
allowRefresh={canWrite}
clearable
/>
{#if !dynamicSkipPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href="/scripts/add?hub=hub%2F19822%2Fwindmill%2Fdynamic_skip_template{wsParam}"
disabled={!canWrite}
target="_blank"
>
Create from template
</Button>
{/if}
</div>
</Label>
<Alert type="info" size="xs" title="Handler requirements">
Handler must return a boolean value. Return true to execute the scheduled job, false
to skip.
</Alert>
</div>
</Section>
{:else if optionTabSelected === 'tag'}
<Section
label="Custom script tag"
tooltip="When set, the script tag will be overridden by this tag"
>
<WorkerTagPicker
bind:tag
workspaceId={wsId}
popupPlacement="top-end"
disabled={!canWrite}
/>
</Section>
{/if}
{:else}
<Loader2 class="animate-spin" />
{/if}
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { tickPainted } from '$lib/utils/paint'
import type ScheduleEditorInner from './ScheduleEditorInner.svelte'
import type { Schedule } from '$lib/gen'
let { onUpdate }: { onUpdate?: (path?: string) => void } = $props()
@@ -29,6 +30,17 @@
drawer?.openNew(is_flow, initial_script_path, undefined, schedule_path, fixedScriptPath)
}
/** Edit a schedule that exists only as the caller's draft; Save hands it back. */
export async function openDraft(
cfg: Schedule,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await loadInner()
await tickPainted()
drawer?.openNew(false, cfg.script_path, cfg, undefined, cfg.script_path, { onSaveDraft })
}
let drawer: ScheduleEditorInner | undefined = $state()
</script>
@@ -1,11 +1,12 @@
<script lang="ts">
import { Alert, Badge, Button, ButtonType, Tab, Tabs } from '$lib/components/common'
import { Alert, Badge, Button, ButtonType } from '$lib/components/common'
import {
clearPageDrawerAnchor,
setPageDrawerAnchor
} from '$lib/components/sessions/pageDrawerSession'
import { SCHEDULES_PATH } from '$lib/components/sessions/previewPaths'
import TriggerAdvancedBadges from '../TriggerAdvancedBadges.svelte'
import ScheduleAdvancedOptions, { scheduleAdvancedCfg } from './ScheduleAdvancedOptions.svelte'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import CronInput from '$lib/components/CronInput.svelte'
@@ -15,12 +16,8 @@
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { loadSchema } from '$lib/infer'
import PipelineLockedRunnableInfo from '$lib/components/triggers/PipelineLockedRunnableInfo.svelte'
import ErrorOrRecoveryHandler, {
handlerFullPath
} from '$lib/components/ErrorOrRecoveryHandler.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Dropdown from '$lib/components/DropdownV2.svelte'
import {
FlowService,
ScheduleService,
@@ -32,19 +29,15 @@
type Schedule,
type ErrorHandler
} from '$lib/gen'
import { enterpriseLicense } from '$lib/stores'
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
import { useScheduleLock } from '$lib/operatorWriteRights'
import { base } from '$lib/base'
import Section from '$lib/components/Section.svelte'
import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte'
import { List, Loader2, AlertTriangle } from 'lucide-svelte'
import autosize from '$lib/autosize'
import TriggerEditorToolbar from '$lib/components/triggers/TriggerEditorToolbar.svelte'
import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte'
import Label from '$lib/components/Label.svelte'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import { runScheduleNow } from '../scheduled/utils'
import { handleConfigChange } from '../utils'
import { withForkConflictRetry } from '$lib/utils/forkConflict'
@@ -74,12 +67,6 @@
trigger = undefined
} = $props()
let optionTabSelected:
| 'error_handler'
| 'recovery_handler'
| 'success_handler'
| 'retries'
| 'dynamic_skip' = $state('error_handler')
let initialPath = $state('')
let edit = $state(true)
let schedule: string = $state('0 0 12 * *')
@@ -139,6 +126,10 @@
let isValid = $state(true)
let allowSchedule = $derived(isValid && validCRON && script_path != '')
let deploymentLoading = $state(false)
// Set by `openNew({ onSaveDraft })`: the caller keeps the schedule as its own
// draft, so Save hands it the config instead of writing it, and nothing may
// autosave a workspace trigger draft for a path that is not deployed.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
let permissionedAs = $state<string | undefined>(undefined)
let selectedPermissionedAs = $state<string | undefined>(undefined)
let preservePermissionedAs = $state(false)
@@ -164,14 +155,11 @@
emptyString(errorHandlerExtraArgs['channel'])) ||
!can_write
)
// Carry the acting workspace onto "create from template" routes, so the script is created
// in the workspace this schedule lives in.
const wsParam = $derived(wsId ? `&workspace=${encodeURIComponent(wsId)}` : '')
const scheduleCfg = $derived.by(getScheduleCfg)
const draftSync = useTriggerDraftSync({
itemKind: 'trigger_schedule',
path: () => initialPath,
path: () => (saveDraftHandler ? '' : initialPath),
workspace: () => wsId,
drawerLoading: () => drawerLoading,
getCfg: () => scheduleCfg,
@@ -190,6 +178,7 @@
}, 100) // Do not show loading spinner for the first 100ms
drawerLoading = true
acting.forgetFailures()
saveDraftHandler = undefined
try {
drawer?.openDrawer()
setPageDrawerAnchor(SCHEDULES_PATH, ePath)
@@ -328,9 +317,10 @@
defaultValues?: Schedule,
schedule_path?: string,
fixedScriptPath_?: string,
opts: { getDraft?: boolean } = {}
opts: { getDraft?: boolean; onSaveDraft?: (cfg: Record<string, any>) => void } = {}
) {
const getDraft = opts.getDraft ?? true
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100) // Do not show loading spinner for the first 100ms
@@ -432,93 +422,6 @@
}
}
async function saveAsDefaultErrorHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default error handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'error',
override_existing: overrideExisting,
path:
errorHandlerPath == undefined
? undefined
: handlerFullPath(errorHandlerSelected, errorHandleritemKind, errorHandlerPath),
extra_args: errorHandlerExtraArgs,
number_of_occurence: failedTimes,
number_of_occurence_exact: failedExact,
workspace_handler_muted: wsErrorHandlerMuted
}
})
if (errorHandlerPath !== undefined) {
sendUserToast(`Default error handler saved to ${errorHandlerPath}`, false)
} else {
sendUserToast(`Default error handler reset`, false)
}
}
}
async function saveAsDefaultRecoveryHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default recovery handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'recovery',
override_existing: overrideExisting,
path:
recoveryHandlerPath === undefined
? undefined
: handlerFullPath(
recoveryHandlerSelected,
recoveryHandlerItemKind,
recoveryHandlerPath
),
extra_args: recoveryHandlerExtraArgs,
number_of_occurence: recoveredTimes
}
})
if (recoveryHandlerPath !== undefined) {
sendUserToast(`Default recovery handler saved to ${recoveryHandlerPath}`, false)
} else {
sendUserToast(`Default recovery handler reset`, false)
}
}
}
async function saveAsDefaultSuccessHandler(overrideExisting: boolean) {
if (!$enterpriseLicense) {
sendUserToast(`Setting default success handler is an enterprise edition feature`, true)
return
}
if (wsId) {
await ScheduleService.setDefaultErrorOrRecoveryHandler({
workspace: wsId!,
requestBody: {
handler_type: 'success',
override_existing: overrideExisting,
path:
successHandlerPath === undefined
? undefined
: handlerFullPath(successHandlerSelected, successHandlerItemKind, successHandlerPath),
extra_args: successHandlerExtraArgs,
number_of_occurence: recoveredTimes
}
})
if (successHandlerPath !== undefined) {
sendUserToast(`Default success handler saved to ${successHandlerPath}`, false)
} else {
sendUserToast(`Default success handler reset`, false)
}
}
}
/**
* Apply the deployed config to the form, then return the saved-draft overlay
* so the caller captures `initialConfig` from the deployed-only form BEFORE
@@ -631,6 +534,11 @@
async function scheduleScript(): Promise<void> {
const previousPath = initialPath
const scheduleCfg = getScheduleCfg()
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(scheduleCfg))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const isSaved = await saveScheduleFromCfg(scheduleCfg, edit, wsId!)
if (isSaved) {
@@ -705,32 +613,34 @@
is_flow: is_flow,
args: args,
enabled: enabled,
on_failure: errorHandlerPath
? handlerFullPath(errorHandlerSelected, errorHandleritemKind, errorHandlerPath)
: undefined,
on_failure_times: failedTimes,
on_failure_exact: failedExact,
on_failure_extra_args: errorHandlerPath ? errorHandlerExtraArgs : undefined,
on_recovery: recoveryHandlerPath
? handlerFullPath(recoveryHandlerSelected, recoveryHandlerItemKind, recoveryHandlerPath)
: undefined,
on_recovery_times: recoveredTimes,
on_recovery_extra_args: recoveryHandlerPath ? recoveryHandlerExtraArgs : {},
on_success: successHandlerPath
? handlerFullPath(successHandlerSelected, successHandlerItemKind, successHandlerPath)
: undefined,
on_success_extra_args: successHandlerPath ? successHandlerExtraArgs : {},
ws_error_handler_muted: wsErrorHandlerMuted,
retry: retry,
...scheduleAdvancedCfg({
errorHandlerSelected,
errorHandlerPath,
errorHandleritemKind,
errorHandlerExtraArgs,
wsErrorHandlerMuted,
failedTimes,
failedExact,
recoveryHandlerSelected,
recoveryHandlerPath,
recoveryHandlerItemKind,
recoveryHandlerExtraArgs,
recoveredTimes,
successHandlerSelected,
successHandlerPath,
successHandlerItemKind,
successHandlerExtraArgs,
retry,
dynamicSkipPath,
tag
}),
summary: summary != '' ? summary : undefined,
labels: labels,
description: description,
no_flow_overlap: no_flow_overlap,
tag: tag,
paused_until: paused_until,
cron_version: cronVersion,
extra_perms: extraPerms,
dynamic_skip: dynamicSkipPath,
permissioned_as: selectedPermissionedAs,
preserve_permissioned_as: preservePermissionedAs || undefined
}
@@ -799,6 +709,7 @@
onToggleMode={(mode) => handleToggleEnabled(mode === 'enabled')}
{isDeployed}
disableSuspendedMode
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
>
{#snippet extra()}
{#if !drawerLoading && edit}
@@ -1093,367 +1004,37 @@
]}
/>
{/snippet}
{@render errorHandler()}
<ScheduleAdvancedOptions
{wsId}
{itemKind}
canWrite={can_write}
{loading}
bind:errorHandlerSelected
bind:errorHandlerPath
bind:errorHandleritemKind
bind:errorHandlerExtraArgs
bind:wsErrorHandlerMuted
bind:failedTimes
bind:failedExact
bind:recoveryHandlerSelected
bind:recoveryHandlerPath
bind:recoveryHandlerItemKind
bind:recoveryHandlerExtraArgs
bind:recoveredTimes
bind:successHandlerSelected
bind:successHandlerPath
bind:successHandlerItemKind
bind:successHandlerExtraArgs
bind:retry
bind:dynamicSkipPath
bind:tag
/>
</Section>
<div class="pb-8" />
</div>
{/if}
{/snippet}
{#snippet errorHandler()}
<div class="flex flex-col gap-2 min-h-96">
{#if !loading}
<Tabs bind:selected={optionTabSelected}>
<Tab value="error_handler" label="Error Handler" />
<Tab value="recovery_handler" label="Recovery Handler" />
<Tab value="success_handler" label="Success Handler" />
<Tab value="retries" label="Retries" />
<Tab value="dynamic_skip" label="Dynamic skip" />
{#if itemKind === 'script'}
<Tab value="tag" label="Custom tag" />
{/if}
</Tabs>
{#if optionTabSelected === 'error_handler'}
<Section label="Error handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center gap-1 text-xs text-secondary">
<Dropdown
disabled={!can_write}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultErrorHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultErrorHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<div class="flex flex-row py-2">
<Toggle
size="xs"
disabled={!can_write || !$enterpriseLicense}
bind:checked={wsErrorHandlerMuted}
options={{ right: 'Mute workspace error handler for this schedule' }}
/>
</div>
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={can_write}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerPath}
toggleText="Alert channel on error"
customScriptTemplate="/scripts/add?hub=hub%2F19743%2Fwindmill%2Fschedule_error_handler_template"
bind:customHandlerKind={errorHandleritemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the error handler:
<ul class="mt-1 ml-2">
<li
><b>workspace_id</b>: The ID of the workspace that the schedule belongs to.</li
>
<li><b>job_id</b>: The UUID of the job that errored.</li>
<li><b>path</b>: The path of the script or flow that failed.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>error</b>: The error details.</li>
<li
><b>failed_times</b>: Minimum number of times the schedule failed before
calling the error handler.</li
>
<li><b>started_at</b>: The start datetime of the latest job that failed.</li>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-row items-center justify-between">
<div class="flex flex-row items-center mt-4 font-semibold text-xs gap-2">
<p class={emptyString(errorHandlerPath) ? 'text-primary' : ''}>
Triggered when schedule failed</p
>
<select
class="!w-14"
bind:value={failedExact}
disabled={!$enterpriseLicense || emptyString(errorHandlerPath)}
>
<option value={false}>&gt;=</option>
<option value={true}>==</option>
</select>
<input
type="number"
class="!w-14 text-center {emptyString(errorHandlerPath) ? 'text-primary' : ''}"
bind:value={failedTimes}
disabled={!$enterpriseLicense}
min="1"
/>
<p class={emptyString(errorHandlerPath) ? 'text-primary' : ''}
>time{failedTimes > 1 ? 's in a row' : ''}</p
>
</div>
</div>
</Section>
{:else if optionTabSelected === 'recovery_handler'}
{@const disabled = !can_write || emptyString($enterpriseLicense)}
<Section label="Recovery handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center text-secondary text-xs gap-2">
defaults
<Dropdown
{disabled}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultRecoveryHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultRecoveryHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="recovery"
bind:handlerSelected={recoveryHandlerSelected}
bind:handlerPath={recoveryHandlerPath}
toggleText="Alert channel when error recovered"
customScriptTemplate="/scripts/add?hub=hub%2F9082%2Fwindmill%2Fschedule_recovery_handler_template"
bind:customHandlerKind={recoveryHandlerItemKind}
bind:handlerExtraArgs={recoveryHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the recovery handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that recovered.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>error</b>: The error of the last job that errored</li>
<li
><b>error_started_at</b>: The start datetime of the last job that errored</li
>
<li
><b>success_times</b>: The number of times the schedule succeeded before
calling the recovery handler.</li
>
<li><b>success_result</b>: The result of the latest successful job</li>
<li
><b>success_started_at</b>: The start datetime of the latest successful job</li
>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-row items-center justify-between">
<div
class="flex flex-row items-center mt-5 font-semibold text-xs {emptyString(
recoveryHandlerPath
)
? 'text-primary'
: ''}"
>
<p>Triggered when schedule recovered</p>
<input
type="number"
class="!w-14 mx-2 text-center"
bind:value={recoveredTimes}
min="1"
{disabled}
/>
<p>time{recoveredTimes > 1 ? 's in a row' : ''}</p>
</div>
</div>
</Section>
{:else if optionTabSelected === 'success_handler'}
{@const disabled = !can_write || emptyString($enterpriseLicense)}
<Section label="Success handler">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
{/snippet}
{#snippet action()}
<div class="flex flex-row items-center text-secondary text-xs gap-2">
defaults
<Dropdown
{disabled}
items={[
{
displayName: `Override future schedules only`,
action: () => saveAsDefaultSuccessHandler(false)
},
{
displayName: 'Override all existing',
type: 'delete',
action: () => saveAsDefaultSuccessHandler(true)
}
]}
>
{#snippet buttonReplacement()}
<Save size={12} class="mr-1" />
Set as default
{/snippet}
</Dropdown>
</div>
{/snippet}
<ErrorOrRecoveryHandler
workspace={wsId}
isEditable={!disabled}
errorOrRecovery="success"
bind:handlerSelected={successHandlerSelected}
bind:handlerPath={successHandlerPath}
toggleText="Alert channel when successful"
customScriptTemplate="/scripts/add?hub=hub%2F9071%2Fwindmill%2Fschedule_success_handler_template"
bind:customHandlerKind={successHandlerItemKind}
bind:handlerExtraArgs={successHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-xs"
>The following args will be passed to the success handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that succeeded.</li>
<li><b>is_flow</b>: Whether the runnable is a flow.</li>
<li><b>schedule_path</b>: The path of the schedule.</li>
<li><b>success_result</b>: The result of the successful job</li>
<li><b>success_started_at</b>: The start datetime of the successful job</li>
</ul>
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
</Section>
{:else if optionTabSelected === 'retries'}
{@const disabled = !can_write || emptyString($enterpriseLicense)}
<Section label="Retries">
{#snippet header()}
<div class="flex flex-row gap-2">
{#if !$enterpriseLicense}<span class="text-xs text-secondary">(ee only)</span>{/if}
</div>
<Tooltip>
If defined, upon error this schedule will be retried with a delay and a maximum number
of attempts as defined below.
<br />
This is only available for individual script. For flows, retries can be set on each flow
step in the flow editor.
</Tooltip>
{/snippet}
{#if itemKind !== 'script'}
<Alert type="info" title="Only available for scripts" class="mb-2">
Error Handler and Retries are only available for scripts. For flows, use the built-in <a
href="https://www.windmill.dev/docs/flows/flow_error_handler"
target="_blank">error handler</a
>
and <a href="https://www.windmill.dev/docs/flows/retries" target="_blank">retries</a>.
</Alert>
{:else}
<FlowRetries
bind:flowModuleRetry={retry}
disabled={itemKind !== 'script' || disabled}
/>
{/if}
</Section>
{:else if optionTabSelected === 'dynamic_skip'}
<Section label="Dynamic skip">
{#snippet header()}
<Tooltip>
Optional script to filter scheduled dates. Receives the proposed datetime and returns
boolean. True = run on this date, False = skip to next occurrence.
</Tooltip>
{/snippet}
<div class="flex flex-col gap-6">
<Label label="Dynamic skip script">
<div class="flex flex-row">
<ScriptPicker
workspace={wsId}
disabled={!can_write}
bind:scriptPath={dynamicSkipPath}
kinds={['script']}
allowRefresh={can_write}
clearable
/>
{#if !dynamicSkipPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href="/scripts/add?hub=hub%2F19822%2Fwindmill%2Fdynamic_skip_template{wsParam}"
disabled={!can_write}
target="_blank"
>
Create from template
</Button>
{/if}
</div>
</Label>
<Alert type="info" size="xs" title="Handler requirements">
Handler must return a boolean value. Return true to execute the scheduled job, false
to skip.
</Alert>
</div>
</Section>
{:else if optionTabSelected === 'tag'}
<Section
label="Custom script tag"
tooltip="When set, the script tag will be overridden by this tag"
>
<WorkerTagPicker
bind:tag
workspaceId={wsId}
popupPlacement="top-end"
disabled={!can_write}
/>
</Section>
{/if}
{:else}
<Loader2 class="animate-spin" />
{/if}
</div>
{/snippet}
{#snippet drawerBody()}
<DrawerContent
hideClose={inline && !onClose}
@@ -1463,7 +1044,9 @@
? can_write
? `Edit schedule ${initialPath}`
: `View schedule ${initialPath}`
: 'New schedule'}
: saveDraftHandler
? 'Draft schedule'
: 'New schedule'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -21,6 +21,21 @@
drawer?.openNew(is_flow, initial_script_path, defaultValues)
}
/** Edit a trigger that exists only as the caller's draft; Save hands the config back. */
export async function openDraft(
scriptPath: string,
defaults: Record<string, any>,
saved: Record<string, any> | undefined,
onSaveDraft: (cfg: Record<string, any>) => void
) {
open = true
await tickPainted()
drawer?.openNew(false, scriptPath, defaults as any, {
onSaveDraft,
draftConfig: saved
})
}
let drawer: SqsTriggerEditorInner | undefined = $state()
</script>
@@ -152,12 +152,17 @@
is_flow = itemKind === 'flow'
})
// Set by `openNew(…, { onSaveDraft })`: the caller keeps the trigger as its own
// draft, so Save hands it the config instead of writing it.
let saveDraftHandler: ((cfg: Record<string, any>) => void) | undefined = $state(undefined)
export async function openEdit(
ePath: string,
isFlow: boolean,
defaultConfig?: Record<string, any>,
fixedScriptPath_?: string
) {
saveDraftHandler = undefined
// A `whoami` that failed earlier would otherwise pin this workspace to "unknown user".
operatingUser.forgetFailures()
let loadingTimeout = setTimeout(() => {
@@ -199,8 +204,14 @@
export async function openNew(
nis_flow: boolean,
fixedScriptPath_?: string,
defaultValues?: Record<string, any>
defaultValues?: Record<string, any>,
opts: {
onSaveDraft?: (cfg: Record<string, any>) => void
/** A config this editor saved before, re-applied over the defaults. */
draftConfig?: Record<string, any>
} = {}
) {
saveDraftHandler = opts.onSaveDraft
let loadingTimeout = setTimeout(() => {
showLoading = true
}, 100)
@@ -229,6 +240,7 @@
selectedPermissionedAs = undefined
preservePermissionedAs = false
originalConfig = undefined
if (opts.draftConfig) await loadTriggerConfig(opts.draftConfig)
} finally {
initialConfig = structuredClone($state.snapshot(getSaveCfg()))
clearTimeout(loadingTimeout)
@@ -336,6 +348,11 @@
}
async function updateTrigger(): Promise<void> {
if (saveDraftHandler) {
saveDraftHandler($state.snapshot(getSaveCfg()))
drawer?.closeDrawer()
return
}
deploymentLoading = true
const previousPath = initialPath
const cfg = getSaveCfg()
@@ -406,7 +423,9 @@
? can_write
? `Edit SQS trigger ${initialPath}`
: `SQS trigger ${initialPath}`
: 'New SQS trigger'}
: saveDraftHandler
? 'Draft SQS trigger'
: 'New SQS trigger'}
on:close={() => (inline ? onClose?.() : drawer?.closeDrawer())}
>
{#snippet actions()}
@@ -463,6 +482,7 @@
isLoading={deploymentLoading}
{isDeployed}
onUpdate={updateTrigger}
saveLabel={saveDraftHandler ? 'Save draft' : undefined}
{onReset}
{onDelete}
{mode}
@@ -3,8 +3,8 @@
import PipelineFolderList from '$lib/components/assets/AssetGraph/PipelineFolderList.svelte'
import PipelineSetupSignpost from '$lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte'
import PipelineAlphaAckModal from '$lib/components/assets/AssetGraph/PipelineAlphaAckModal.svelte'
import PipelineDbtSignpost from '$lib/components/assets/AssetGraph/PipelineDbtSignpost.svelte'
import { BookOpen, NetworkIcon } from 'lucide-svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { onMount } from 'svelte'
const ACK_STORAGE_KEY = 'pipeline-alpha-ack'
@@ -41,34 +41,24 @@
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center min-h-10 shrink-0 whitespace-nowrap"
>
<div class="flex flex-row items-center gap-2">
<NetworkIcon size={16} class="text-tertiary shrink-0" />
<h1 class="text-sm font-semibold">Pipelines</h1>
<span
class="text-2xs px-1.5 py-0.5 rounded font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
Alpha
</span>
<NetworkIcon size={16} class="text-secondary shrink-0" />
<h1 class="text-sm font-semibold text-emphasis">Pipelines</h1>
<Badge color="green">Alpha</Badge>
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto bg-surface-secondary">
<div class="flex-1 min-h-0 overflow-y-auto bg-surface">
<div class="max-w-2xl mx-auto flex flex-col gap-6 px-4 py-8">
<div class="flex flex-col gap-2">
<h2 class="text-lg font-semibold">Data pipelines</h2>
<p class="text-sm text-tertiary">
<h2 class="text-lg font-semibold text-emphasis">Data pipelines</h2>
<p class="text-xs text-secondary">
Chain ingestion, transformation and materialization steps into an asset-aware graph.
{#if !$userStore?.operator}
Each pipeline lives in a folder: open one below, or pick a folder to start a new
pipeline.
{:else}
Open a pipeline below to view its graph and runs.
{/if}
</p>
<a
href="https://www.windmill.dev/docs/pipelines"
target="_blank"
rel="noreferrer"
class="text-xs text-blue-500 hover:underline inline-flex items-center gap-1"
class="text-xs text-accent hover:underline inline-flex items-center gap-1 w-fit"
>
<BookOpen size={12} />
Pipelines documentation
@@ -79,14 +69,7 @@
<PipelineSetupSignpost workspace={$workspaceStore} />
{/if}
<PipelineFolderList />
<!-- Below the pipeline content on purpose: dbt is a separate runtime, not a
way of building a pipeline, and reads as one when it sits in the flow
that defines what a pipeline is. -->
{#if !$userStore?.operator}
<PipelineDbtSignpost />
{/if}
<PipelineFolderList hideExisting />
</div>
</div>
</div>
@@ -27,7 +27,9 @@
AssetGraphResponse,
AssetGraphSelection,
NativeTriggerKind,
PipelineMode
PipelineMode,
PipelineTriggerDraft,
PipelineTriggerDraftKind
} from '$lib/components/assets/AssetGraph/types'
import PipelineModeToggle from '$lib/components/assets/AssetGraph/PipelineModeToggle.svelte'
import MacroExplorerDrawer from '$lib/components/assets/AssetGraph/MacroExplorerDrawer.svelte'
@@ -79,7 +81,17 @@
createPipelineAiHelpers,
type PipelineDraft
} from '$lib/components/assets/AssetGraph/pipelineAiHelpers'
import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte'
import {
PipelineEditorState
} from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte'
import {
defaultTriggerPath,
isDraftableTriggerKind,
triggerDraftKey
} from '$lib/components/assets/AssetGraph/pipelineTriggerDrafts'
import { deployTriggerDraft } from '$lib/components/assets/AssetGraph/pipelineTriggerDraftDeploy'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { PipelineInsertOptions } from '$lib/components/assets/AssetGraph/PipelineInsertMenu.svelte'
import {
createPipelineRecording,
finalizePipelineRecording
@@ -288,6 +300,8 @@
// appear in the current `g.runnables`. That self-cleaning property is
// the whole reason for the refactor — no rename/delete cleanup needed.
let bodiesByPath = $state<Map<string, string>>(new Map())
// Deployed summaries from the same fetch, for node titles. Same only-add cache.
let summariesByPath = $state<Map<string, string>>(new Map())
// Sibling cache: the parsed asset usages from `inferAssets` (wasm), one
// pass per body. Same only-add semantics as `bodiesByPath`.
let inferredAssetsByPath = $state<Map<string, AssetWithAltAccessType[]>>(new Map())
@@ -412,9 +426,10 @@
triggers: DraftTriggerSource[],
outputKind: PipelineOutputKind,
input?: { kind: AssetKind; path: string },
aiPrompt?: string
aiPrompt?: string,
options?: PipelineInsertOptions
) {
const out = autoOutputAsset(outputKind, folder, language)
const out = options?.outputAsset ?? autoOutputAsset(outputKind, folder, language)
const script = buildDraft(language, scriptPath, triggers, outputKind, out, input)
// Write the new draft into the map (structural update so Svelte
// re-derives graphWithDraft) and focus it in the details pane. When
@@ -429,6 +444,16 @@
pe.drafts = next
pe.activeDraftPath = scriptPath
pe.selection = undefined
if (options?.schedule) {
pe.setTriggerDraft({
kind: 'schedule',
config: {
...options.schedule,
path: options.schedule.path || defaultTriggerPath(scriptPath, 'schedule'),
script_path: scriptPath
}
})
}
// Follow the new node with a smooth pan. The id matches the runnable
// node the canvas builds for a draft script (`script:<path>`).
@@ -528,7 +553,7 @@
bypassNavigationGuard = false
return
}
if (pe.drafts.size === 0) return
if (pendingCount === 0) return
// `leave` covers tab close / hard reload / cross-origin nav. SvelteKit
// turns a cancelled leave into a browser-native "Leave site?" prompt,
// which we explicitly don't want — match the rest of the editors and
@@ -563,6 +588,7 @@
// stale active path don't bleed into the next page. localStorage
// is overwritten by the persist effect on the next tick.
pe.drafts = new Map()
pe.triggerDrafts = new Map()
pe.activeDraftPath = undefined
saveErrors = new Map()
const target = pendingNavigationUrl
@@ -582,7 +608,7 @@
// page so they can deal with the failures via the bar's error
// popover. Otherwise resume the navigation that triggered the
// guard.
if (pe.drafts.size === 0) {
if (pendingCount === 0) {
const target = pendingNavigationUrl
leaveModalOpen = false
pendingNavigationUrl = undefined
@@ -670,8 +696,39 @@
})
}
// Scripts plus trigger drafts: what "Save all" deploys.
let pendingCount = $derived(pe.drafts.size + pe.triggerDrafts.size)
let confirmTriggerDeployOpen = $state(false)
function requestSaveAll() {
if (pe.triggerDrafts.size > 0) confirmTriggerDeployOpen = true
else void saveAllDrafts()
}
// Runs after the script deploys: a trigger's `script_path` must already
// exist. Triggers whose script is still an undeployed draft stay drafts.
async function deployTriggerDrafts(
ws: string,
savedScriptPaths: string[],
errors: Map<string, string>
): Promise<string[]> {
const ready = [...pe.triggerDrafts].filter(
([, d]) =>
!pe.drafts.has(d.config.script_path) || savedScriptPaths.includes(d.config.script_path)
)
const results = await Promise.all(ready.map(([, d]) => deployTriggerDraft(d, ws)))
const saved: string[] = []
results.forEach((ok, i) => {
const [key, d] = ready[i]
if (ok) saved.push(key)
else errors.set(d.config.path, `Could not create the ${d.kind} trigger, see the notification`)
})
for (const key of saved) pe.discardTriggerDraft(key)
return saved
}
async function saveAllDrafts() {
if (!$workspaceStore || pe.drafts.size === 0 || savingAll) return
if (!$workspaceStore || pendingCount === 0 || savingAll) return
savingAll = true
const ws = $workspaceStore
// The open pane's keystrokes live in `pe.liveContent` until the pane is
@@ -715,6 +772,7 @@
errors.set(path, e?.body ?? e?.message ?? String(e))
}
}
const savedTriggers = await deployTriggerDrafts(ws, savedPaths, errors)
// Drop the saved drafts from the map; failed ones stay so the user
// can fix them and retry. Build the new map from the still-failing
// entries to keep insertion order stable.
@@ -737,6 +795,8 @@
}
pe.activeDraftPath = undefined
}
}
if (savedPaths.length > 0 || savedTriggers.length > 0) {
await graphRes.refetch()
// Verify only what actually deployed — failed drafts would
// otherwise false-positive as "missing" rows.
@@ -744,10 +804,11 @@
}
saveErrors = errors
savingAll = false
if (savedPaths.length > 0 && errors.size === 0) {
sendUserToast(`Saved ${savedPaths.length} draft${savedPaths.length === 1 ? '' : 's'}`)
} else if (savedPaths.length > 0 && errors.size > 0) {
sendUserToast(`Saved ${savedPaths.length}, ${errors.size} failed — see details`, true)
const savedCount = savedPaths.length + savedTriggers.length
if (savedCount > 0 && errors.size === 0) {
sendUserToast(`Saved ${savedCount} draft${savedCount === 1 ? '' : 's'}`)
} else if (savedCount > 0 && errors.size > 0) {
sendUserToast(`Saved ${savedCount}, ${errors.size} failed — see details`, true)
} else if (errors.size > 0) {
sendUserToast(`${errors.size} draft${errors.size === 1 ? '' : 's'} failed to save`, true)
}
@@ -759,6 +820,7 @@
next.delete(path)
pe.drafts = next
forgetPath(path)
pe.discardTriggerDraftsFor(path)
}
function clearSaveError(path: string) {
@@ -819,6 +881,7 @@
}
}
pe.drafts = next
pe.retargetTriggerDrafts(oldPath, newPath)
if (pe.activeDraftPath === oldPath) pe.activeDraftPath = newPath
// Path-keyed live overlays: re-key for the renamed draft so the
// graph stays consistent between the moment we mutate `drafts`
@@ -1050,7 +1113,8 @@
language: ScriptLang,
scriptPath: string,
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: PipelineInsertOptions
) {
const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
openMaterializerDraft(
@@ -1062,7 +1126,8 @@
kind: asset.kind,
path: asset.path
},
aiPrompt
aiPrompt,
options
)
}
function handleAddPipelineScript(
@@ -1070,9 +1135,15 @@
scriptPath: string,
source: { kind: NativeTriggerKind; path: string | undefined },
outputKind: PipelineOutputKind,
aiPrompt?: string
aiPrompt?: string,
options?: PipelineInsertOptions
) {
openMaterializerDraft(language, scriptPath, [source], outputKind, undefined, aiPrompt)
openMaterializerDraft(language, scriptPath, [source], outputKind, undefined, aiPrompt, options)
// A schedule was configured by the wizard itself; other row-backed kinds
// are configured in their own editor, as a draft of the new script.
if (source.kind !== 'schedule' && isDraftableTriggerKind(source.kind)) {
openTriggerDraft(source.kind, scriptPath)
}
}
function handleRunnableMenuRemove(info: {
runnable_kind: 'script' | 'flow'
@@ -1227,7 +1298,8 @@
liveAnnotations: pe.liveAnnotations,
inferredWritesByPath,
inferredReadsByPath,
annotatedNativeKindsByPath
annotatedNativeKindsByPath,
triggerDrafts: pe.triggerDrafts.values()
})
)
@@ -1250,7 +1322,21 @@
// What the canvas renders: drafts merged in edit, deployed-only in view —
// unless the "show drafts" chip is on, which overlays the drafts onto the
// view (what View will show once they're deployed).
let displayGraph = $derived(mode === 'edit' || includeDrafts ? graphWithDraft : deployedGraph)
let displayGraph = $derived(
withSummaries(mode === 'edit' || includeDrafts ? graphWithDraft : deployedGraph)
)
function withSummaries(g: AssetGraphResponse): AssetGraphResponse {
const showDrafts = mode === 'edit' || includeDrafts
return {
...g,
runnables: g.runnables.map((r) => {
const summary =
(showDrafts ? pe.drafts.get(r.path)?.script.summary : undefined) ||
summariesByPath.get(r.path)
return summary ? { ...r, summary } : r
})
}
}
// Leaving edit mode: drop the live editor overlays — they substitute the
// last keystroke buffer for one script inside inferredWritesByPath /
@@ -2168,6 +2254,12 @@
// time or silently bind to nothing. Surface that as a toast and
// keep the drawer closed; the user needs to save the script first
// (which also creates it under the new path if they renamed it).
// Kinds with a trigger row can instead wait as a pipeline draft and
// deploy with the pipeline, after the script.
if (isDraftableTriggerKind(kind) && pe.drafts.has(scriptPath)) {
openTriggerDraft(kind, scriptPath)
return
}
if (pe.drafts.has(scriptPath)) {
sendUserToast(
`Save the script "${scriptPath}" first — triggers can only be attached to deployed scripts.`,
@@ -2183,13 +2275,40 @@
// can still be edited everywhere else (TriggersPanel, etc.) where the
// picker stays editable.
function deleteAttachedTrigger(kind: NativeTriggerKind, triggerPath: string) {
const key = triggerDraftKey(kind, triggerPath)
if (pe.triggerDrafts.has(key)) {
pe.discardTriggerDraft(key)
return
}
triggerEditors?.requestDelete(kind, triggerPath)
}
function openEditTriggerDrawer(kind: NativeTriggerKind, triggerPath: string, scriptPath: string) {
const draft = pe.triggerDrafts.get(triggerDraftKey(kind, triggerPath))
if (draft) {
openTriggerDraft(draft.kind, scriptPath, draft)
return
}
triggerEditors?.openEdit(kind, triggerPath, scriptPath)
}
/** The kind's editor over a new draft, or over `draft` when re-editing one. */
function openTriggerDraft(
kind: PipelineTriggerDraftKind,
scriptPath: string,
draft?: PipelineTriggerDraft
) {
const defaults = { path: defaultTriggerPath(scriptPath, kind) }
triggerEditors?.openTriggerDraft(kind, scriptPath, defaults, draft?.config, (cfg) => {
const { extra_perms: _, ...config } = cfg
pe.setTriggerDraft(
{ kind, config: { ...config, path: cfg.path, script_path: scriptPath, is_flow: false } },
// The editor's Path field can rename it; drafts are keyed by path.
draft ? triggerDraftKey(kind, draft.config.path) : undefined
)
})
}
// Reuse the empty AssetGraphResponse so we can still render the canvas
// (layout, controls, mini-map) on a fresh pipeline.
const EMPTY_GRAPH: AssetGraphResponse = {
@@ -2328,6 +2447,9 @@
nextBodies.set(path, content)
bodiesByPath = nextBodies
}
if (s.summary && !summariesByPath.has(path)) {
summariesByPath = new Map(summariesByPath).set(path, s.summary)
}
if (!inferredAssetsByPath.has(path)) {
const nextAssets = new Map(inferredAssetsByPath)
nextAssets.set(path, inferred)
@@ -2436,8 +2558,8 @@
anchored between the two flex-1 side groups so it stays
centered, with breathing room on both sides. -->
<div class="flex flex-row items-center gap-2 shrink-0 px-6">
<PipelineModeToggle {mode} draftCount={pe.drafts.size} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pe.drafts.size > 0}
<PipelineModeToggle {mode} draftCount={pendingCount} onModeChange={(m) => setMode(m)} />
{#if mode === 'view' && pendingCount > 0}
<!-- View variant: overlay the unsaved drafts onto the deployed
graph — "what View will show once they're deployed". -->
<button
@@ -2455,7 +2577,7 @@
>
<Telescope size={14} />
{includeDrafts ? 'Showing' : 'Show'}
{pe.drafts.size} draft{pe.drafts.size === 1 ? '' : 's'}
{pendingCount} draft{pendingCount === 1 ? '' : 's'}
</button>
{/if}
</div>
@@ -2533,7 +2655,7 @@
{/snippet}
</Popover>
{/if}
{#if mode === 'edit' && pe.drafts.size > 0}
{#if mode === 'edit' && pendingCount > 0}
<!-- Draft autosave status for the whole pipeline bundle. Distinct
from "Save all", which DEPLOYS the drafts — this only reflects
that in-flight edits are persisted to the per-user server draft. -->
@@ -2550,11 +2672,11 @@
variant="accent"
unifiedSize="sm"
startIcon={{ icon: savingAll ? Loader2 : Save }}
onclick={saveAllDrafts}
onclick={requestSaveAll}
disabled={savingAll}
title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`}
title={savingAll ? 'Saving drafts…' : `Deploy all ${pendingCount} drafts`}
>
{savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`}
{savingAll ? 'Saving…' : `Save all (${pendingCount})`}
</Button>
{/if}
{#if mode === 'view'}
@@ -2759,6 +2881,38 @@
<!-- Native trigger drawer wiring: create/edit/delete drawers (edit-mode
only) + the always-mounted webhook drawer. Driven imperatively from the
page via `triggerEditors`. -->
<ConfirmationModal
open={confirmTriggerDeployOpen}
title="Deploy triggers?"
confirmationText="Deploy"
type="info"
onConfirmed={() => {
confirmTriggerDeployOpen = false
void saveAllDrafts()
}}
onCanceled={() => (confirmTriggerDeployOpen = false)}
>
<div class="flex flex-col gap-2 text-xs text-secondary">
<p>
{pe.triggerDrafts.size === 1
? 'Saving the pipeline also creates this trigger. It starts running its script once deployed.'
: `Saving the pipeline also creates these ${pe.triggerDrafts.size} triggers. They start running their scripts once deployed.`}
</p>
<ul class="flex flex-col gap-1">
{#each [...pe.triggerDrafts] as [key, d] (key)}
<li class="flex flex-col">
<span class="font-mono text-2xs text-emphasis truncate">{d.config.path}</span>
<span class="text-2xs text-hint truncate">
{d.kind}{#if d.kind === 'schedule'}
· <span class="font-mono">{d.config.schedule}</span> ({d.config.timezone}){/if}
→ {d.config.script_path}
</span>
</li>
{/each}
</ul>
</div>
</ConfirmationModal>
<PipelineTriggerEditors
bind:this={triggerEditors}
mountTriggerEditors={mode === 'edit'}
@@ -2790,18 +2944,18 @@
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-medium text-primary">
{pe.drafts.size === 1 ? 'Unsaved draft' : `${pe.drafts.size} unsaved drafts`}
{pendingCount === 1 ? 'Unsaved draft' : `${pendingCount} unsaved drafts`}
</h3>
<div class="mt-2 text-sm text-secondary flex flex-col gap-2">
<p>
You have {pe.drafts.size === 1
? 'a draft pipeline script'
: `${pe.drafts.size} draft pipeline scripts`} that {pe.drafts.size === 1
You have {pendingCount === 1
? 'a pipeline draft'
: `${pendingCount} pipeline drafts`} that {pendingCount === 1
? 'has'
: 'have'} not been deployed yet. What would you like to do?
</p>
<ul class="text-2xs font-mono pl-4 max-h-40 overflow-y-auto flex flex-col gap-0.5">
{#each [...pe.drafts.keys()] as p}
{#each [...pe.drafts.keys(), ...[...pe.triggerDrafts.values()].map((d) => d.config.path)] as p}
<li class="truncate text-tertiary">{p}</li>
{/each}
</ul>
@@ -2817,7 +2971,7 @@
startIcon={{ icon: leaveSaving ? Loader2 : Save }}
>
<span class="min-w-20"
>{leaveSaving ? 'Saving…' : `Save all (${pe.drafts.size})`}</span
>{leaveSaving ? 'Saving…' : `Save all (${pendingCount})`}</span
>
</Button>
<Button