mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
all
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$app/navigation'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { Code2 } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
folder: string
|
||||
// When present, an `// on <asset>` trigger line is prefilled below the
|
||||
// `// materialize` marker. Used by the per-asset + on an asset node so
|
||||
// the new script is wired to refresh when that asset changes.
|
||||
triggerAsset?: { prefix: string; path: string } | undefined
|
||||
onOpenChange: (v: boolean) => void
|
||||
}
|
||||
let { open = $bindable(), folder, triggerAsset = undefined, onOpenChange }: Props = $props()
|
||||
|
||||
const LANGUAGES: Array<{ label: string; value: ScriptLang }> = [
|
||||
{ label: 'TypeScript (Bun)', value: 'bun' },
|
||||
{ label: 'TypeScript (Deno)', value: 'deno' },
|
||||
{ label: 'Python', value: 'python3' },
|
||||
{ label: 'PostgreSQL', value: 'postgresql' },
|
||||
{ label: 'DuckDB', value: 'duckdb' },
|
||||
{ label: 'BigQuery', value: 'bigquery' },
|
||||
{ label: 'Snowflake', value: 'snowflake' },
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'MS SQL', value: 'mssql' },
|
||||
{ label: 'Bash', value: 'bash' },
|
||||
{ label: 'Go', value: 'go' }
|
||||
]
|
||||
|
||||
let scriptPath = $state('')
|
||||
let language = $state<ScriptLang>('bun')
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
scriptPath = `f/${folder}/new_materializer`
|
||||
language = 'bun'
|
||||
}
|
||||
})
|
||||
|
||||
let canConfirm = $derived(
|
||||
scriptPath.trim().startsWith(`f/${folder}/`) && scriptPath.trim().length > `f/${folder}/`.length
|
||||
)
|
||||
|
||||
function confirm() {
|
||||
if (!canConfirm) return
|
||||
const params = new URLSearchParams({
|
||||
path: scriptPath.trim(),
|
||||
lang: language,
|
||||
materialize: '1'
|
||||
})
|
||||
if (triggerAsset) {
|
||||
params.set('on_asset', `${triggerAsset.prefix}${triggerAsset.path}`)
|
||||
}
|
||||
onOpenChange(false)
|
||||
goto(`${base}/scripts/add?${params}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={triggerAsset ? 'Add script triggered by asset' : 'Add materializer script'}>
|
||||
<div class="flex flex-col gap-4 w-full min-w-0">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-secondary">Path</span>
|
||||
<TextInput bind:value={scriptPath} placeholder="f/{folder}/new_materializer" />
|
||||
<span class="text-2xs text-tertiary">
|
||||
Must live in <code>f/{folder}/</code>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-secondary">Language</span>
|
||||
<Select items={LANGUAGES} bind:value={language} />
|
||||
</label>
|
||||
|
||||
{#if triggerAsset}
|
||||
<div class="rounded-md bg-surface-secondary border px-3 py-2 text-xs text-secondary">
|
||||
The new script will include <code>// on {triggerAsset.prefix}{triggerAsset.path}</code> so it
|
||||
refreshes when this asset changes.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md bg-surface-secondary border px-3 py-2 text-xs text-secondary">
|
||||
The new script will include a bare <code>// materialize</code> marker so it's counted as a pipeline
|
||||
member. Any writes the parser detects become its outputs.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet actions()}
|
||||
<Button variant="subtle" unifiedSize="sm" onclick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!canConfirm}
|
||||
onclick={confirm}
|
||||
startIcon={{ icon: Code2 }}
|
||||
>
|
||||
Create script
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import PipelineInsertMenu, { type PipelineInsertPick } from './PipelineInsertMenu.svelte'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Webhook,
|
||||
Mail,
|
||||
Zap,
|
||||
Radio,
|
||||
MessageSquare,
|
||||
Database,
|
||||
Send,
|
||||
CloudCog
|
||||
} from 'lucide-svelte'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
|
||||
// Each left-column kind is just "materializer triggered by <trigger
|
||||
// source>". id === the SCRIPT_TRIGGER_KIND value, so the handler can
|
||||
// dispatch on it uniformly. Asset-triggered materializers are not in
|
||||
// this menu; those live under the per-asset + inside the graph.
|
||||
type KindId = 'schedule' | NativeTriggerKind
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
onAddMaterializer: (
|
||||
language: ScriptLang,
|
||||
path: string,
|
||||
source:
|
||||
| { kind: 'schedule'; cron: string }
|
||||
| { kind: NativeTriggerKind; path: string | undefined }
|
||||
) => void
|
||||
pathPrefix: string
|
||||
defaultPathSuffix: string
|
||||
defaultScheduleCron: string
|
||||
}
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
const LANGUAGES: Array<{ label: string; lang: ScriptLang }> = [
|
||||
{ label: 'TypeScript (Bun)', lang: 'bun' },
|
||||
{ label: 'TypeScript (Deno)', lang: 'deno' },
|
||||
{ label: 'Python', lang: 'python3' },
|
||||
{ label: 'PostgreSQL', lang: 'postgresql' },
|
||||
{ label: 'DuckDB', lang: 'duckdb' },
|
||||
{ label: 'BigQuery', lang: 'bigquery' },
|
||||
{ label: 'Snowflake', lang: 'snowflake' },
|
||||
{ label: 'MySQL', lang: 'mysql' },
|
||||
{ label: 'MS SQL', lang: 'mssql' },
|
||||
{ label: 'Bash', lang: 'bash' },
|
||||
{ label: 'Go', lang: 'go' }
|
||||
]
|
||||
|
||||
function handlePick(pick: PipelineInsertPick) {
|
||||
if (!pick.language || !pick.path) return
|
||||
const kindId = pick.kindId as KindId
|
||||
if (kindId === 'schedule') {
|
||||
data.onAddMaterializer(pick.language as ScriptLang, pick.path, {
|
||||
kind: 'schedule',
|
||||
cron: data.defaultScheduleCron
|
||||
})
|
||||
} else {
|
||||
// Native trigger reference: user is expected to fill in the
|
||||
// trigger path themselves in the editor (or configure it in the
|
||||
// trigger's own UI). We seed the annotation with an empty ref
|
||||
// the user replaces.
|
||||
data.onAddMaterializer(pick.language as ScriptLang, pick.path, {
|
||||
kind: kindId,
|
||||
path: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PipelineInsertMenu
|
||||
kinds={[
|
||||
{
|
||||
id: 'schedule',
|
||||
label: 'On schedule',
|
||||
description: 'Cron-driven materializer',
|
||||
icon: Clock,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'webhook',
|
||||
label: 'On webhook',
|
||||
description: 'Triggered by an HTTP webhook',
|
||||
icon: Webhook,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'On email',
|
||||
description: 'Triggered by incoming email',
|
||||
icon: Mail,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'kafka',
|
||||
label: 'On Kafka',
|
||||
description: 'Triggered by a Kafka message',
|
||||
icon: Zap,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
label: 'On MQTT',
|
||||
description: 'Triggered by an MQTT message',
|
||||
icon: Radio,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'nats',
|
||||
label: 'On NATS',
|
||||
description: 'Triggered by a NATS message',
|
||||
icon: MessageSquare,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'postgres',
|
||||
label: 'On Postgres',
|
||||
description: 'Triggered by a Postgres event',
|
||||
icon: Database,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'sqs',
|
||||
label: 'On SQS',
|
||||
description: 'Triggered by an SQS message',
|
||||
icon: Send,
|
||||
pickLanguage: true
|
||||
},
|
||||
{
|
||||
id: 'gcp',
|
||||
label: 'On GCP Pub/Sub',
|
||||
description: 'Triggered by a Pub/Sub message',
|
||||
icon: CloudCog,
|
||||
pickLanguage: true
|
||||
}
|
||||
]}
|
||||
languages={LANGUAGES as any}
|
||||
pathPrefix={data.pathPrefix}
|
||||
defaultPathSuffix={data.defaultPathSuffix}
|
||||
onPick={handlePick}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center bg-emerald-500 hover:bg-emerald-600 text-white shadow-md transition-colors cursor-pointer"
|
||||
title="Add to pipeline"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</PipelineInsertMenu>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script lang="ts" module>
|
||||
import type { ComponentType } from 'svelte'
|
||||
import type { SupportedLanguage } from '$lib/common'
|
||||
|
||||
export type PipelineInsertKind = {
|
||||
// Machine-readable id; drives which right-column panel renders.
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: ComponentType
|
||||
// When pickLanguage is true, the right panel shows the language
|
||||
// picker (and after a language is chosen, a path-entry stage).
|
||||
// Otherwise onSelect is called directly with no language / path.
|
||||
pickLanguage?: boolean
|
||||
}
|
||||
|
||||
export type PipelineInsertPick = {
|
||||
kindId: string
|
||||
language?: SupportedLanguage
|
||||
path?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import LanguageIcon from '$lib/components/common/languageIcons/LanguageIcon.svelte'
|
||||
import { ArrowLeft, ChevronRight } from 'lucide-svelte'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
kinds: PipelineInsertKind[]
|
||||
languages?: Array<{ label: string; lang: SupportedLanguage }>
|
||||
// Non-editable path prefix shown as a read-only chip before the
|
||||
// user-editable suffix (e.g. `f/<folder>/`). The final path passed
|
||||
// in onPick is `pathPrefix + suffix`.
|
||||
pathPrefix?: string
|
||||
// Default suffix seeded into the editable input when the user
|
||||
// reaches the path stage (e.g. `new_materializer`).
|
||||
defaultPathSuffix?: string
|
||||
onPick: (pick: PipelineInsertPick) => void
|
||||
trigger: import('svelte').Snippet
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||
}
|
||||
|
||||
let {
|
||||
kinds,
|
||||
languages = [],
|
||||
pathPrefix = '',
|
||||
defaultPathSuffix = '',
|
||||
onPick,
|
||||
trigger: triggerSnippet,
|
||||
placement = 'bottom'
|
||||
}: Props = $props()
|
||||
|
||||
// Flow stages: kind → lang → path → confirm. `stage` drives the right
|
||||
// column. Only the `language` kinds reach the lang/path stages.
|
||||
// Default-select the first kind, and if it needs a language jump
|
||||
// straight into the lang stage so the user doesn't have to click the
|
||||
// left column to see the language picker.
|
||||
let selectedKindId = $state<string>(kinds[0]?.id ?? '')
|
||||
let selectedKind = $derived(kinds.find((k) => k.id === selectedKindId) ?? kinds[0])
|
||||
let stage = $state<'lang' | 'path' | 'description'>(
|
||||
kinds[0]?.pickLanguage ? 'lang' : 'description'
|
||||
)
|
||||
let selectedLanguage = $state<SupportedLanguage | undefined>(undefined)
|
||||
let pathSuffix = $state('')
|
||||
let pathInput: HTMLInputElement | undefined = $state(undefined)
|
||||
|
||||
// Popover closing doesn't unmount its content, so state persists across
|
||||
// opens. Reset everything back to initial state on close so the next
|
||||
// open starts fresh — otherwise reopening lands on the previous path
|
||||
// stage with the previous suffix, and a subsequent Enter creates a
|
||||
// duplicate of the first draft.
|
||||
function resetMenuState() {
|
||||
selectedKindId = kinds[0]?.id ?? ''
|
||||
stage = kinds[0]?.pickLanguage ? 'lang' : 'description'
|
||||
selectedLanguage = undefined
|
||||
pathSuffix = ''
|
||||
}
|
||||
|
||||
function handleKindClick(k: PipelineInsertKind, close: () => void) {
|
||||
if (!k.pickLanguage) {
|
||||
onPick({ kindId: k.id })
|
||||
close()
|
||||
return
|
||||
}
|
||||
selectedKindId = k.id
|
||||
stage = 'lang'
|
||||
selectedLanguage = undefined
|
||||
pathSuffix = ''
|
||||
}
|
||||
|
||||
// Short random slug appended to the default suffix so that opening the
|
||||
// menu twice in a row seeds two distinct paths — otherwise creating
|
||||
// multiple materializers in the same folder collides on the same
|
||||
// `f/<folder>/<suffix>` and they all become revisions of one script.
|
||||
function shortSlug(len = 4): string {
|
||||
const a = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let out = ''
|
||||
for (let i = 0; i < len; i++) out += a[Math.floor(Math.random() * a.length)]
|
||||
return out
|
||||
}
|
||||
|
||||
async function handleLanguageClick(lang: SupportedLanguage) {
|
||||
selectedLanguage = lang
|
||||
const base = defaultPathSuffix || 'materializer'
|
||||
pathSuffix = `${base}_${shortSlug()}`
|
||||
stage = 'path'
|
||||
// Focus the suffix input so the user can just start typing a name.
|
||||
await tick()
|
||||
pathInput?.focus()
|
||||
pathInput?.select()
|
||||
}
|
||||
|
||||
function confirmPath(close: () => void) {
|
||||
const suffix = pathSuffix.trim()
|
||||
if (!suffix || !selectedLanguage) return
|
||||
onPick({
|
||||
kindId: selectedKindId,
|
||||
language: selectedLanguage,
|
||||
path: pathPrefix + suffix
|
||||
})
|
||||
close()
|
||||
}
|
||||
|
||||
function handlePathKeydown(e: KeyboardEvent, close: () => void) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmPath(close)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
stage = 'lang'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover
|
||||
contentClasses="p-0 bg-surface overflow-hidden"
|
||||
class="inline-block"
|
||||
usePointerDownOutside
|
||||
floatingConfig={{
|
||||
placement,
|
||||
strategy: 'absolute',
|
||||
gutter: 8,
|
||||
overflowPadding: 16,
|
||||
flip: true,
|
||||
fitViewport: true,
|
||||
overlap: false
|
||||
}}
|
||||
on:openChange={(e) => {
|
||||
if (!e.detail) resetMenuState()
|
||||
}}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{@render triggerSnippet?.()}
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{@const singleKind = kinds.length === 1}
|
||||
<div
|
||||
class={singleKind
|
||||
? 'flex flex-row bg-surface-tertiary w-[360px] h-[280px]'
|
||||
: 'flex flex-row divide-x bg-surface-tertiary w-[560px] h-[280px]'}
|
||||
>
|
||||
<!-- Left column: kind picker. Only shown when there's more than one
|
||||
option — single-kind menus jump straight to language/path. -->
|
||||
{#if !singleKind}
|
||||
<div class="flex flex-col gap-1 p-2 w-52 shrink-0 overflow-auto">
|
||||
{#each kinds as k}
|
||||
{@const isSelected = selectedKindId === k.id}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleKindClick(k, close)}
|
||||
class={[
|
||||
'flex items-start gap-2 px-2 py-2 rounded-md text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-surface-selected text-emphasis'
|
||||
: 'hover:bg-surface-hover text-primary'
|
||||
].join(' ')}
|
||||
>
|
||||
{#if k.icon}
|
||||
{@const Icon = k.icon}
|
||||
<Icon size={14} class="shrink-0 mt-0.5 text-secondary" />
|
||||
{/if}
|
||||
<span class="flex flex-col items-start flex-1 min-w-0">
|
||||
<span class="text-sm font-medium leading-tight">{k.label}</span>
|
||||
{#if k.description}
|
||||
<span class="text-2xs text-tertiary font-normal leading-snug mt-0.5">
|
||||
{k.description}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if k.pickLanguage}
|
||||
<ChevronRight size={12} class="shrink-0 mt-1 text-secondary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Right column: stage-driven -->
|
||||
<div class="flex flex-col grow min-w-0 overflow-hidden">
|
||||
{#if stage === 'lang'}
|
||||
<div class="flex flex-col gap-1 p-2 grow overflow-auto">
|
||||
<div class="text-2xs font-normal text-secondary ml-2 mb-1">Language</div>
|
||||
{#each languages as l}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start"
|
||||
onClick={() => handleLanguageClick(l.lang)}
|
||||
>
|
||||
<LanguageIcon lang={l.lang} width={14} height={14} />
|
||||
<span class="grow truncate text-left text-sm">{l.label}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stage === 'path' && selectedLanguage}
|
||||
<div class="flex flex-col gap-3 p-3 grow overflow-auto">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
title="Back to language"
|
||||
onClick={() => (stage = 'lang')}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<LanguageIcon lang={selectedLanguage} width={13} height={13} />
|
||||
<span class="text-xs font-medium">{selectedLanguage}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-2xs font-normal text-secondary">Path</span>
|
||||
<!-- Prefix is a separate non-editable <span> sitting next to
|
||||
the <input>, so users physically can't delete it with
|
||||
backspace. The final path is pathPrefix + pathSuffix. -->
|
||||
<div
|
||||
class="flex items-stretch border rounded-md bg-surface overflow-hidden focus-within:ring-2 focus-within:ring-emerald-400"
|
||||
>
|
||||
{#if pathPrefix}
|
||||
<span
|
||||
class="flex items-center px-2 bg-surface-secondary text-tertiary text-sm font-mono border-r select-none"
|
||||
title="Folder-scoped prefix (fixed)"
|
||||
>
|
||||
{pathPrefix}
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={pathInput}
|
||||
bind:value={pathSuffix}
|
||||
onkeydown={(e) => handlePathKeydown(e, close)}
|
||||
class="flex-1 min-w-0 px-2 py-1.5 text-sm font-mono bg-transparent focus:outline-none"
|
||||
placeholder="my_materializer"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-2xs text-tertiary">Press Enter to create</span>
|
||||
</div>
|
||||
<div class="flex justify-end mt-auto">
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!pathSuffix.trim()}
|
||||
onClick={() => confirmPath(close)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if selectedKind?.description}
|
||||
<div class="flex-1 flex items-center justify-center p-4 text-center">
|
||||
<span class="text-xs text-secondary">{selectedKind.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$app/navigation'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import { FolderService, OpenAPI } from '$lib/gen'
|
||||
import { resource } from 'runed'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { ArrowRight, FolderPlus, Loader2 } from 'lucide-svelte'
|
||||
|
||||
interface PipelineFolder {
|
||||
folder: string
|
||||
script_count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
// When provided, the current folder is dropped from the existing list
|
||||
// so users don't "switch" to the folder they're already on.
|
||||
currentFolder?: string | undefined
|
||||
}
|
||||
let { open = $bindable(), currentFolder = undefined }: Props = $props()
|
||||
|
||||
let pipelines = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws, _prev, { signal }) => {
|
||||
if (!ws) return [] as PipelineFolder[]
|
||||
const base_url = OpenAPI.BASE ?? ''
|
||||
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
|
||||
credentials: 'include',
|
||||
signal
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET /assets/pipelines → ${res.status}`)
|
||||
return (await res.json()) as PipelineFolder[]
|
||||
}
|
||||
)
|
||||
|
||||
let allFolders = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws) => {
|
||||
if (!ws) return [] as string[]
|
||||
return await FolderService.listFolderNames({ workspace: ws })
|
||||
}
|
||||
)
|
||||
|
||||
let selectedExistingFolder = $state<string | undefined>(undefined)
|
||||
let newFolderName = $state('')
|
||||
let creatingFolder = $state(false)
|
||||
|
||||
let visiblePipelines = $derived(
|
||||
(pipelines.current ?? []).filter((p) => p.folder !== currentFolder)
|
||||
)
|
||||
|
||||
let foldersWithoutPipeline = $derived.by(() => {
|
||||
const existing = new Set((pipelines.current ?? []).map((p) => p.folder))
|
||||
return (allFolders.current ?? []).filter((f) => !existing.has(f) && f !== currentFolder)
|
||||
})
|
||||
|
||||
async function openExistingPipeline(folder: string) {
|
||||
open = false
|
||||
await goto(`${base}/pipeline/${encodeURIComponent(folder)}`)
|
||||
}
|
||||
|
||||
async function startInExistingFolder() {
|
||||
if (!selectedExistingFolder) return
|
||||
await openExistingPipeline(selectedExistingFolder)
|
||||
}
|
||||
|
||||
async function createFolderAndStart() {
|
||||
const name = newFolderName.trim()
|
||||
if (!name || !$workspaceStore) return
|
||||
creatingFolder = true
|
||||
try {
|
||||
await FolderService.createFolder({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: { name }
|
||||
})
|
||||
sendUserToast(`Created folder f/${name}`)
|
||||
await openExistingPipeline(name)
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to create folder: ${e?.body ?? e?.message ?? e}`, true)
|
||||
} finally {
|
||||
creatingFolder = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title="Open a pipeline">
|
||||
<div class="flex flex-col gap-6 w-full min-w-0">
|
||||
{#if visiblePipelines.length > 0 || pipelines.loading}
|
||||
<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}
|
||||
<div class="flex flex-col border rounded-md overflow-hidden max-h-64 overflow-y-auto">
|
||||
{#each visiblePipelines as p}
|
||||
<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 ? 'materializer' : 'materializers'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
|
||||
Start in an existing folder
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<Select
|
||||
items={foldersWithoutPipeline.map((f) => ({ label: `f/${f}`, value: f }))}
|
||||
bind:value={selectedExistingFolder}
|
||||
placeholder={foldersWithoutPipeline.length === 0
|
||||
? 'No folders available'
|
||||
: 'Pick a folder…'}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!selectedExistingFolder}
|
||||
onclick={startInExistingFolder}
|
||||
startIcon={{ icon: ArrowRight }}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold text-secondary uppercase tracking-wide">
|
||||
Or create a new folder
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
bind:value={newFolderName}
|
||||
placeholder="new-folder-name"
|
||||
disabled={creatingFolder}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
disabled={!newFolderName.trim() || creatingFolder}
|
||||
onclick={createFolderAndStart}
|
||||
startIcon={{ icon: FolderPlus }}
|
||||
>
|
||||
{creatingFolder ? 'Creating…' : 'Create & open'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts" module>
|
||||
import type { ComponentType } from 'svelte'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
|
||||
// Trigger kinds the pipeline graph can render as a source node. Union of
|
||||
// 'schedule' (inline cron) and the eight native-trigger keywords.
|
||||
export type TriggerNodeKind = 'schedule' | NativeTriggerKind
|
||||
|
||||
// Per-kind presentation. Icons are kept loose — pick the lucide glyph
|
||||
// whose shape most-obviously signals the trigger type at a glance.
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Send,
|
||||
Webhook,
|
||||
Zap,
|
||||
CloudCog
|
||||
} from 'lucide-svelte'
|
||||
|
||||
type Presentation = {
|
||||
icon: ComponentType
|
||||
label: string
|
||||
// Tailwind class fragments for bg + border + text accent.
|
||||
bg: string
|
||||
border: string
|
||||
borderUnsaved: string
|
||||
iconText: string
|
||||
}
|
||||
|
||||
export const TRIGGER_NODE_STYLE: Record<TriggerNodeKind, Presentation> = {
|
||||
schedule: {
|
||||
icon: Clock,
|
||||
label: 'schedule',
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/30',
|
||||
border: 'outline-amber-300 dark:outline-amber-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-amber-400',
|
||||
iconText: 'text-amber-700 dark:text-amber-400'
|
||||
},
|
||||
webhook: {
|
||||
icon: Webhook,
|
||||
label: 'webhook',
|
||||
bg: 'bg-sky-50 dark:bg-sky-900/30',
|
||||
border: 'outline-sky-300 dark:outline-sky-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-sky-400',
|
||||
iconText: 'text-sky-700 dark:text-sky-400'
|
||||
},
|
||||
email: {
|
||||
icon: Mail,
|
||||
label: 'email',
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/30',
|
||||
border: 'outline-violet-300 dark:outline-violet-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-violet-400',
|
||||
iconText: 'text-violet-700 dark:text-violet-400'
|
||||
},
|
||||
kafka: {
|
||||
icon: Zap,
|
||||
label: 'kafka',
|
||||
bg: 'bg-rose-50 dark:bg-rose-900/30',
|
||||
border: 'outline-rose-300 dark:outline-rose-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-rose-400',
|
||||
iconText: 'text-rose-700 dark:text-rose-400'
|
||||
},
|
||||
mqtt: {
|
||||
icon: Radio,
|
||||
label: 'mqtt',
|
||||
bg: 'bg-teal-50 dark:bg-teal-900/30',
|
||||
border: 'outline-teal-300 dark:outline-teal-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-teal-400',
|
||||
iconText: 'text-teal-700 dark:text-teal-400'
|
||||
},
|
||||
nats: {
|
||||
icon: MessageSquare,
|
||||
label: 'nats',
|
||||
bg: 'bg-cyan-50 dark:bg-cyan-900/30',
|
||||
border: 'outline-cyan-300 dark:outline-cyan-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-cyan-400',
|
||||
iconText: 'text-cyan-700 dark:text-cyan-400'
|
||||
},
|
||||
postgres: {
|
||||
icon: Database,
|
||||
label: 'postgres',
|
||||
bg: 'bg-indigo-50 dark:bg-indigo-900/30',
|
||||
border: 'outline-indigo-300 dark:outline-indigo-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-indigo-400',
|
||||
iconText: 'text-indigo-700 dark:text-indigo-400'
|
||||
},
|
||||
sqs: {
|
||||
icon: Send,
|
||||
label: 'sqs',
|
||||
bg: 'bg-orange-50 dark:bg-orange-900/30',
|
||||
border: 'outline-orange-300 dark:outline-orange-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-orange-400',
|
||||
iconText: 'text-orange-700 dark:text-orange-400'
|
||||
},
|
||||
gcp: {
|
||||
icon: CloudCog,
|
||||
label: 'gcp',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/30',
|
||||
border: 'outline-emerald-300 dark:outline-emerald-600/60',
|
||||
borderUnsaved: 'outline-dashed outline-emerald-400',
|
||||
iconText: 'text-emerald-700 dark:text-emerald-400'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import { NODE } from '$lib/components/graph/util'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
// `ref` is the cron expression for schedules, the trigger-path for
|
||||
// every other kind. Rendered verbatim — no formatting per kind.
|
||||
data: { kind: TriggerNodeKind; ref: string; unsaved?: boolean }
|
||||
}
|
||||
let { data }: Props = $props()
|
||||
|
||||
let style = $derived(TRIGGER_NODE_STYLE[data.kind])
|
||||
let Icon = $derived(style.icon)
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center rounded-md drop-shadow-sm overflow-hidden outline outline-1',
|
||||
style.bg,
|
||||
data.unsaved ? `opacity-80 ${style.borderUnsaved}` : style.border
|
||||
)}
|
||||
style="width: {NODE.width}px; min-height: {NODE.height}px;"
|
||||
title={data.unsaved ? `Unsaved ${style.label}: ${data.ref}` : `${style.label}: ${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 text-tertiary truncate">
|
||||
{style.label}{data.unsaved ? ' · unsaved' : ''}
|
||||
</span>
|
||||
<span class="text-2xs font-mono text-emphasis truncate">{data.ref}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} />
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AssetKind } from '$lib/gen'
|
||||
import type { NativeTriggerKind } from './types'
|
||||
|
||||
// Mirror of backend/parsers/windmill-parser/src/asset_parser.rs
|
||||
// `parse_pipeline_annotations` + `parse_trigger_spec`, ported to TS so the
|
||||
// pipeline editor can reflect `// materialize` / `// on <spec>` edits live
|
||||
// before deploy. Keep the two implementations behaviorally identical — any
|
||||
// divergence means the graph preview lies.
|
||||
|
||||
const COMMENT_PREFIXES = ['//', '--', '#'] as const
|
||||
|
||||
const ASSET_PREFIXES: Array<[string, AssetKind]> = [
|
||||
['s3://', 's3object'],
|
||||
['res://', 'resource'],
|
||||
['$res:', 'resource'],
|
||||
['ducklake://', 'ducklake'],
|
||||
['datatable://', 'datatable'],
|
||||
['volume://', 'volume']
|
||||
]
|
||||
|
||||
// Non-native, non-schedule trigger keywords — each carries a workspace
|
||||
// trigger-path reference (`on <kind> <path>`).
|
||||
const NATIVE_TRIGGER_KEYWORDS: NativeTriggerKind[] = [
|
||||
'webhook',
|
||||
'email',
|
||||
'kafka',
|
||||
'mqtt',
|
||||
'nats',
|
||||
'postgres',
|
||||
'sqs',
|
||||
'gcp'
|
||||
]
|
||||
|
||||
export type PipelineTriggerAsset = { kind: AssetKind; path: string }
|
||||
export type PipelineNativeTrigger = { kind: NativeTriggerKind; path: string }
|
||||
|
||||
export type PipelineAnnotations = {
|
||||
isMaterializer: boolean
|
||||
triggerAssets: PipelineTriggerAsset[]
|
||||
schedules: string[] // raw cron expressions, in insertion order
|
||||
nativeTriggers: PipelineNativeTrigger[]
|
||||
}
|
||||
|
||||
function unquote(s: string): string | undefined {
|
||||
if (s.length >= 2) {
|
||||
const q = s[0]
|
||||
if ((q === '"' || q === "'") && s.endsWith(q)) {
|
||||
return s.slice(1, -1)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseAssetSyntax(s: string): PipelineTriggerAsset | undefined {
|
||||
for (const [prefix, kind] of ASSET_PREFIXES) {
|
||||
if (s.startsWith(prefix)) {
|
||||
return { kind, path: s.slice(prefix.length) }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type ParsedSpec =
|
||||
| { kind: 'asset'; value: PipelineTriggerAsset }
|
||||
| { kind: 'schedule'; value: string }
|
||||
| { kind: 'native'; value: PipelineNativeTrigger }
|
||||
|
||||
function parseTriggerSpec(s: string): ParsedSpec | undefined {
|
||||
if (s.startsWith('schedule')) {
|
||||
const rest = s.slice('schedule'.length).trimStart()
|
||||
const cron = unquote(rest)
|
||||
if (cron && cron.trim() !== '') return { kind: 'schedule', value: cron }
|
||||
return undefined
|
||||
}
|
||||
for (const kw of NATIVE_TRIGGER_KEYWORDS) {
|
||||
if (s.startsWith(kw)) {
|
||||
const after = s.slice(kw.length)
|
||||
// Require whitespace so `kafkalike` doesn't match `kafka`.
|
||||
if (after.length === 0 || !/\s/.test(after[0])) continue
|
||||
const path = after.trim()
|
||||
if (!path) return undefined
|
||||
return { kind: 'native', value: { kind: kw, path } }
|
||||
}
|
||||
}
|
||||
const asset = parseAssetSyntax(s)
|
||||
if (asset) return { kind: 'asset', value: asset }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stripCommentPrefix(line: string): string | undefined {
|
||||
const trimmed = line.trimStart()
|
||||
for (const p of COMMENT_PREFIXES) {
|
||||
if (trimmed.startsWith(p)) return trimmed.slice(p.length)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function parsePipelineAnnotations(code: string): PipelineAnnotations {
|
||||
let isMaterializer = false
|
||||
const triggerAssets: PipelineTriggerAsset[] = []
|
||||
const schedules: string[] = []
|
||||
const nativeTriggers: PipelineNativeTrigger[] = []
|
||||
|
||||
for (const rawLine of code.split('\n')) {
|
||||
const rest = stripCommentPrefix(rawLine)
|
||||
if (rest === undefined) continue
|
||||
const inner = rest.trimStart()
|
||||
|
||||
if (inner.startsWith('materialize')) {
|
||||
const after = inner.slice('materialize'.length)
|
||||
if (after === '' || /\s/.test(after[0])) isMaterializer = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (inner.startsWith('on')) {
|
||||
const after = inner.slice('on'.length)
|
||||
if (after === '' || !/\s/.test(after[0])) continue
|
||||
const specText = after.trim()
|
||||
if (!specText) continue
|
||||
const spec = parseTriggerSpec(specText)
|
||||
if (!spec) continue
|
||||
if (spec.kind === 'asset') {
|
||||
if (!triggerAssets.some((a) => a.kind === spec.value.kind && a.path === spec.value.path)) {
|
||||
triggerAssets.push(spec.value)
|
||||
}
|
||||
} else if (spec.kind === 'schedule') {
|
||||
if (!schedules.includes(spec.value)) schedules.push(spec.value)
|
||||
} else {
|
||||
if (!nativeTriggers.some((n) => n.kind === spec.value.kind && n.path === spec.value.path)) {
|
||||
nativeTriggers.push(spec.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { isMaterializer, triggerAssets, schedules, nativeTriggers }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Pipelines' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { userStore } from '$lib/stores'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
|
||||
import { ArrowRight, NetworkIcon } from 'lucide-svelte'
|
||||
|
||||
// Modal is open by default on landing; the editor shell stays empty
|
||||
// behind it until the user picks or creates a folder.
|
||||
let pickerOpen = $state(true)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Pipeline editor — Windmill</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $userStore?.operator}
|
||||
<div class="p-8 text-tertiary">Page not available for operators.</div>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
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">Pipeline editor</h1>
|
||||
<span class="text-xs text-tertiary">· no folder selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 relative bg-surface-secondary">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 text-tertiary">
|
||||
<NetworkIcon size={32} class="opacity-50" />
|
||||
<span class="text-sm">Pick a folder to open its pipeline.</span>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
onclick={() => (pickerOpen = true)}
|
||||
startIcon={{ icon: ArrowRight }}
|
||||
>
|
||||
Choose a folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PipelinePickerModal bind:open={pickerOpen} />
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Pipeline editor' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { base } from '$lib/base'
|
||||
import { page } from '$app/state'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
|
||||
import AssetGraphDetailsPane from '$lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte'
|
||||
import PipelinePickerModal from '$lib/components/assets/AssetGraph/PipelinePickerModal.svelte'
|
||||
import type {
|
||||
AssetGraphResponse,
|
||||
AssetGraphSelection
|
||||
} from '$lib/components/assets/AssetGraph/types'
|
||||
import {
|
||||
parsePipelineAnnotations,
|
||||
type PipelineAnnotations
|
||||
} from '$lib/components/assets/AssetGraph/parsePipelineAnnotations'
|
||||
import { decodeState, encodeState } from '$lib/utils'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderSearch,
|
||||
Loader2,
|
||||
NetworkIcon,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte'
|
||||
import { OpenAPI, type AssetKind, type Script, type ScriptLang } from '$lib/gen'
|
||||
import { initialCode } from '$lib/script_helpers'
|
||||
import { resource } from 'runed'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { emptySchema } from '$lib/utils'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
// Variables and resources are declarative config, not pipeline assets —
|
||||
// they're hub-shaped (referenced by most runnables) and would swamp the
|
||||
// layout without adding lineage information.
|
||||
const DATA_KINDS = ['s3object', 'ducklake', 'datatable', 'volume']
|
||||
|
||||
let folder = $derived(page.params.folder as string)
|
||||
let selection = $state<AssetGraphSelection | undefined>(undefined)
|
||||
|
||||
// Asset-kind → syntax-prefix for `// on <ref>` reconstruction. Mirrors
|
||||
// ASSET_KINDS in backend/parsers/windmill-parser/src/asset_parser.rs.
|
||||
const ASSET_PREFIX: Record<AssetKind, string> = {
|
||||
s3object: 's3://',
|
||||
resource: '$res:',
|
||||
ducklake: 'ducklake://',
|
||||
datatable: 'datatable://',
|
||||
volume: 'volume://'
|
||||
}
|
||||
|
||||
// Path-input split for the insert menu: a read-only `f/<folder>/` chip
|
||||
// on the left the user can't delete, plus an editable suffix seeded
|
||||
// with a placeholder name.
|
||||
let pathPrefix = $derived(`f/${folder}/`)
|
||||
const DEFAULT_PATH_SUFFIX = 'new_materializer'
|
||||
// Default cron for top + materializers (pipeline roots). Every hour is a
|
||||
// sane middle ground between batch and real-time; users edit the
|
||||
// `// on schedule "..."` line in the editor before saving if they want
|
||||
// something different. Per-asset materializers don't get a schedule by
|
||||
// default — they inherit their trigger from the upstream asset.
|
||||
const DEFAULT_SCHEDULE_CRON = '0 * * * *'
|
||||
|
||||
// In-flight drafts keyed by script path. Multiple can coexist — clicking
|
||||
// + repeatedly creates additional drafts, each with its own random
|
||||
// output asset, and they all render on the graph simultaneously.
|
||||
// Saving removes a draft from the map; closing the pane keeps it so the
|
||||
// user can come back to it.
|
||||
type Draft = {
|
||||
script: Script
|
||||
outputAsset: { kind: AssetKind; path: string }
|
||||
}
|
||||
let drafts = $state<Map<string, Draft>>(new Map())
|
||||
|
||||
// Which draft (if any) is currently open in the details pane. When
|
||||
// undefined and `selection` is set, the pane shows the persisted
|
||||
// selection's script. Never both at once.
|
||||
let activeDraftPath = $state<string | undefined>(undefined)
|
||||
|
||||
// Per-folder localStorage key. Matches the flow-builder pattern so
|
||||
// reloading /pipeline/<folder> restores in-flight drafts. We serialize
|
||||
// the drafts map as an entry array (Map doesn't survive JSON.stringify).
|
||||
let storageKey = $derived(`pipeline-${folder}`)
|
||||
|
||||
onMount(() => {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
const raw = localStorage.getItem(`pipeline-${folder}`)
|
||||
if (!raw) return
|
||||
try {
|
||||
const state = decodeState(raw)
|
||||
if (Array.isArray(state?.drafts)) {
|
||||
const loaded = new Map<string, Draft>()
|
||||
for (const entry of state.drafts) {
|
||||
if (entry && typeof entry[0] === 'string' && entry[1]?.script) {
|
||||
loaded.set(entry[0], entry[1] as Draft)
|
||||
}
|
||||
}
|
||||
if (loaded.size > 0) drafts = loaded
|
||||
}
|
||||
if (typeof state?.activeDraftPath === 'string') {
|
||||
activeDraftPath = state.activeDraftPath
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('failed to restore pipeline state', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Debounced persist: reruns whenever drafts / activeDraftPath change.
|
||||
// 500 ms matches FlowBuilder.saveSessionDraft; balances typing churn
|
||||
// against losing state to a crash.
|
||||
let persistTimer: number | undefined = undefined
|
||||
$effect(() => {
|
||||
// Track deps explicitly so Svelte 5 re-runs on mutation.
|
||||
const serialized = Array.from(drafts.entries())
|
||||
const activePath = activeDraftPath
|
||||
const key = storageKey
|
||||
untrack(() => {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
if (persistTimer != undefined) clearTimeout(persistTimer)
|
||||
persistTimer = window.setTimeout(() => {
|
||||
try {
|
||||
if (serialized.length === 0 && !activePath) {
|
||||
localStorage.removeItem(key)
|
||||
} else {
|
||||
localStorage.setItem(
|
||||
key,
|
||||
encodeState({ drafts: serialized, activeDraftPath: activePath })
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('failed to persist pipeline state', e)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
|
||||
// Live-parsed annotations from whatever script is currently open in the
|
||||
// details pane (draft or existing). Refreshed on every keystroke via
|
||||
// `onAnnotationsChange`. Used to overlay unsaved schedule / trigger-asset
|
||||
// edges onto the graph so the editor buffer and the graph stay in sync.
|
||||
let liveAnnotations = $state<{
|
||||
scriptPath: string | undefined
|
||||
annotations: PipelineAnnotations
|
||||
}>({
|
||||
scriptPath: undefined,
|
||||
annotations: {
|
||||
isMaterializer: false,
|
||||
triggerAssets: [],
|
||||
schedules: [],
|
||||
nativeTriggers: []
|
||||
}
|
||||
})
|
||||
|
||||
// Crockford-ish random slug for asset paths. 7 chars of [a-z0-9] gives
|
||||
// ~36^7 ≈ 7.8e10 combinations — collision-free in practice for the
|
||||
// handful of scripts a user creates in a session.
|
||||
function randomSlug(len = 7): string {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let out = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomOutputAssetPath(): { kind: AssetKind; path: string } {
|
||||
// `s3://` is the most universal kind; works as a literal across every
|
||||
// supported language without extra imports. Inside `pipelines/<folder>/`
|
||||
// so outputs of a single pipeline cluster under one prefix.
|
||||
return { kind: 's3object', path: `pipelines/${folder}/out_${randomSlug()}.parquet` }
|
||||
}
|
||||
|
||||
// Language → comment prefix recognized by parse_pipeline_annotations.
|
||||
function commentPrefix(lang: ScriptLang): string {
|
||||
switch (lang) {
|
||||
case 'python3':
|
||||
case 'bash':
|
||||
case 'powershell':
|
||||
case 'nu':
|
||||
case 'ansible':
|
||||
return '#'
|
||||
case 'postgresql':
|
||||
case 'mysql':
|
||||
case 'bigquery':
|
||||
case 'snowflake':
|
||||
case 'mssql':
|
||||
case 'oracledb':
|
||||
case 'duckdb':
|
||||
return '--'
|
||||
default:
|
||||
return '//'
|
||||
}
|
||||
}
|
||||
|
||||
// Each entry becomes a `// on <…>` line in the seeded template. Multiple
|
||||
// trigger sources are valid (a script can be fired by both a schedule
|
||||
// and a webhook, for instance), but the menu only seeds one at a time.
|
||||
type DraftTriggerSource =
|
||||
| { kind: 'schedule'; cron: string }
|
||||
| { kind: 'asset'; ref: string } // already-prefixed (e.g. s3://…)
|
||||
| {
|
||||
kind: 'webhook' | 'email' | 'kafka' | 'mqtt' | 'nats' | 'postgres' | 'sqs' | 'gcp'
|
||||
path: string | undefined
|
||||
}
|
||||
|
||||
// Short "how to author a materializer" doc block prepended to every new
|
||||
// draft. Kept terse because the editor mounts it on-screen — a wall of
|
||||
// comments up top discourages the user more than it helps.
|
||||
function materializerHeader(language: ScriptLang, sources: DraftTriggerSource[]): string {
|
||||
const p = commentPrefix(language)
|
||||
const onLines = sources.map((s) => {
|
||||
switch (s.kind) {
|
||||
case 'schedule':
|
||||
return `${p} on schedule "${s.cron}"`
|
||||
case 'asset':
|
||||
return `${p} on ${s.ref}`
|
||||
default:
|
||||
// Empty placeholder path makes it visible the user needs
|
||||
// to fill in the trigger reference.
|
||||
return `${p} on ${s.kind} ${s.path ?? '<trigger-path>'}`
|
||||
}
|
||||
})
|
||||
const lines = [
|
||||
`${p} materialize`,
|
||||
...onLines,
|
||||
`${p}`,
|
||||
`${p} This script is a pipeline materializer.`,
|
||||
`${p} - Reads and writes detected in the code become the lineage edges`,
|
||||
`${p} shown in the pipeline graph (no extra declaration needed).`,
|
||||
`${p} - The \`${p} materialize\` marker opts it into the pipeline.`,
|
||||
`${p} - \`${p} on <asset|schedule "cron"|<kind> <path>>\` declares trigger edges.`,
|
||||
`${p} Supported trigger kinds: schedule, webhook, email, kafka, mqtt,`,
|
||||
`${p} nats, postgres, sqs, gcp.`,
|
||||
`${p}`,
|
||||
`${p} Put your logic inside \`main\`. Whatever you return is the script's`,
|
||||
`${p} output; writes to assets (e.g. s3://, datatable://, ducklake://,`,
|
||||
`${p} volume://) go through the usual Windmill helpers.`,
|
||||
''
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// Per-language boilerplate that declares the output asset URI at module
|
||||
// scope. Two reasons:
|
||||
// 1. The string literal is where the backend parser picks up the write
|
||||
// on deploy — reproducing the same asset → runnable edge the
|
||||
// frontend draft overlay shows while unsaved.
|
||||
// 2. Gives the user a named variable to reference when filling in
|
||||
// `main`, instead of a magic string deep in the body.
|
||||
function outputAssetSnippet(language: ScriptLang, uri: string): string {
|
||||
const p = commentPrefix(language)
|
||||
const note = `${p} Output of this materializer — write to this path inside \`main\`.`
|
||||
switch (language) {
|
||||
case 'python3':
|
||||
return `${note}\nOUTPUT = "${uri}"\n\n`
|
||||
case 'postgresql':
|
||||
case 'mysql':
|
||||
case 'bigquery':
|
||||
case 'snowflake':
|
||||
case 'mssql':
|
||||
case 'oracledb':
|
||||
case 'duckdb':
|
||||
// SQL has no variable scope we can reliably reuse; drop a
|
||||
// referenceable comment the parser still finds inside strings.
|
||||
return `${note}\n${p} ${uri}\n\n`
|
||||
case 'bash':
|
||||
case 'powershell':
|
||||
case 'nu':
|
||||
case 'ansible':
|
||||
return `${note}\nOUTPUT="${uri}"\n\n`
|
||||
default:
|
||||
return `${note}\nconst OUTPUT = "${uri}"\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
function buildDraft(
|
||||
language: ScriptLang,
|
||||
scriptPath: string,
|
||||
sources: DraftTriggerSource[],
|
||||
outputAssetUri?: string
|
||||
): Script {
|
||||
// Reuse the language-specific main() boilerplate Windmill already
|
||||
// ships with so every language produces a usable function signature.
|
||||
const body = initialCode(language as any, 'script', 'script')
|
||||
const header = materializerHeader(language, sources)
|
||||
const output = outputAssetUri ? outputAssetSnippet(language, outputAssetUri) : ''
|
||||
const content = header + output + body
|
||||
// Cast through unknown: the Script generated type has many readonly
|
||||
// deployment fields (hash, created_*) that we don't care about for a
|
||||
// local draft. The details pane only reads path/language/content/schema.
|
||||
return {
|
||||
hash: '',
|
||||
path: scriptPath,
|
||||
summary: '',
|
||||
description: '',
|
||||
content,
|
||||
schema: emptySchema(),
|
||||
is_template: false,
|
||||
extra_perms: {},
|
||||
language,
|
||||
kind: 'script',
|
||||
created_by: '',
|
||||
created_at: new Date().toISOString(),
|
||||
archived: false,
|
||||
deleted: false,
|
||||
starred: false
|
||||
} as unknown as Script
|
||||
}
|
||||
|
||||
function openMaterializerDraft(
|
||||
language: ScriptLang,
|
||||
scriptPath: string,
|
||||
sources: DraftTriggerSource[]
|
||||
) {
|
||||
const out = randomOutputAssetPath()
|
||||
const outputUri = `${ASSET_PREFIX[out.kind]}${out.path}`
|
||||
const script = buildDraft(language, scriptPath, sources, outputUri)
|
||||
// Write the new draft into the map (structural update so Svelte
|
||||
// re-derives graphWithDraft) and focus it in the details pane.
|
||||
const next = new Map(drafts)
|
||||
next.set(scriptPath, { script, outputAsset: out })
|
||||
drafts = next
|
||||
activeDraftPath = scriptPath
|
||||
selection = undefined
|
||||
}
|
||||
|
||||
function discardDraft(path: string) {
|
||||
if (!drafts.has(path)) return
|
||||
const next = new Map(drafts)
|
||||
next.delete(path)
|
||||
drafts = next
|
||||
if (activeDraftPath === path) activeDraftPath = undefined
|
||||
}
|
||||
|
||||
// Currently-open draft shape (if any) — fed into the details pane.
|
||||
let activeDraft = $derived(activeDraftPath ? drafts.get(activeDraftPath) : undefined)
|
||||
|
||||
// Overlay the draft runnable + live-parsed trigger edges onto the fetched
|
||||
// graph. Live edges come from the editor buffer's `// on <spec>` lines
|
||||
// and are marked `unsaved: true` unless they already match a persisted
|
||||
// script_trigger row. This keeps the canvas in sync with the editor
|
||||
// keystroke-by-keystroke; saving replaces the live edges with real ones
|
||||
// via graphRes.refetch().
|
||||
let graphWithDraft = $derived.by<AssetGraphResponse>(() => {
|
||||
const base = graphRes.current ?? EMPTY_GRAPH
|
||||
|
||||
// Every draft contributes: a runnable, an output asset, a write edge,
|
||||
// plus its own seeded schedule trigger (template includes `// on
|
||||
// schedule "0 * * * *"` by default, picked up through live parse).
|
||||
// We iterate the whole `drafts` map so multiple concurrent drafts
|
||||
// all render as their own subgraph at once.
|
||||
const runnables = [...base.runnables]
|
||||
const assets = [...base.assets]
|
||||
const edges = [...base.edges]
|
||||
const extraTriggers: AssetGraphResponse['triggers'] = []
|
||||
|
||||
for (const [path, d] of drafts) {
|
||||
runnables.push({ path, usage_kind: 'script', is_materializer: true })
|
||||
const out = d.outputAsset
|
||||
const hasAsset = assets.some((a) => a.kind === out.kind && a.path === out.path)
|
||||
if (!hasAsset) assets.push({ kind: out.kind, path: out.path })
|
||||
edges.push({
|
||||
runnable_path: path,
|
||||
runnable_kind: 'script',
|
||||
asset_kind: out.kind,
|
||||
asset_path: out.path,
|
||||
access_type: 'w',
|
||||
unsaved: true
|
||||
})
|
||||
// Seed trigger edges (schedule + asset) from the draft's template
|
||||
// so the graph stays stable when the user clicks off this draft.
|
||||
// Live annotations (below) take over for the currently-open draft
|
||||
// so keystroke edits still update in real time.
|
||||
const parsed = parsePipelineAnnotations(d.script.content)
|
||||
for (const cron of parsed.schedules) {
|
||||
extraTriggers.push({
|
||||
trigger_kind: 'schedule',
|
||||
cron,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: path,
|
||||
unsaved: true
|
||||
})
|
||||
}
|
||||
for (const a of parsed.triggerAssets) {
|
||||
extraTriggers.push({
|
||||
trigger_kind: 'asset',
|
||||
asset_kind: a.kind,
|
||||
asset_path: a.path,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: path,
|
||||
unsaved: true
|
||||
})
|
||||
// Also synthesize the asset node so the trigger edge has a
|
||||
// target even if the upstream asset isn't in base (e.g. the
|
||||
// producer script is in another folder we haven't fetched
|
||||
// or also a draft).
|
||||
const hasTriggerAsset = assets.some((x) => x.kind === a.kind && x.path === a.path)
|
||||
if (!hasTriggerAsset) assets.push({ kind: a.kind, path: a.path })
|
||||
}
|
||||
for (const n of parsed.nativeTriggers) {
|
||||
extraTriggers.push({
|
||||
trigger_kind: n.kind,
|
||||
path: n.path,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: path,
|
||||
unsaved: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Live-parsed overlay for the currently-open script — takes precedence
|
||||
// over the seeded-template triggers for the same path by swapping
|
||||
// them out. Scoped to one path (only one pane is open at a time).
|
||||
const livePath = liveAnnotations.scriptPath
|
||||
if (livePath) {
|
||||
const persistedAssetKeys = new Set(
|
||||
base.triggers
|
||||
.filter(
|
||||
(t) =>
|
||||
t.trigger_kind === 'asset' &&
|
||||
t.runnable_kind === 'script' &&
|
||||
t.runnable_path === livePath
|
||||
)
|
||||
.map((t) => (t.trigger_kind === 'asset' ? `${t.asset_kind}:${t.asset_path}` : ''))
|
||||
)
|
||||
const persistedScheduleCrons = new Set(
|
||||
base.triggers
|
||||
.filter(
|
||||
(t) =>
|
||||
t.trigger_kind === 'schedule' &&
|
||||
t.runnable_kind === 'script' &&
|
||||
t.runnable_path === livePath
|
||||
)
|
||||
.map((t) => (t.trigger_kind === 'schedule' ? t.cron : ''))
|
||||
)
|
||||
// Strip seeded triggers we computed above for the active draft;
|
||||
// live annotations are authoritative for the open buffer.
|
||||
for (let i = extraTriggers.length - 1; i >= 0; i--) {
|
||||
if (extraTriggers[i].runnable_path === livePath) extraTriggers.splice(i, 1)
|
||||
}
|
||||
for (const a of liveAnnotations.annotations.triggerAssets) {
|
||||
const key = `${a.kind}:${a.path}`
|
||||
if (persistedAssetKeys.has(key)) continue
|
||||
extraTriggers.push({
|
||||
trigger_kind: 'asset',
|
||||
asset_kind: a.kind,
|
||||
asset_path: a.path,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: livePath,
|
||||
unsaved: true
|
||||
})
|
||||
}
|
||||
for (const cron of liveAnnotations.annotations.schedules) {
|
||||
if (persistedScheduleCrons.has(cron)) continue
|
||||
extraTriggers.push({
|
||||
trigger_kind: 'schedule',
|
||||
cron,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: livePath,
|
||||
unsaved: true
|
||||
})
|
||||
}
|
||||
// Persisted native triggers keyed by `<kind>:<path>`, used to
|
||||
// suppress duplicate overlay for already-saved `// on <kind>`
|
||||
// annotations. trigger_kind is narrower than the union so we
|
||||
// cast through string.
|
||||
const persistedNativeKeys = new Set(
|
||||
base.triggers
|
||||
.filter(
|
||||
(t) =>
|
||||
t.trigger_kind !== 'asset' &&
|
||||
t.trigger_kind !== 'schedule' &&
|
||||
t.runnable_kind === 'script' &&
|
||||
t.runnable_path === livePath
|
||||
)
|
||||
.map((t) => `${t.trigger_kind}:${(t as { path: string }).path}`)
|
||||
)
|
||||
for (const n of liveAnnotations.annotations.nativeTriggers) {
|
||||
const key = `${n.kind}:${n.path}`
|
||||
if (persistedNativeKeys.has(key)) continue
|
||||
extraTriggers.push({
|
||||
trigger_kind: n.kind,
|
||||
path: n.path,
|
||||
runnable_kind: 'script',
|
||||
runnable_path: livePath,
|
||||
unsaved: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { ...base, assets, runnables, edges, triggers: [...base.triggers, ...extraTriggers] }
|
||||
})
|
||||
|
||||
// Selection highlights the active draft (if any) or the user's picked
|
||||
// node. Non-active drafts render without selection highlight but are
|
||||
// still clickable to re-enter their edit pane.
|
||||
let effectiveSelection = $derived<AssetGraphSelection | undefined>(
|
||||
activeDraftPath
|
||||
? { kind: 'runnable', runnable_kind: 'script', path: activeDraftPath }
|
||||
: selection
|
||||
)
|
||||
|
||||
// Folder-picker modal state. Opens from the folder selector button when
|
||||
// there are no other pipelines to switch to, or from the "Choose another
|
||||
// folder…" entry in the dropdown otherwise.
|
||||
let pickerModalOpen = $state(false)
|
||||
|
||||
// Reuse the empty AssetGraphResponse so we can still render the canvas
|
||||
// (layout, controls, mini-map) on a fresh pipeline.
|
||||
const EMPTY_GRAPH: AssetGraphResponse = {
|
||||
assets: [],
|
||||
runnables: [],
|
||||
edges: [],
|
||||
triggers: []
|
||||
}
|
||||
|
||||
// Powers the folder switcher in the header. Same endpoint the landing
|
||||
// page uses, so switches are free after the first fetch.
|
||||
let pipelineFoldersRes = resource(
|
||||
() => $workspaceStore,
|
||||
async (ws, _prev, { signal }) => {
|
||||
if (!ws) return [] as Array<{ folder: string; script_count: number }>
|
||||
const base_url = OpenAPI.BASE ?? ''
|
||||
const res = await fetch(`${base_url}/w/${ws}/assets/pipelines`, {
|
||||
credentials: 'include',
|
||||
signal
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return (await res.json()) as Array<{ folder: string; script_count: number }>
|
||||
}
|
||||
)
|
||||
|
||||
// Only pipelines the user can actually switch to — the current folder
|
||||
// is excluded. If this is empty, the folder selector button opens the
|
||||
// picker modal directly instead of a single-item dropdown.
|
||||
let otherPipelineFolders = $derived(
|
||||
(pipelineFoldersRes.current ?? []).filter((p) => p.folder !== folder)
|
||||
)
|
||||
|
||||
let folderSwitcherItems = $derived.by(() => {
|
||||
const items = otherPipelineFolders.map((p) => ({
|
||||
displayName: `f/${p.folder}`,
|
||||
icon: Folder,
|
||||
disabled: false,
|
||||
action: () => goto(`${base}/pipeline/${encodeURIComponent(p.folder)}`)
|
||||
}))
|
||||
items.push({
|
||||
displayName: 'Choose another folder…',
|
||||
icon: FolderSearch,
|
||||
disabled: false,
|
||||
action: async () => {
|
||||
pickerModalOpen = true
|
||||
}
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
let graphRes = resource(
|
||||
[() => $workspaceStore, () => folder],
|
||||
async ([ws, f], _prev, { signal }) => {
|
||||
if (!ws || !f) return undefined
|
||||
const base_url = OpenAPI.BASE ?? ''
|
||||
const params = new URLSearchParams({
|
||||
folder: f,
|
||||
asset_kinds: DATA_KINDS.join(',')
|
||||
})
|
||||
const res = await fetch(`${base_url}/w/${ws}/assets/graph?${params}`, {
|
||||
credentials: 'include',
|
||||
signal
|
||||
})
|
||||
if (!res.ok) throw new Error(`GET /assets/graph → ${res.status}`)
|
||||
return (await res.json()) as AssetGraphResponse
|
||||
}
|
||||
)
|
||||
|
||||
function pluralize(n: number, singular: string): string {
|
||||
return `${n} ${singular}${n === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
let summary = $derived.by<string[]>(() => {
|
||||
const g = graphRes.current
|
||||
if (!g) return []
|
||||
const parts: string[] = []
|
||||
const scripts = g.runnables.filter((r) => r.usage_kind === 'script').length
|
||||
const flows = g.runnables.filter((r) => r.usage_kind === 'flow').length
|
||||
if (scripts) parts.push(pluralize(scripts, 'script'))
|
||||
if (flows) parts.push(pluralize(flows, 'flow'))
|
||||
const byKind = new Map<string, number>()
|
||||
for (const a of g.assets) byKind.set(a.kind, (byKind.get(a.kind) ?? 0) + 1)
|
||||
for (const [kind, n] of byKind) parts.push(pluralize(n, kind))
|
||||
return parts
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Pipeline · {folder} — Windmill</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if $userStore?.operator}
|
||||
<div class="p-8 text-tertiary">Page not available for operators.</div>
|
||||
{:else}
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="border-b flex flex-row justify-between gap-2 px-2 py-1 items-center overflow-y-visible overflow-x-auto min-h-10 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
href="{base}/pipeline"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
title="Back to pipelines"
|
||||
/>
|
||||
<NetworkIcon size={16} class="text-tertiary shrink-0" />
|
||||
<h1 class="text-sm font-semibold">Pipeline editor</h1>
|
||||
<span class="text-tertiary text-sm">·</span>
|
||||
{#if otherPipelineFolders.length === 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pickerModalOpen = true)}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
|
||||
title="Switch pipeline folder"
|
||||
>
|
||||
<Folder size={14} class="text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
|
||||
<ChevronDown size={12} class="text-tertiary" />
|
||||
</button>
|
||||
{:else}
|
||||
<DropdownV2 size="sm" items={folderSwitcherItems}>
|
||||
{#snippet buttonReplacement()}
|
||||
<span
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border border-gray-300 dark:border-gray-600 bg-surface hover:bg-surface-hover transition-colors"
|
||||
title="Switch pipeline folder"
|
||||
>
|
||||
<Folder size={14} class="text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<span class="text-sm font-mono font-medium text-emphasis">f/{folder}</span>
|
||||
<ChevronDown size={12} class="text-tertiary" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if summary.length > 0}
|
||||
<span class="text-xs text-tertiary">· {summary.join(' · ')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
onclick={() => graphRes.refetch()}
|
||||
disabled={graphRes.loading}
|
||||
iconOnly
|
||||
title="Refresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0">
|
||||
{#if graphRes.loading && !graphRes.current}
|
||||
<div class="h-full flex items-center justify-center gap-2 text-tertiary">
|
||||
<Loader2 size={18} class="animate-spin" />
|
||||
<span>Loading pipeline…</span>
|
||||
</div>
|
||||
{:else if graphRes.error}
|
||||
<div class="h-full flex items-center justify-center text-red-500 text-sm">
|
||||
Failed to load pipeline: {graphRes.error.message}
|
||||
</div>
|
||||
{:else}
|
||||
<Splitpanes class="!h-full">
|
||||
<Pane size={selection ? 60 : 100}>
|
||||
<AssetGraphCanvas
|
||||
graph={graphWithDraft}
|
||||
selection={effectiveSelection}
|
||||
{pathPrefix}
|
||||
defaultPathSuffix={DEFAULT_PATH_SUFFIX}
|
||||
defaultScheduleCron={DEFAULT_SCHEDULE_CRON}
|
||||
onselect={(s) => {
|
||||
// Clicking a draft runnable node re-opens it in the pane;
|
||||
// clicking anything else selects it normally and detaches
|
||||
// from any active draft (drafts stay overlaid until saved
|
||||
// or discarded).
|
||||
if (
|
||||
s &&
|
||||
s.kind === 'runnable' &&
|
||||
s.runnable_kind === 'script' &&
|
||||
drafts.has(s.path)
|
||||
) {
|
||||
activeDraftPath = s.path
|
||||
selection = undefined
|
||||
} else {
|
||||
activeDraftPath = undefined
|
||||
selection = s
|
||||
}
|
||||
}}
|
||||
onAddScriptForAsset={(asset, language, scriptPath) => {
|
||||
const ref = `${ASSET_PREFIX[asset.kind]}${asset.path}`
|
||||
openMaterializerDraft(language, scriptPath, [{ kind: 'asset', ref }])
|
||||
}}
|
||||
onAddMaterializer={(language, scriptPath, source) =>
|
||||
openMaterializerDraft(language, scriptPath, [source])}
|
||||
/>
|
||||
</Pane>
|
||||
{#if (selection || activeDraft) && $workspaceStore}
|
||||
<Pane size={40} minSize={25}>
|
||||
<AssetGraphDetailsPane
|
||||
selection={activeDraft ? undefined : selection}
|
||||
draftScript={activeDraft?.script}
|
||||
workspace={$workspaceStore}
|
||||
onAnnotationsChange={(scriptPath, annotations) => {
|
||||
liveAnnotations = { scriptPath, annotations }
|
||||
}}
|
||||
onclose={() => {
|
||||
// Close dismisses the pane but preserves drafts so
|
||||
// the user can come back to them. Discarding is
|
||||
// via the explicit "Discard" button in the pane.
|
||||
selection = undefined
|
||||
activeDraftPath = undefined
|
||||
liveAnnotations = {
|
||||
scriptPath: undefined,
|
||||
annotations: {
|
||||
isMaterializer: false,
|
||||
triggerAssets: [],
|
||||
schedules: [],
|
||||
nativeTriggers: []
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDiscard={() => {
|
||||
if (activeDraftPath) discardDraft(activeDraftPath)
|
||||
}}
|
||||
onDraftSaved={async (savedPath) => {
|
||||
discardDraft(savedPath)
|
||||
await graphRes.refetch()
|
||||
}}
|
||||
/>
|
||||
</Pane>
|
||||
{/if}
|
||||
</Splitpanes>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PipelinePickerModal bind:open={pickerModalOpen} currentFolder={folder} />
|
||||
{/if}
|
||||
Reference in New Issue
Block a user