mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat: the pipeline page fills the band
Found by sweeping every route under the band: the pipeline editor drew its own row beneath it, 48px of a second header, while the band named only the section. Its name is the band's now, the folder it is open on sits with that name — the switcher is how you change pipelines, so it belongs to the name rather than to the actions — and the mode switch, deploy-all, activity, refresh and overflow menu are the bar's actions. The arrow back to the list stays: nothing else on the page points there, and the sidebar does not list pipelines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f37d878580
commit
24223ef9cd
@@ -2,6 +2,7 @@
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths'
|
||||
import { base } from '$lib/base'
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { page } from '$app/state'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
@@ -98,7 +99,6 @@
|
||||
FolderSearch,
|
||||
History,
|
||||
Loader2,
|
||||
NetworkIcon,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -2386,203 +2386,209 @@
|
||||
<title>Pipeline · {folder} — Windmill</title>
|
||||
</svelte:head>
|
||||
|
||||
<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-12 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
|
||||
<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">{mode === 'edit' ? 'Pipeline editor' : 'Pipeline'}</h1>
|
||||
<span class="text-tertiary text-sm">·</span>
|
||||
{#if otherPipelineFolders.length === 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (pickerModalOpen = true)}
|
||||
<!-- The page header names the pipeline and carries its controls: the folder it is open on sits
|
||||
with that name, and the mode switch and the graph's own actions are the bar's actions. -->
|
||||
<PageHeaderContent
|
||||
section={{ label: mode === 'edit' ? 'Pipeline editor' : 'Pipeline' }}
|
||||
afterName={pipelineFolder}
|
||||
actions={pipelineActions}
|
||||
actionsFlexible
|
||||
/>
|
||||
|
||||
{#snippet pipelineFolder()}
|
||||
<!-- Which folder the pipeline is open on, beside the name of what this page is: switching it is
|
||||
switching pipelines, so it belongs to the name rather than to the actions. The arrow leads
|
||||
back to the list, which nothing else on the page points to. -->
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
href="{base}/pipeline"
|
||||
startIcon={{ icon: ArrowLeft }}
|
||||
iconOnly
|
||||
title="Back to pipelines"
|
||||
/>
|
||||
{#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-tertiary 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-tertiary 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-tertiary 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>
|
||||
{#if !isOperator}
|
||||
<!-- Center group: the mode toggle is the page's primary control —
|
||||
anchored between the two flex-1 side groups so it stays
|
||||
centered, with breathing room on both sides. -->
|
||||
<div class="flex flex-row items-center gap-2 shrink-0 px-6">
|
||||
<PipelineModeToggle {mode} draftCount={pe.drafts.size} onModeChange={(m) => setMode(m)} />
|
||||
{#if mode === 'view' && pe.drafts.size > 0}
|
||||
<!-- View variant: overlay the unsaved drafts onto the deployed
|
||||
graph — "what View will show once they're deployed". -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (includeDrafts = !includeDrafts)}
|
||||
class={twMerge(
|
||||
'flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors',
|
||||
includeDrafts
|
||||
? 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-surface border-gray-300 dark:border-gray-600 text-secondary hover:bg-surface-hover'
|
||||
)}
|
||||
title={includeDrafts
|
||||
? 'Showing undeployed drafts overlaid on the deployed pipeline — click to hide them'
|
||||
: 'Overlay your undeployed drafts to see what the pipeline will look like once deployed'}
|
||||
>
|
||||
<Telescope size={14} />
|
||||
{includeDrafts ? 'Showing' : 'Show'}
|
||||
{pe.drafts.size} draft{pe.drafts.size === 1 ? '' : 's'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-2 flex-1 justify-end">
|
||||
{#if !isOperator && allPipelineScripts.length > 0}
|
||||
<!-- Only the armed state surfaces on the bar (arming lives in the ⋮
|
||||
menu) — a compact disarm pill. -->
|
||||
{#if recordingMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (recordingMode = false)}
|
||||
disabled={!!cascadeRunningRoot}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors disabled:opacity-50 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-700 dark:text-red-400"
|
||||
title={RECORDING_ARMED_HINT}
|
||||
>
|
||||
<Circle size={12} class="fill-red-600 text-red-600 animate-pulse" />
|
||||
Recording
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Pipeline-level run: always visible in both View and Edit so a
|
||||
run never requires hunting for a specific node's play button
|
||||
(dbt `build` / Dagster "Materialize all"). Runs every script in
|
||||
dependency order (roots first, cascading downstream) over the
|
||||
displayed graph — deployed in View, draft-overlaid in Edit. -->
|
||||
<Button
|
||||
variant="accent-secondary"
|
||||
unifiedSize="sm"
|
||||
startIcon={{
|
||||
icon: cascadeRunningRoot ? Loader2 : Play,
|
||||
classes: cascadeRunningRoot ? 'animate-spin' : undefined
|
||||
}}
|
||||
onclick={runWholePipeline}
|
||||
disabled={!!cascadeRunningRoot}
|
||||
title={cascadeRunningRoot
|
||||
? 'A pipeline run is already in progress'
|
||||
: `Run all ${allPipelineScripts.length} script${
|
||||
allPipelineScripts.length === 1 ? '' : 's'
|
||||
} in dependency order (roots first, cascading downstream)`}
|
||||
>
|
||||
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if mode === 'edit' && saveErrors.size > 0}
|
||||
<!-- Compact errors popover anchored next to Save all so users
|
||||
can see exactly which drafts failed and why without losing
|
||||
the editor context. Drafts that succeed disappear from
|
||||
the map; the ones still listed here are the unresolved
|
||||
failures. -->
|
||||
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
|
||||
title="View save errors"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
<span>{saveErrors.size} failed</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Save errors</span>
|
||||
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
|
||||
{#each [...saveErrors.entries()] as [path, message]}
|
||||
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
|
||||
<span class="text-2xs font-mono text-emphasis">{path}</span>
|
||||
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if mode === 'edit' && pe.drafts.size > 0}
|
||||
<!-- Draft autosave status for the whole pipeline bundle. Distinct
|
||||
from "Save all", which DEPLOYS the drafts — this only reflects
|
||||
that in-flight edits are persisted to the per-user server draft. -->
|
||||
{#if $workspaceStore}
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind={PIPELINE_DRAFT_KIND}
|
||||
path={pipelineDraftPath}
|
||||
draftOnly
|
||||
loadedFromDraft={pe.loadedFromDbDraft}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: savingAll ? Loader2 : Save }}
|
||||
onclick={saveAllDrafts}
|
||||
disabled={savingAll}
|
||||
title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`}
|
||||
>
|
||||
{savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if mode === 'view'}
|
||||
<Button
|
||||
variant={activityShowing ? 'accent-secondary' : 'subtle'}
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: History }}
|
||||
onclick={toggleActivity}
|
||||
title={activityShowing
|
||||
? 'Hide the activity panel'
|
||||
: 'Show the pipeline activity feed (recent and live runs)'}
|
||||
>
|
||||
Activity
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
onclick={() => graphRes.refetch()}
|
||||
disabled={graphRes.loading}
|
||||
iconOnly
|
||||
title="Refresh"
|
||||
/>
|
||||
<DropdownV2 size="sm" items={overflowMenuItems} />
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if summary.length > 0}
|
||||
<span class="text-xs text-tertiary">· {summary.join(' · ')}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet pipelineActions()}
|
||||
{#if !isOperator}
|
||||
<!-- Center group: the mode toggle is the page's primary control —
|
||||
anchored between the two flex-1 side groups so it stays
|
||||
centered, with breathing room on both sides. -->
|
||||
<div class="flex flex-row items-center gap-2 shrink-0 px-6">
|
||||
<PipelineModeToggle {mode} draftCount={pe.drafts.size} onModeChange={(m) => setMode(m)} />
|
||||
{#if mode === 'view' && pe.drafts.size > 0}
|
||||
<!-- View variant: overlay the unsaved drafts onto the deployed
|
||||
graph — "what View will show once they're deployed". -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (includeDrafts = !includeDrafts)}
|
||||
class={twMerge(
|
||||
'flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors',
|
||||
includeDrafts
|
||||
? 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-surface border-gray-300 dark:border-gray-600 text-secondary hover:bg-surface-hover'
|
||||
)}
|
||||
title={includeDrafts
|
||||
? 'Showing undeployed drafts overlaid on the deployed pipeline — click to hide them'
|
||||
: 'Overlay your undeployed drafts to see what the pipeline will look like once deployed'}
|
||||
>
|
||||
<Telescope size={14} />
|
||||
{includeDrafts ? 'Showing' : 'Show'}
|
||||
{pe.drafts.size} draft{pe.drafts.size === 1 ? '' : 's'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !isOperator && allPipelineScripts.length > 0}
|
||||
<!-- Only the armed state surfaces on the bar (arming lives in the ⋮
|
||||
menu) — a compact disarm pill. -->
|
||||
{#if recordingMode}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (recordingMode = false)}
|
||||
disabled={!!cascadeRunningRoot}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-xs font-medium transition-colors disabled:opacity-50 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-700 dark:text-red-400"
|
||||
title={RECORDING_ARMED_HINT}
|
||||
>
|
||||
<Circle size={12} class="fill-red-600 text-red-600 animate-pulse" />
|
||||
Recording
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Pipeline-level run: always visible in both View and Edit so a
|
||||
run never requires hunting for a specific node's play button
|
||||
(dbt `build` / Dagster "Materialize all"). Runs every script in
|
||||
dependency order (roots first, cascading downstream) over the
|
||||
displayed graph — deployed in View, draft-overlaid in Edit. -->
|
||||
<Button
|
||||
variant="accent-secondary"
|
||||
unifiedSize="sm"
|
||||
startIcon={{
|
||||
icon: cascadeRunningRoot ? Loader2 : Play,
|
||||
classes: cascadeRunningRoot ? 'animate-spin' : undefined
|
||||
}}
|
||||
onclick={runWholePipeline}
|
||||
disabled={!!cascadeRunningRoot}
|
||||
title={cascadeRunningRoot
|
||||
? 'A pipeline run is already in progress'
|
||||
: `Run all ${allPipelineScripts.length} script${
|
||||
allPipelineScripts.length === 1 ? '' : 's'
|
||||
} in dependency order (roots first, cascading downstream)`}
|
||||
>
|
||||
{cascadeRunningRoot ? 'Running…' : 'Run pipeline'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if mode === 'edit' && saveErrors.size > 0}
|
||||
<!-- Compact errors popover anchored next to Save all so users
|
||||
can see exactly which drafts failed and why without losing
|
||||
the editor context. Drafts that succeed disappear from
|
||||
the map; the ones still listed here are the unresolved
|
||||
failures. -->
|
||||
<Popover placement="bottom-end" contentClasses="p-3 max-w-[480px]" usePointerDownOutside>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-md text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors text-xs font-medium"
|
||||
title="View save errors"
|
||||
>
|
||||
<AlertTriangle size={14} />
|
||||
<span>{saveErrors.size} failed</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-emphasis">Save errors</span>
|
||||
<div class="flex flex-col gap-2 max-h-72 overflow-y-auto">
|
||||
{#each [...saveErrors.entries()] as [path, message]}
|
||||
<div class="flex flex-col gap-0.5 border-l-2 border-red-400 pl-2">
|
||||
<span class="text-2xs font-mono text-emphasis">{path}</span>
|
||||
<span class="text-2xs text-red-600 dark:text-red-400 break-words">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if mode === 'edit' && pe.drafts.size > 0}
|
||||
<!-- Draft autosave status for the whole pipeline bundle. Distinct
|
||||
from "Save all", which DEPLOYS the drafts — this only reflects
|
||||
that in-flight edits are persisted to the per-user server draft. -->
|
||||
{#if $workspaceStore}
|
||||
<AutosaveIndicator
|
||||
workspace={$workspaceStore}
|
||||
itemKind={PIPELINE_DRAFT_KIND}
|
||||
path={pipelineDraftPath}
|
||||
draftOnly
|
||||
loadedFromDraft={pe.loadedFromDbDraft}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: savingAll ? Loader2 : Save }}
|
||||
onclick={saveAllDrafts}
|
||||
disabled={savingAll}
|
||||
title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`}
|
||||
>
|
||||
{savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if mode === 'view'}
|
||||
<Button
|
||||
variant={activityShowing ? 'accent-secondary' : 'subtle'}
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: History }}
|
||||
onclick={toggleActivity}
|
||||
title={activityShowing
|
||||
? 'Hide the activity panel'
|
||||
: 'Show the pipeline activity feed (recent and live runs)'}
|
||||
>
|
||||
Activity
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
onclick={() => graphRes.refetch()}
|
||||
disabled={graphRes.loading}
|
||||
iconOnly
|
||||
title="Refresh"
|
||||
/>
|
||||
<DropdownV2 size="sm" items={overflowMenuItems} />
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user