mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 16:02:12 +00:00
feat: show restart from failed step in run page top bar (#11317)
* feat: show restart from failed step in run page top bar Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep failed-step restart button when selection is not restartable Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add restart link to the failed run status line Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: use the input error color for the progress bar error state Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: mention restarting on a fixed flow version in the error line Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show a chevron on the restart button when it opens a picker Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: shrink run page top bar buttons so the bar fits on one line Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: target the step that failed the run, not a tolerated failure Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: address review nits on the restart link Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: skip canceled runs and match the restart link label to its text Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- 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
5b03c1732e
commit
62d0088de5
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button } from './common'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import { Play, RefreshCw } from 'lucide-svelte'
|
||||
import { ChevronDown, Play, RefreshCw } from 'lucide-svelte'
|
||||
import { FlowService, JobService, type FlowVersion } from '$lib/gen'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
enterpriseOnly?: boolean
|
||||
variant?: 'default' | 'accent'
|
||||
unifiedSize?: 'xs' | 'sm' | 'md' | 'lg'
|
||||
/** `link` renders the trigger as inline text, for use inside a sentence. */
|
||||
triggerStyle?: 'button' | 'link'
|
||||
/**
|
||||
* For nested-step restarts: path of ancestor containers from the top-level
|
||||
* step down to the leaf. When provided, the LAST entry's step_id is the
|
||||
@@ -71,6 +73,7 @@
|
||||
enterpriseOnly = false,
|
||||
variant = 'default',
|
||||
unifiedSize = 'md',
|
||||
triggerStyle = 'button',
|
||||
nestedPath = undefined,
|
||||
nestedTopStepId = undefined,
|
||||
nestedTopBranchOrIterationN = undefined,
|
||||
@@ -89,6 +92,10 @@
|
||||
: selectedJobStep
|
||||
)
|
||||
|
||||
const restartTitle = $derived(
|
||||
`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`
|
||||
)
|
||||
|
||||
// Sentinel value meaning "use the same version as the original run" (backend receives undefined)
|
||||
const RUN_VERSION_SENTINEL = -1
|
||||
|
||||
@@ -142,8 +149,13 @@
|
||||
})
|
||||
return out
|
||||
})
|
||||
// A link has no button of its own to restart on click: it always opens the
|
||||
// popover, whose Restart button then acts as the confirmation.
|
||||
const needsPopup = $derived(
|
||||
!!flowPath || iterationFields.length > 0 || selectedJobStepType !== 'single'
|
||||
triggerStyle === 'link' ||
|
||||
!!flowPath ||
|
||||
iterationFields.length > 0 ||
|
||||
selectedJobStepType !== 'single'
|
||||
)
|
||||
let selectedFlowVersion: number = $state(RUN_VERSION_SENTINEL)
|
||||
let flowVersions: Array<FlowVersion> = $state([])
|
||||
@@ -244,12 +256,21 @@
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet restartTriggerButton(usePlayIcon: boolean)}
|
||||
{#if triggerStyle === 'link'}
|
||||
<!-- The popover trigger is already a <button>; nesting another is invalid. -->
|
||||
<span class="underline hover:no-underline" title={restartTitle}>Restart from this step</span>
|
||||
{:else}
|
||||
{@render restartButton(usePlayIcon)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet restartButton(usePlayIcon: boolean)}
|
||||
<Button
|
||||
title={`Re-start this flow from step ${displayStepId} (included).${enterpriseOnly ? ' This is a feature only available in enterprise edition.' : ''}`}
|
||||
title={restartTitle}
|
||||
{variant}
|
||||
{unifiedSize}
|
||||
{disabled}
|
||||
startIcon={{ icon: usePlayIcon ? Play : RefreshCw }}
|
||||
endIcon={needsPopup ? { icon: ChevronDown } : undefined}
|
||||
nonCaptureEvent={!usePlayIcon || !!flowPath}
|
||||
onClick={() => {
|
||||
if (usePlayIcon && !flowPath) {
|
||||
@@ -273,6 +294,12 @@
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-start' }}
|
||||
disablePopup={!needsPopup}
|
||||
triggerAttrs={{
|
||||
'aria-label':
|
||||
triggerStyle === 'link'
|
||||
? `Restart from this step (${displayStepId})`
|
||||
: `Re-start from ${displayStepId}`
|
||||
}}
|
||||
on:openChange={(e) => {
|
||||
if (e.detail) {
|
||||
loadFlowVersions()
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{@render middle?.()}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if right}
|
||||
{@render right?.()}
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { type Job } from '$lib/gen'
|
||||
import ProgressBar from '../progressBar/ProgressBar.svelte'
|
||||
import { forLater } from '$lib/forLater'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
job?: Job | undefined
|
||||
@@ -11,6 +12,7 @@
|
||||
textPosition?: 'top' | 'bottom'
|
||||
// Prefer step ID over step index when both are available
|
||||
showStepId?: boolean
|
||||
errorAction?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,7 +21,8 @@
|
||||
class: className,
|
||||
slim = false,
|
||||
textPosition = 'top',
|
||||
showStepId = false
|
||||
showStepId = false,
|
||||
errorAction
|
||||
}: Props = $props()
|
||||
|
||||
let error: number | undefined = $state(undefined)
|
||||
@@ -183,4 +186,5 @@
|
||||
{isCanceled}
|
||||
{isScheduled}
|
||||
{isSkipped}
|
||||
{errorAction}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
// Remove padding/margin, border radius and titles
|
||||
|
||||
@@ -36,6 +37,8 @@
|
||||
isScheduled?: boolean
|
||||
// Whether the job was skipped (early-stop labelled as skipped)
|
||||
isSkipped?: boolean
|
||||
/** Rendered after "Error occurred" when the bar is in its error state. */
|
||||
errorAction?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -56,7 +59,8 @@
|
||||
isWaitingForEvents = false,
|
||||
isCanceled = false,
|
||||
isScheduled = false,
|
||||
isSkipped = false
|
||||
isSkipped = false,
|
||||
errorAction
|
||||
}: Props = $props()
|
||||
let duration = 200
|
||||
|
||||
@@ -148,7 +152,7 @@
|
||||
<div
|
||||
class="flex justify-between items-end font-medium transition-colors duration-300 ease-in-out {error !=
|
||||
undefined || isCanceled
|
||||
? 'text-red-700 dark:text-red-200'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-700 dark:text-blue-200'}"
|
||||
>
|
||||
<div class={twMerge(slim ? 'text-xs' : 'text-sm', 'flex items-center gap-1')}>
|
||||
@@ -184,6 +188,9 @@
|
||||
{/if}
|
||||
</span>
|
||||
{/key}
|
||||
{#if status == 'error' && errorAction}
|
||||
{@render errorAction()}
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class={twMerge(slim ? 'text-xs' : 'text-sm', 'transition-all duration-200 ease-in-out')}
|
||||
|
||||
@@ -152,12 +152,41 @@
|
||||
string,
|
||||
{ modules: import('$lib/gen').FlowModule[]; groups?: any[] }
|
||||
> = $state({})
|
||||
const restart = useNestedRestartState({
|
||||
const selectedRestart = useNestedRestartState({
|
||||
selectedJobStep: () => selectedJobStep,
|
||||
job: () => job,
|
||||
graphModuleStates: () => graphModuleStates,
|
||||
expandedSubflows: () => expandedSubflows
|
||||
})
|
||||
// When the selection can't be restarted from (nothing clicked, Input/Result,
|
||||
// a step inside a parallel loop), the button targets the top-level step that
|
||||
// failed the run, so it is visible without hunting for it in a large graph.
|
||||
// A continue-on-error step keeps its `Failure` status on a run that succeeds,
|
||||
// so only a failed run counts, and its last `Failure` is the one that ended it.
|
||||
const failedTopLevelStep = $derived.by(() => {
|
||||
if (job?.type !== 'CompletedJob' || job.success !== false || job.canceled) return undefined
|
||||
const failures = job.flow_status?.modules?.filter((m) => m.type === 'Failure') ?? []
|
||||
return failures[failures.length - 1]?.id
|
||||
})
|
||||
const failedRestart = useNestedRestartState({
|
||||
selectedJobStep: () => failedTopLevelStep,
|
||||
job: () => job,
|
||||
graphModuleStates: () => graphModuleStates,
|
||||
expandedSubflows: () => expandedSubflows
|
||||
})
|
||||
const selectionRestartable = $derived(
|
||||
selectedJobStep !== undefined &&
|
||||
(selectedRestart.topLevelRestartable || selectedRestart.nestedRestartSupported)
|
||||
)
|
||||
const restartStep = $derived(selectionRestartable ? selectedJobStep : failedTopLevelStep)
|
||||
const restart = $derived(selectionRestartable ? selectedRestart : failedRestart)
|
||||
function canRestart(state: ReturnType<typeof useNestedRestartState>) {
|
||||
return (
|
||||
job?.type === 'CompletedJob' &&
|
||||
job.job_kind === 'flow' &&
|
||||
(state.topLevelRestartable || state.nestedRestartSupported)
|
||||
)
|
||||
}
|
||||
|
||||
let testIsLoading = $state(false)
|
||||
let jobLoader: JobLoader | undefined = $state(undefined)
|
||||
@@ -643,6 +672,36 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#snippet flowRestartButton(
|
||||
state: ReturnType<typeof useNestedRestartState>,
|
||||
step: string,
|
||||
triggerStyle: 'button' | 'link'
|
||||
)}
|
||||
{#if job}
|
||||
<FlowRestartButton
|
||||
jobId={job.id}
|
||||
selectedJobStep={step}
|
||||
selectedJobStepType={state.selectedJobStepType}
|
||||
restartBranchNames={state.restartBranchNames}
|
||||
nestedPath={state.nestedRestartSupported ? state.nestedRestartPath : undefined}
|
||||
nestedTopStepId={state.nestedRestartTopStepId}
|
||||
nestedTopBranchOrIterationN={state.nestedRestartTopBranchOrIterationN}
|
||||
presetIterationN={state.topLevelLoopIteration}
|
||||
iterationCounts={state.iterationCounts}
|
||||
nestedPathIterationCounts={state.nestedPathIterationCounts}
|
||||
onRestartComplete={(newJobId) => {
|
||||
goto('/run/' + newJobId + '?workspace=' + $workspaceStore)
|
||||
}}
|
||||
flowPath={job.script_path}
|
||||
flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined}
|
||||
disabled={!$enterpriseLicense}
|
||||
enterpriseOnly={!$enterpriseLicense}
|
||||
{triggerStyle}
|
||||
unifiedSize="sm"
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Portal name="persistent-run">
|
||||
<PersistentScriptDrawer bind:this={persistentScriptDrawer} />
|
||||
</Portal>
|
||||
@@ -738,13 +797,13 @@
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Trash }}
|
||||
/>
|
||||
{/snippet}
|
||||
</Dropdown>
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
|
||||
<Button href={runsHref} variant="default" unifiedSize="md" startIcon={{ icon: List }}>
|
||||
<Button href={runsHref} variant="default" unifiedSize="sm" startIcon={{ icon: List }}>
|
||||
View runs
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -772,7 +831,7 @@
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button nonCaptureEvent variant="default" unifiedSize="md" startIcon={{ icon: Share2 }}>
|
||||
<Button nonCaptureEvent variant="default" unifiedSize="sm" startIcon={{ icon: Share2 }}>
|
||||
Share
|
||||
</Button>
|
||||
{/snippet}
|
||||
@@ -794,7 +853,7 @@
|
||||
class="h-auto"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button nonCaptureEvent unifiedSize="md" variant="subtle">
|
||||
<Button nonCaptureEvent unifiedSize="sm" variant="subtle">
|
||||
<div class="flex flex-row items-center">
|
||||
<EllipsisVertical size={14} />
|
||||
</div>
|
||||
@@ -805,7 +864,7 @@
|
||||
{/if}
|
||||
{#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: GitBranch }}
|
||||
on:click={forkPreview}
|
||||
@@ -817,7 +876,7 @@
|
||||
{/if}
|
||||
{#if persistentScriptDefinition !== undefined}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: Activity }}
|
||||
on:click={() => {
|
||||
@@ -830,7 +889,7 @@
|
||||
{#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)}
|
||||
{#if !forceCancel}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
destructive
|
||||
startIcon={{ icon: TimerOff }}
|
||||
@@ -848,7 +907,7 @@
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
destructive
|
||||
startIcon={{ icon: TimerOff }}
|
||||
@@ -864,7 +923,7 @@
|
||||
{/if}
|
||||
{#if job?.schedule_path}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
if (!job || !job.schedule_path) {
|
||||
@@ -875,26 +934,8 @@
|
||||
startIcon={{ icon: Calendar }}>Edit schedule</Button
|
||||
>
|
||||
{/if}
|
||||
{#if job?.type === 'CompletedJob' && job?.job_kind === 'flow' && selectedJobStep !== undefined && (restart.topLevelRestartable || restart.nestedRestartSupported) && job.id}
|
||||
<FlowRestartButton
|
||||
jobId={job.id}
|
||||
{selectedJobStep}
|
||||
selectedJobStepType={restart.selectedJobStepType}
|
||||
restartBranchNames={restart.restartBranchNames}
|
||||
nestedPath={restart.nestedRestartSupported ? restart.nestedRestartPath : undefined}
|
||||
nestedTopStepId={restart.nestedRestartTopStepId}
|
||||
nestedTopBranchOrIterationN={restart.nestedRestartTopBranchOrIterationN}
|
||||
presetIterationN={restart.topLevelLoopIteration}
|
||||
iterationCounts={restart.iterationCounts}
|
||||
nestedPathIterationCounts={restart.nestedPathIterationCounts}
|
||||
onRestartComplete={(newJobId) => {
|
||||
goto('/run/' + newJobId + '?workspace=' + $workspaceStore)
|
||||
}}
|
||||
flowPath={job.script_path}
|
||||
flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined}
|
||||
disabled={!$enterpriseLicense}
|
||||
enterpriseOnly={!$enterpriseLicense}
|
||||
/>
|
||||
{#if restartStep !== undefined && canRestart(restart)}
|
||||
{@render flowRestartButton(restart, restartStep, 'button')}
|
||||
{/if}
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
|
||||
<Button
|
||||
@@ -909,7 +950,7 @@
|
||||
`#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
|
||||
)
|
||||
}}
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
loading={runImmediatelyLoading}
|
||||
@@ -936,10 +977,9 @@
|
||||
on:click={() => {
|
||||
$initialArgsStore = job?.args
|
||||
}}
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
disabled={!showEditButton}
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}>Edit</Button
|
||||
>
|
||||
{#if showEditButton}
|
||||
@@ -951,7 +991,7 @@
|
||||
target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' },
|
||||
workspaceId: $workspaceStore ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'md' }}
|
||||
btnProps={{ unifiedSize: 'sm' }}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -962,9 +1002,8 @@
|
||||
onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', {
|
||||
hasHref: true
|
||||
})}
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
size="sm"
|
||||
startIcon={{ icon: Pen }}>{editInForkLabel($workspaceStore, $userWorkspaces)}</Button
|
||||
>
|
||||
{/if}
|
||||
@@ -973,7 +1012,7 @@
|
||||
{#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'}
|
||||
<Button
|
||||
href={viewHref}
|
||||
unifiedSize="md"
|
||||
unifiedSize="sm"
|
||||
variant="accent"
|
||||
startIcon={{
|
||||
icon:
|
||||
@@ -1024,7 +1063,18 @@
|
||||
textPosition="bottom"
|
||||
slim
|
||||
showStepId
|
||||
/>
|
||||
>
|
||||
{#snippet errorAction()}
|
||||
{#if failedTopLevelStep}
|
||||
<span>at step {failedTopLevelStep}</span>
|
||||
{#if $enterpriseLicense && canRestart(failedRestart)}
|
||||
<span>·</span>
|
||||
{@render flowRestartButton(failedRestart, failedTopLevelStep, 'link')}
|
||||
<span>on this flow version, or on a new one after you fix it</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FlowProgressBar>
|
||||
{#if suspendStatus}
|
||||
<FlowExecutionStatus
|
||||
{job}
|
||||
|
||||
Reference in New Issue
Block a user