mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
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:
co-authored by
Claude Opus 5.5
parent
cf5c49c3dc
commit
f138e53b80
@@ -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}>>=</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}>>=</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
|
||||
|
||||
Reference in New Issue
Block a user