mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat: data upload opens on its inputs alone, works in sessions, with a prominent Upload data button
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
e96f6bcfae
commit
9e5982769a
@@ -1523,6 +1523,9 @@
|
||||
} catch (e) {
|
||||
console.log('error initializing services', e)
|
||||
}
|
||||
// Unmounted while the services loaded: the container is detached, and
|
||||
// creating the editor on it throws.
|
||||
if (destroyed || !divEl?.isConnected) return
|
||||
|
||||
// vscode.languages.registerDefinitionProvider('*', {
|
||||
// provideDefinition(document, position, token) {
|
||||
|
||||
@@ -2068,43 +2068,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if customUi?.previewPanel?.hideArgs}
|
||||
<!-- Compact preview layout used by the pipeline editor:
|
||||
no args column (the script is known to take no
|
||||
inputs), LogPanel takes the full width, with a
|
||||
small Test/Cancel button at the top-left of the
|
||||
preview band. The earlier `-translate-y-1/2`
|
||||
"float onto the editor" version was clipped in
|
||||
Firefox (transform + overflow: visible interaction
|
||||
on the parent splitpane Pane), so the button is
|
||||
now positioned inside the panel — `top-1` keeps
|
||||
it visually pinned to the top edge without
|
||||
relying on cross-browser overflow behaviour. -->
|
||||
<div class="relative h-full pt-9 flex flex-col">
|
||||
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
|
||||
<!-- Right-side affordances when we're displaying a *completed*
|
||||
job (either the user just ran a test, or the on-mount
|
||||
last-run loader populated the panel). The job-id link
|
||||
opens the full run page in a new tab; the dispatch
|
||||
button mounts a popover that shows what downstream
|
||||
jobs this run triggered (rendered only when the run
|
||||
actually dispatched anything). -->
|
||||
<div class="absolute top-1 right-2 z-10 flex items-center gap-2">
|
||||
<a
|
||||
class="text-3xs text-blue-600 hover:underline font-mono"
|
||||
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open this run"
|
||||
>
|
||||
{testJob.id.slice(0, 8)}… ↗
|
||||
</a>
|
||||
<DispatchEventsButton
|
||||
workspace={testJob.workspace_id ?? opWs}
|
||||
jobId={testJob.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-1 left-2 z-10">
|
||||
<!-- Compact preview layout used by the pipeline editor: no args
|
||||
column (most pipeline scripts take no inputs) and a full-width
|
||||
LogPanel. The controls sit in normal flow rather than floating
|
||||
onto the editor band, which Firefox clipped (transform +
|
||||
overflow: visible on the parent splitpane Pane). -->
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Test sits at the top right, the last run's link under it. -->
|
||||
<div class="shrink-0 flex flex-col items-end gap-1 px-2 pt-2 pb-1">
|
||||
<div>
|
||||
{#if testIsLoading}
|
||||
{@render cancelTestButton('sm', 'shadow-md')}
|
||||
{:else if (customUi?.previewPanel?.downstreamSubscribers ?? 0) > 0 || customUi?.previewPanel?.onBoundedRun}
|
||||
@@ -2238,6 +2210,31 @@
|
||||
{@render runTestButton('sm', 'shadow-md')}
|
||||
{/if}
|
||||
</div>
|
||||
{#if testJob?.id && testJob.type === 'CompletedJob' && opWs}
|
||||
<!-- Right-side affordances when we're displaying a *completed*
|
||||
job (either the user just ran a test, or the on-mount
|
||||
last-run loader populated the panel). The job-id link
|
||||
opens the full run page in a new tab; the dispatch
|
||||
button mounts a popover that shows what downstream
|
||||
jobs this run triggered (rendered only when the run
|
||||
actually dispatched anything). -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
class="text-3xs text-blue-600 hover:underline font-mono"
|
||||
href={`${base}/run/${testJob.id}?workspace=${opWs}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open this run"
|
||||
>
|
||||
{testJob.id.slice(0, 8)}… ↗
|
||||
</a>
|
||||
<DispatchEventsButton
|
||||
workspace={testJob.workspace_id ?? opWs}
|
||||
jobId={testJob.id}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if customUi?.previewPanel?.argsAboveLogs && schema?.properties && Object.keys(schema.properties).length > 0}
|
||||
<div
|
||||
class="shrink-0 overflow-auto max-h-[45%] border-b border-gray-200 dark:border-gray-700 px-4 py-3"
|
||||
|
||||
@@ -1325,6 +1325,7 @@
|
||||
label: string
|
||||
/** Nothing to show: the edges into the node already say it runs on writes. */
|
||||
hidden?: boolean
|
||||
kind?: string
|
||||
nodeId?: string
|
||||
missing?: boolean
|
||||
draft?: boolean
|
||||
@@ -1352,6 +1353,7 @@
|
||||
: undefined
|
||||
return {
|
||||
label,
|
||||
kind,
|
||||
nodeId: t.nodeId,
|
||||
missing,
|
||||
draft: t.data?.draft,
|
||||
|
||||
@@ -299,6 +299,12 @@
|
||||
// can persist a data-upload entry's staged input (drives node readiness).
|
||||
// `isValid` is the full-schema validity, not just "file present".
|
||||
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
|
||||
// Show only the open script's inputs and runs (a data-upload click), not
|
||||
// its editor. `onRunInputs` runs it with them, drafts included, and
|
||||
// `onShowScript` leaves this view for the script.
|
||||
inputsOnly?: boolean
|
||||
onRunInputs?: (path: string, args: Record<string, any>) => Promise<string | undefined>
|
||||
onShowScript?: () => void
|
||||
}
|
||||
let {
|
||||
selection,
|
||||
@@ -351,7 +357,10 @@
|
||||
onRunByPath,
|
||||
onRunCascadeByPath,
|
||||
runFormInitialArgs,
|
||||
onRunFormArgsChange
|
||||
onRunFormArgsChange,
|
||||
inputsOnly = false,
|
||||
onRunInputs,
|
||||
onShowScript
|
||||
}: Props = $props()
|
||||
|
||||
let readOnly = $derived(mode !== 'edit')
|
||||
@@ -433,7 +442,7 @@
|
||||
// PipelineScriptView renders instead of the test panel.
|
||||
let viewReadyForTarget = $derived.by(
|
||||
() =>
|
||||
readOnly &&
|
||||
(readOnly || inputsOnly) &&
|
||||
runTargetPath !== undefined &&
|
||||
script?.path === runTargetPath &&
|
||||
(isDraft || (!scriptRes.loading && scriptRes.current?.path === runTargetPath))
|
||||
@@ -574,7 +583,9 @@
|
||||
let args = $state<Record<string, any>>({})
|
||||
let argsSeedPath: string | undefined = undefined
|
||||
$effect.pre(() => {
|
||||
const p = script?.path
|
||||
// Re-seeded on leaving the inputs-only view too, so the file picked there
|
||||
// shows in the editor's test form.
|
||||
const p = script?.path === undefined ? undefined : `${script.path}|${inputsOnly}`
|
||||
if (p === argsSeedPath) return
|
||||
argsSeedPath = p
|
||||
args = runFormInitialArgs ? structuredClone($state.snapshot(runFormInitialArgs)) : {}
|
||||
@@ -584,7 +595,8 @@
|
||||
// required field (not just the S3 file) is satisfied.
|
||||
let runFormIsValid = $state(true)
|
||||
$effect(() => {
|
||||
if (readOnly || !script) return
|
||||
// Inputs-only mounts no ScriptEditor: PipelineScriptView reports its own args.
|
||||
if (readOnly || inputsOnly || !script) return
|
||||
onRunFormArgsChange?.(script.path, $state.snapshot(args), runFormIsValid)
|
||||
})
|
||||
|
||||
@@ -1333,6 +1345,26 @@
|
||||
<div class="p-3 text-xs text-red-500">
|
||||
Failed to load: {scriptRes.error.message}
|
||||
</div>
|
||||
{:else if script && inputsOnly}
|
||||
{#key script.path}
|
||||
<PipelineScriptView
|
||||
{script}
|
||||
{isDraft}
|
||||
canRun
|
||||
runsDrafts
|
||||
inputsOnly
|
||||
{onShowScript}
|
||||
onRun={onRunInputs}
|
||||
{runsRefreshKey}
|
||||
{runsPendingJobId}
|
||||
initialArgs={runFormInitialArgs}
|
||||
onArgsChange={onRunFormArgsChange}
|
||||
onRunCompleted={() => {
|
||||
previewRefreshKey += 1
|
||||
onRunCompleted?.()
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
{:else if script && readOnly}
|
||||
<!-- Read-only modes: no Monaco/ScriptEditor (operators are
|
||||
backend-blocked from previews anyway) — highlighted source,
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
Pencil,
|
||||
CircleSlash,
|
||||
Trash2,
|
||||
Upload,
|
||||
XCircle
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import type { ScriptLang } from '$lib/gen'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import { sendUserToast, type Item } from '$lib/utils'
|
||||
@@ -48,6 +50,8 @@
|
||||
/** What starts a producing script, as its chip shows it. */
|
||||
export type TriggerChipData = {
|
||||
label: string
|
||||
/** The native trigger kind; a data upload renders as a call to action. */
|
||||
kind?: string
|
||||
/** No chip: the node's incoming edges already show what starts it. */
|
||||
hidden?: boolean
|
||||
missing?: boolean
|
||||
@@ -517,7 +521,29 @@
|
||||
trigger?.onOpen && 'bg-surface hover:bg-surface-hover cursor-pointer',
|
||||
trigger?.selected && CHIP_SELECTED
|
||||
)}
|
||||
{#if trigger?.onOpen}
|
||||
{#if trigger?.kind === 'data_upload' && trigger.onOpen}
|
||||
<!-- The one trigger that needs the user: nothing runs until a file is
|
||||
provided, so it reads as the node's main action rather than a chip. -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="flex min-w-0"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Upload }}
|
||||
title={`Upload the data ${triggerScript} runs on`}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation()
|
||||
trigger.onOpen?.()
|
||||
}}
|
||||
>
|
||||
Upload data
|
||||
</Button>
|
||||
</span>
|
||||
{:else if trigger?.onOpen}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(chipClass, 'flex items-center gap-1 min-w-0')}
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
onRunCascadeByPath,
|
||||
runFormInitialArgs,
|
||||
onRunFormArgsChange,
|
||||
inputsOnly = false,
|
||||
onRunInputs,
|
||||
onShowScript,
|
||||
readyDataUploadPaths,
|
||||
resolveLocalScript,
|
||||
localScriptsVersion,
|
||||
@@ -213,6 +216,10 @@
|
||||
// callback to persist them as they change — see the page's dataUploadArgs.
|
||||
runFormInitialArgs?: Record<string, any>
|
||||
onRunFormArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
|
||||
/** See AssetGraphDetailsPane's `inputsOnly`. */
|
||||
inputsOnly?: boolean
|
||||
onRunInputs?: (path: string, args: Record<string, any>) => Promise<string | undefined>
|
||||
onShowScript?: () => void
|
||||
// Data-upload entry scripts whose staged upload is ready (green node).
|
||||
readyDataUploadPaths?: Set<string>
|
||||
/** Local-dev (`/pipeline_dev`): resolve a node to its working-tree content
|
||||
@@ -329,6 +336,13 @@
|
||||
onSelect(s)
|
||||
if (opts?.open && s && panelMode === 'modal') panelModalOpen = true
|
||||
}
|
||||
// A data-upload node's click asks for its run form; in modal mode nothing shows
|
||||
// it unless the modal opens too.
|
||||
$effect(() => {
|
||||
if (focusUploadSignal > 0 && untrack(() => panelMode) === 'modal') {
|
||||
untrack(() => (panelModalOpen = true))
|
||||
}
|
||||
})
|
||||
const showNodeHint = $derived(panelMode === 'modal' && !panelModalOpen)
|
||||
const nodeHintText = $derived(
|
||||
effectiveSelection
|
||||
@@ -656,6 +670,9 @@
|
||||
{onRunCascadeByPath}
|
||||
{runFormInitialArgs}
|
||||
{onRunFormArgsChange}
|
||||
{inputsOnly}
|
||||
{onRunInputs}
|
||||
{onShowScript}
|
||||
{resolveLocalScript}
|
||||
{localScriptsVersion}
|
||||
selection={activeDraft ? undefined : editor.selection}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { AssetKind, Script } from '$lib/gen'
|
||||
import { CalendarClock, Loader2, Play, Upload, Zap } from 'lucide-svelte'
|
||||
import { CalendarClock, Code2, Loader2, Play, Upload, Zap } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import HighlightCode from '$lib/components/HighlightCode.svelte'
|
||||
import PipelineRunForm from './PipelineRunForm.svelte'
|
||||
@@ -44,6 +44,12 @@
|
||||
// "green / ready" state and seeds the whole-pipeline run. `isValid` is the
|
||||
// full-schema validity (all required fields, not just the S3 file).
|
||||
onArgsChange?: (path: string, args: Record<string, any>, isValid: boolean) => void
|
||||
// Just the run form and the runs, without the source: what a data-upload
|
||||
// click asks for. `onShowScript` switches to the script itself.
|
||||
inputsOnly?: boolean
|
||||
onShowScript?: () => void
|
||||
// `onRun` previews a draft's own content, so a draft can run too.
|
||||
runsDrafts?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -57,8 +63,12 @@
|
||||
runsPendingJobId,
|
||||
onRunCompleted,
|
||||
initialArgs,
|
||||
onArgsChange
|
||||
onArgsChange,
|
||||
inputsOnly = false,
|
||||
onShowScript,
|
||||
runsDrafts = false
|
||||
}: Props = $props()
|
||||
let runBlockedByDraft = $derived(isDraft && !runsDrafts)
|
||||
|
||||
// Seed once from the persisted args (see initialArgs). Cloned so the run
|
||||
// form mutates its own copy, not the page's stored snapshot.
|
||||
@@ -139,7 +149,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
{#if isDraft}
|
||||
{#if runBlockedByDraft}
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-2 px-3 py-1.5 text-2xs bg-amber-50 dark:bg-amber-900/30 border-b border-amber-200 dark:border-amber-900/60 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
@@ -168,13 +178,24 @@
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if inputsOnly && onShowScript}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Code2 }}
|
||||
onclick={onShowScript}
|
||||
title="Show the script instead of its inputs"
|
||||
>
|
||||
Open script
|
||||
</Button>
|
||||
{/if}
|
||||
{#if hasCascade}
|
||||
<Button
|
||||
variant="accent-secondary"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: running ? Loader2 : Zap }}
|
||||
onclick={() => run(true)}
|
||||
disabled={isDraft || running || !runValid}
|
||||
disabled={runBlockedByDraft || running || !runValid}
|
||||
title={`Run this script with these inputs, then run its ${downstreamCount} downstream pipeline script${downstreamCount === 1 ? '' : 's'} in order`}
|
||||
>
|
||||
Run + downstream
|
||||
@@ -185,8 +206,8 @@
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: running ? Loader2 : Play }}
|
||||
onclick={() => run(false)}
|
||||
disabled={isDraft || running || !runValid || !onRun}
|
||||
title={isDraft
|
||||
disabled={runBlockedByDraft || running || !runValid || !onRun}
|
||||
title={runBlockedByDraft
|
||||
? 'Deploy this draft to run it for real'
|
||||
: hasCascade
|
||||
? 'Run just this step — downstream scripts are not triggered'
|
||||
@@ -210,9 +231,11 @@
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
|
||||
<HighlightCode code={script.content ?? ''} language={script.language} />
|
||||
</div>
|
||||
{#if !inputsOnly}
|
||||
<div class="flex-1 min-h-0 overflow-auto text-xs p-3">
|
||||
<HighlightCode code={script.content ?? ''} language={script.language} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={45} minSize={20}>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { untrack } from 'svelte'
|
||||
import type { AssetGraphResponse } from './types'
|
||||
|
||||
type Staged = { args: Record<string, any>; valid: boolean }
|
||||
|
||||
// An S3Object-shaped value (the file picker writes `{ s3: '<path>' }`).
|
||||
function isS3Object(v: any): boolean {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v) && 's3' in v
|
||||
}
|
||||
|
||||
// Whether a staged value carries actual data. Covers the two shapes a
|
||||
// data-upload entry takes: an S3Object (file picker → `{ s3: '<path>' }`) and
|
||||
// a plain required input (e.g. a JSON array pasted into the run form).
|
||||
function hasMeaningfulValue(v: any): boolean {
|
||||
if (v == null) return false
|
||||
if (typeof v === 'string') return v.length > 0
|
||||
if (Array.isArray(v)) return v.length > 0
|
||||
if (typeof v === 'object')
|
||||
return isS3Object(v) ? typeof v.s3 === 'string' && v.s3.length > 0 : Object.keys(v).length > 0
|
||||
return true // numbers / booleans count as provided
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged run-form input for the graph's data-upload entry scripts, keyed by path.
|
||||
* Lifted out of the (transient, per-selection) run form so the uploaded/entered
|
||||
* data persists across selection changes: it drives each entry node's green
|
||||
* "ready" state and seeds a run with that input. `valid` is the run form's
|
||||
* full-schema validity (all required fields satisfied).
|
||||
*/
|
||||
export function usePipelineDataUploads(getGraph: () => AssetGraphResponse) {
|
||||
let staged = $state<Record<string, Staged>>({})
|
||||
|
||||
// Scripts that are data-upload entry points (a `data_upload` trigger in the
|
||||
// displayed graph). They can't auto-run — they need their data first.
|
||||
const entryPaths = $derived(
|
||||
new Set(
|
||||
getGraph()
|
||||
.triggers.filter((t) => t.trigger_kind === 'data_upload' && t.runnable_kind === 'script')
|
||||
.map((t) => t.runnable_path)
|
||||
)
|
||||
)
|
||||
|
||||
// Ready once the user actually provided its data, not just opened the form. Two
|
||||
// guards: the form's own full-schema `valid` (every required field — including
|
||||
// non-file ones — is satisfied), AND that any declared S3Object file field
|
||||
// actually carries a file (the picker can leave an empty `{ s3: '' }` on a
|
||||
// non-required file field, which `valid` alone wouldn't catch).
|
||||
function isReady(path: string): boolean {
|
||||
const s = staged[path]
|
||||
if (!s || !s.valid) return false
|
||||
const values = Object.values(s.args)
|
||||
const s3s = values.filter(isS3Object)
|
||||
if (s3s.length > 0) return s3s.every(hasMeaningfulValue)
|
||||
return values.some(hasMeaningfulValue)
|
||||
}
|
||||
|
||||
const readyPaths = $derived(new Set([...entryPaths].filter((p) => isReady(p))))
|
||||
|
||||
return {
|
||||
get entryPaths(): ReadonlySet<string> {
|
||||
return entryPaths
|
||||
},
|
||||
/** Entry scripts with their data provided — drives the green node treatment. */
|
||||
get readyPaths(): ReadonlySet<string> {
|
||||
return readyPaths
|
||||
},
|
||||
isReady,
|
||||
argsFor: (path: string): Record<string, any> | undefined => staged[path]?.args,
|
||||
// Persist the run form's args + validity, but only for data-upload entries —
|
||||
// other run forms (partitioned producers) run their own way and must not be
|
||||
// mistaken for a staged upload. Idempotent: the run form re-emits on every
|
||||
// keystroke/validation pass, so bail when the value is unchanged — otherwise
|
||||
// each emit would reassign `staged`, giving `readyPaths` a fresh Set identity
|
||||
// that re-syncs the canvas and re-fires the form's emit effect
|
||||
// (effect_update_depth_exceeded).
|
||||
// Called from the run forms' own effects, so its reads are untracked: a form
|
||||
// must not re-run because the store it just wrote to changed.
|
||||
stage: (path: string, args: Record<string, any>, valid: boolean) =>
|
||||
untrack(() => {
|
||||
if (!entryPaths.has(path)) return
|
||||
const prev = staged[path]
|
||||
if (prev && prev.valid === valid && JSON.stringify(prev.args) === JSON.stringify(args))
|
||||
return
|
||||
staged = { ...staged, [path]: { args, valid } }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { Loader2, Save, Workflow } from 'lucide-svelte'
|
||||
import PipelineGraphEditor from '$lib/components/assets/AssetGraph/PipelineGraphEditor.svelte'
|
||||
import PipelineTriggerEditors from '$lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte'
|
||||
import { usePipelineDataUploads } from '$lib/components/assets/AssetGraph/pipelineDataUploads.svelte'
|
||||
import { resolveGraph } from '$lib/components/assets/AssetGraph/resolveGraph'
|
||||
import { useActiveRunnableIds } from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
|
||||
import type {
|
||||
@@ -142,6 +143,7 @@
|
||||
})
|
||||
|
||||
function handleCanvasSelect(s: AssetGraphSelection | undefined) {
|
||||
uploadInputsPath = undefined
|
||||
if (s && s.kind === 'runnable' && s.runnable_kind === 'script' && pe.drafts.has(s.path)) {
|
||||
pe.activeDraftPath = s.path
|
||||
pe.selection = undefined
|
||||
@@ -202,7 +204,11 @@
|
||||
|
||||
// Data upload has no trigger row — open the target in the details pane and pulse
|
||||
// the signal so its auto-generated run form focuses the S3 input.
|
||||
// A data-upload click opens the node on its inputs alone; any other selection
|
||||
// (or "Open script") goes back to the editor.
|
||||
let uploadInputsPath = $state<string | undefined>(undefined)
|
||||
function openDataUploadRun(scriptPath: string) {
|
||||
uploadInputsPath = scriptPath
|
||||
if (pe.drafts.has(scriptPath)) {
|
||||
pe.activeDraftPath = scriptPath
|
||||
pe.selection = undefined
|
||||
@@ -244,12 +250,19 @@
|
||||
}
|
||||
})
|
||||
|
||||
const dataUploads = usePipelineDataUploads(() => resolvedGraph)
|
||||
|
||||
function runProducer(producer: { kind: 'script' | 'flow'; path: string; cascade?: boolean }) {
|
||||
// Pipeline nodes are scripts; a flow producer can't be preview/by-path run.
|
||||
if (producer.kind !== 'script') return Promise.resolve(undefined)
|
||||
return runNode(producer.path, {}, producer.cascade ?? false)
|
||||
// A data-upload entry runs on the file staged in its run form.
|
||||
return runNode(producer.path, dataUploads.argsFor(producer.path) ?? {}, producer.cascade ?? false)
|
||||
}
|
||||
|
||||
// Tell the open node's runs list a job started, so it shows it right away.
|
||||
let runsRefreshKey = $state(0)
|
||||
let runsPendingJobId = $state<string | undefined>(undefined)
|
||||
|
||||
async function runNode(
|
||||
nodePath: string,
|
||||
args: Record<string, any> = {},
|
||||
@@ -283,6 +296,8 @@
|
||||
}
|
||||
activeRunnable = { kind: 'script', path: nodePath }
|
||||
activeRunnableJobId = jobId
|
||||
runsPendingJobId = jobId
|
||||
runsRefreshKey++
|
||||
return jobId
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Run failed: ${e?.body ?? e?.message ?? e}`, true)
|
||||
@@ -483,12 +498,20 @@
|
||||
onOpenWebhook={openWebhookDrawer}
|
||||
onOpenDataUpload={openDataUploadRun}
|
||||
focusUploadSignal={focusDataUploadSignal}
|
||||
readyDataUploadPaths={dataUploads.readyPaths}
|
||||
runFormInitialArgs={pe.openScriptPath ? dataUploads.argsFor(pe.openScriptPath) : undefined}
|
||||
onRunFormArgsChange={dataUploads.stage}
|
||||
{activeRunnable}
|
||||
{runsRefreshKey}
|
||||
{runsPendingJobId}
|
||||
activeRunnableIds={activeRunnables.ids}
|
||||
runStates={activeRunnables.states}
|
||||
eventLogEvents={activeRunnables.events}
|
||||
onRunProducer={runProducer}
|
||||
onRunByPath={(path, args) => runNode(path, args)}
|
||||
inputsOnly={uploadInputsPath !== undefined && uploadInputsPath === pe.openScriptPath}
|
||||
onRunInputs={(path, args) => runNode(path, args)}
|
||||
onShowScript={() => (uploadInputsPath = undefined)}
|
||||
canRunByPath
|
||||
onTestStateChange={(running) => {
|
||||
const openPath = pe.openScriptPath
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
} from '$lib/components/assets/AssetGraph/resolveGraph'
|
||||
import { normalizePipelineFolder } from '$lib/utils/pipelineFolder'
|
||||
import { hideDbtRunnables } from '$lib/components/assets/AssetGraph/hideDbtRunnables'
|
||||
import { usePipelineDataUploads } from '$lib/components/assets/AssetGraph/pipelineDataUploads.svelte'
|
||||
import { buildSchemaContractContext } from '$lib/components/assets/AssetGraph/schemaContracts'
|
||||
import {
|
||||
computeDownstreamClosure,
|
||||
@@ -995,6 +996,7 @@
|
||||
// template can gate them per-mode with simple ternaries — the canvas
|
||||
// hides each affordance when its callback is undefined.
|
||||
function handleCanvasSelect(s: AssetGraphSelection | undefined) {
|
||||
uploadInputsPath = undefined
|
||||
// Clicking a node while the pane is explicitly hidden is a request
|
||||
// to see that node — unhide. Background clicks (s == undefined)
|
||||
// keep the hidden state.
|
||||
@@ -1904,7 +1906,7 @@
|
||||
// data_upload) receives its input from the live event or request at dispatch
|
||||
// time — a message, an HTTP body, an uploaded file — which doesn't exist
|
||||
// during a manual run, so there's nothing to default and those scripts run
|
||||
// empty (data_upload being fed its staged file instead, see dataUploadArgs).
|
||||
// empty (data_upload being fed its staged file instead, see dataUploads).
|
||||
// Resolved on demand right before a run (schedule args aren't in the graph
|
||||
// response); fail-safe — a schedule we can't fetch just contributes nothing.
|
||||
let scheduleArgsByPath: Record<string, Record<string, any>> = {}
|
||||
@@ -1950,7 +1952,7 @@
|
||||
// data-upload entry's staged file override. runWholePipeline already
|
||||
// refused to start unless every data-upload entry is staged, so this is
|
||||
// always the intended input.
|
||||
const staged = { ...(scheduleArgsByPath[path] ?? {}), ...(dataUploadArgs[path]?.args ?? {}) }
|
||||
const staged = { ...(scheduleArgsByPath[path] ?? {}), ...(dataUploads.argsFor(path) ?? {}) }
|
||||
const draft = mode === 'edit' || includeDrafts ? pe.drafts.get(path) : undefined
|
||||
if (draft) {
|
||||
if (!draft.script.content || !draft.script.language) {
|
||||
@@ -2266,70 +2268,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Staged run-form input for data-upload entry scripts, keyed by path. Lifted
|
||||
// out of the (transient, per-selection) run form so the uploaded/entered data
|
||||
// persists across selection changes — it drives each entry node's green
|
||||
// "ready" state and seeds the whole-pipeline run with that input. `valid` is
|
||||
// the run form's full-schema validity (all required fields satisfied).
|
||||
let dataUploadArgs = $state<Record<string, { args: Record<string, any>; valid: boolean }>>({})
|
||||
|
||||
// An S3Object-shaped value (the file picker writes `{ s3: '<path>' }`).
|
||||
function isS3Object(v: any): boolean {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v) && 's3' in v
|
||||
}
|
||||
// Whether a staged value carries actual data. Covers the two shapes a
|
||||
// data-upload entry takes: an S3Object (file picker → `{ s3: '<path>' }`) and
|
||||
// a plain required input (e.g. a JSON array pasted into the run form).
|
||||
function hasMeaningfulValue(v: any): boolean {
|
||||
if (v == null) return false
|
||||
if (typeof v === 'string') return v.length > 0
|
||||
if (Array.isArray(v)) return v.length > 0
|
||||
if (typeof v === 'object')
|
||||
return isS3Object(v) ? typeof v.s3 === 'string' && v.s3.length > 0 : Object.keys(v).length > 0
|
||||
return true // numbers / booleans count as provided
|
||||
}
|
||||
// A data-upload entry is ready once the user actually provided its data, not
|
||||
// just opened the form. Two guards: the form's own full-schema `valid` (every
|
||||
// required field — including non-file ones — is satisfied), AND that any
|
||||
// declared S3Object file field actually carries a file (the picker can leave
|
||||
// an empty `{ s3: '' }` on a non-required file field, which `valid` alone
|
||||
// wouldn't catch).
|
||||
function dataUploadReady(path: string): boolean {
|
||||
const staged = dataUploadArgs[path]
|
||||
if (!staged || !staged.valid) return false
|
||||
const values = Object.values(staged.args)
|
||||
const s3s = values.filter(isS3Object)
|
||||
if (s3s.length > 0) return s3s.every(hasMeaningfulValue)
|
||||
return values.some(hasMeaningfulValue)
|
||||
}
|
||||
// Persist the run form's args + validity, but only for data-upload entries —
|
||||
// other run forms (partitioned producers) run their own way and must not be
|
||||
// mistaken for a staged upload. Idempotent: the run form re-emits on every
|
||||
// keystroke/validation pass, so bail when the value is unchanged — otherwise
|
||||
// each emit would reassign `dataUploadArgs`, giving `readyDataUploadPaths` a
|
||||
// fresh Set identity that re-syncs the canvas and re-fires the form's emit
|
||||
// effect (effect_update_depth_exceeded).
|
||||
function stageRunFormArgs(path: string, args: Record<string, any>, valid: boolean) {
|
||||
if (!dataUploadEntryPaths.has(path)) return
|
||||
const prev = dataUploadArgs[path]
|
||||
if (prev && prev.valid === valid && JSON.stringify(prev.args) === JSON.stringify(args)) return
|
||||
dataUploadArgs = { ...dataUploadArgs, [path]: { args, valid } }
|
||||
}
|
||||
// Pipeline scripts that are data-upload entry points (a `data_upload` trigger
|
||||
// in the displayed graph). They can't auto-run — they need an uploaded file
|
||||
// before the pipeline can go (see runWholePipeline's gate + the node's green
|
||||
// state).
|
||||
let dataUploadEntryPaths = $derived(
|
||||
new Set(
|
||||
displayGraph.triggers
|
||||
.filter((t) => t.trigger_kind === 'data_upload' && t.runnable_kind === 'script')
|
||||
.map((t) => t.runnable_path)
|
||||
)
|
||||
)
|
||||
// Of those, the ones with a staged file — drives the green node treatment.
|
||||
let readyDataUploadPaths = $derived(
|
||||
new Set([...dataUploadEntryPaths].filter((p) => dataUploadReady(p)))
|
||||
)
|
||||
// Data-upload entries' staged input; see usePipelineDataUploads.
|
||||
const dataUploads = usePipelineDataUploads(() => displayGraph)
|
||||
|
||||
// Every pipeline-member script, for the always-visible header "Run pipeline"
|
||||
// control. Per-node runs are hover/select-gated on the canvas; this
|
||||
@@ -2352,7 +2292,7 @@
|
||||
// point the user at the first unready node instead of launching a doomed
|
||||
// run.
|
||||
const unready = allPipelineScripts.filter(
|
||||
(p) => dataUploadEntryPaths.has(p) && !dataUploadReady(p)
|
||||
(p) => dataUploads.entryPaths.has(p) && !dataUploads.isReady(p)
|
||||
)
|
||||
if (unready.length > 0) {
|
||||
sendUserToast(
|
||||
@@ -2559,7 +2499,45 @@
|
||||
// auto-generated run form (the script declares an `S3Object` input, so the
|
||||
// form renders the S3 picker) lets the user upload a file and run the
|
||||
// pipeline. Drafts open in-place too so the user can fill the body first.
|
||||
// A data-upload click opens the node on its inputs alone; any other selection
|
||||
// (or "Open script") goes back to the script.
|
||||
let uploadInputsPath = $state<string | undefined>(undefined)
|
||||
// Runs the open node with its form inputs: a draft previews its own content, a
|
||||
// deployed script runs for real (in view mode, cascading like any legit run).
|
||||
async function runUploadInputs(
|
||||
path: string,
|
||||
args: Record<string, any>
|
||||
): Promise<string | undefined> {
|
||||
const draft = mode === 'edit' ? pe.drafts.get(path) : undefined
|
||||
if (!draft) return mode === 'edit' ? runDeployedWithArgs(path, args) : runByPathLegit(path, args)
|
||||
if (!$workspaceStore || !draft.script.content || !draft.script.language) return undefined
|
||||
activeRunnables.arm(`script:${path}`)
|
||||
try {
|
||||
const jobId = await JobService.runScriptPreview({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
content: draft.script.content,
|
||||
language: draft.script.language,
|
||||
path,
|
||||
args: { ...args, _wmill_skip_asset_dispatch: true }
|
||||
}
|
||||
})
|
||||
runsPendingJobId = jobId
|
||||
runsRefreshKey++
|
||||
activeRunnable = { kind: 'script', path }
|
||||
activeRunnableJobId = jobId
|
||||
return jobId
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Run failed: ${e?.body ?? e?.message ?? e}`, true)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
// Same as a canvas run of a deployed node in edit mode: this step only.
|
||||
function runDeployedWithArgs(path: string, args: Record<string, any>) {
|
||||
return runByPathLegit(path, { ...args, _wmill_skip_asset_dispatch: true })
|
||||
}
|
||||
function openDataUploadRun(scriptPath: string) {
|
||||
uploadInputsPath = scriptPath
|
||||
// Opening the run form is meaningless while the pane is hidden —
|
||||
// unhide first, same as a plain node click.
|
||||
panelHidden = false
|
||||
@@ -3019,9 +2997,12 @@
|
||||
onDeleteTrigger={mode === 'edit' ? deleteAttachedTrigger : undefined}
|
||||
onOpenWebhook={openWebhookDrawer}
|
||||
onOpenDataUpload={openDataUploadRun}
|
||||
{readyDataUploadPaths}
|
||||
runFormInitialArgs={openScriptPath ? dataUploadArgs[openScriptPath]?.args : undefined}
|
||||
onRunFormArgsChange={stageRunFormArgs}
|
||||
readyDataUploadPaths={dataUploads.readyPaths}
|
||||
runFormInitialArgs={openScriptPath ? dataUploads.argsFor(openScriptPath) : undefined}
|
||||
onRunFormArgsChange={dataUploads.stage}
|
||||
inputsOnly={uploadInputsPath !== undefined && uploadInputsPath === openScriptPath}
|
||||
onRunInputs={runUploadInputs}
|
||||
onShowScript={() => (uploadInputsPath = undefined)}
|
||||
onSelect={handleCanvasSelect}
|
||||
onAddScriptForAsset={mode === 'edit' ? handleAddScriptForAsset : undefined}
|
||||
onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined}
|
||||
|
||||
Reference in New Issue
Block a user