diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte
index 9ae1dc399a..018a62ab5d 100644
--- a/frontend/src/lib/components/NavBreadcrumb.svelte
+++ b/frontend/src/lib/components/NavBreadcrumb.svelte
@@ -372,12 +372,17 @@ not this component) — so the lead can run the whole line.
{/if}
- {#if !nameOnly && (item || section || routePage)}
+
+ {@const pathShown = f.path || itemIsLast}
+ {#if !nameOnly && (item ? pathShown : section || routePage)}
{@render slash()}
{/if}
{#if item}
- {#if item.path}
+ {#if !pathShown}
+ {@render afterNameSlot(measure)}
+ {:else if item.path}
{@const shown = shownPathLevels(pathLevels, f)}
@@ -484,14 +489,19 @@ not this component) — so the lead can run the whole line.
SEGMENT,
LAST,
'hover:bg-transparent hover:text-emphasis',
- section.content ? '' : 'truncate'
+ section.content ? '' : 'shrink-0'
)}
aria-current={measure ? undefined : 'page'}
bind:clientWidth={
() => sectionContentWidth, (w) => !measure && section.content && (sectionContentWidth = w)
}
>
- {#if section.content}{@render section.content()}{:else}{section.label}{/if}
+
+ {#if section.content}{@render section.content()}{:else}{section.label}{/if}
{/if}
{@render afterNameSlot(measure)}
@@ -553,9 +563,13 @@ not this component) — so the lead can run the whole line.
bind:clientWidth={measures.fork}>{!nameOnly && showFork ? scopeName : ''}
{item?.path ? (pathLevels[pathLevels.length - 1] ?? '') : ''}{item?.path
+ ? (pathLevels[pathLevels.length - 1] ?? '')
+ : !item && section && !section.kind && !section.content
+ ? section.label
+ : ''}
{/if}
diff --git a/frontend/src/lib/components/runs/RunDetail.svelte b/frontend/src/lib/components/runs/RunDetail.svelte
index 1241bc9c60..80355ed3cf 100644
--- a/frontend/src/lib/components/runs/RunDetail.svelte
+++ b/frontend/src/lib/components/runs/RunDetail.svelte
@@ -20,7 +20,8 @@
isHubFlowPath,
isFlowPreview,
isNotFlow,
- isScriptPreview
+ isScriptPreview,
+ type Item
} from '$lib/utils'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
@@ -714,10 +715,178 @@
onDestroy(resetFavicon)
}
- // The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way
- // grudgingly, so the buttons drop their labels rather than push each other off the end.
- // Embedded, the buttons have a row of their own that wraps.
- const compactBar = $derived(!embedded && pageHeader.barWidth > 0 && pageHeader.barWidth < 1150)
+ // The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: first the
+ // buttons drop their labels, then the plain actions go into one menu. Embedded, the buttons
+ // have a row of their own that wraps.
+ const compactBar = $derived(!embedded && pageHeader.actionFold >= 1)
+ const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 2)
+
+ const isScriptJob = $derived(job?.job_kind === 'script')
+ const isRunnableJob = $derived(
+ job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'
+ )
+ const isHubFlowPreview = $derived(isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path))
+ const runsHref = $derived(`/runs/${job?.script_path}${!isScriptJob ? '?jobKind=flow' : ''}`)
+ const stem = $derived(job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`)
+ // A script is addressed by the hash this run executed. Embedded, that goes in the query: the
+ // host's tabs are keyed by an item's path, and a hash there would be a tab on no item.
+ const viewHref = $derived(
+ isScriptJob && embedded
+ ? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
+ : `${stem}/get/${isScriptJob ? job?.script_hash : job?.script_path}`
+ )
+ const editHref = $derived(`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`)
+ const forkPreviewLabel = $derived(
+ isHubFlowPreview
+ ? 'Fork flow into workspace'
+ : `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`
+ )
+ const viewLabel = $derived(`View ${job?.job_kind === 'script_hub' ? 'script' : job?.job_kind}`)
+ const canDelete = $derived(
+ !!job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))
+ )
+ const canCancel = $derived(
+ !!job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)
+ )
+ const canDebug = $derived(
+ (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) &&
+ !!job?.['running'] &&
+ job?.parent_job == undefined
+ )
+ const canEdit = $derived(
+ (job?.job_kind === 'script' || job?.job_kind === 'flow') &&
+ !actingUser?.operator &&
+ editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})
+ )
+ const canEditInFork = $derived(
+ (job?.job_kind === 'script' || job?.job_kind === 'flow') &&
+ !actingUser?.operator &&
+ !showEditButton &&
+ !isCloudHosted() &&
+ editInForkAllowed(workspaceId, $userWorkspaces)
+ )
+
+ async function runAgain() {
+ // The form this lands on rebuilds the whole project. When resuming THIS run is the cheaper
+ // thing to do, name it so the form can offer that in one click rather than leaving the
+ // reader to know.
+ const from = dbtResumable
+ ? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
+ : ''
+ onNavigate(
+ viewHref + from + `#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
+ )
+ }
+
+ function cancel() {
+ if (!job?.id) return
+ cancelJob(job.id)
+ // The first cancel asks the job to stop; if it has not after three seconds, the same
+ // control offers to force it.
+ if (!forceCancel) setTimeout(() => (forceCancel = true), 3001)
+ }
+
+ // The same actions as the row, most used first; the restart and session controls stay beside
+ // the menu, since each opens a popover of its own that a menu row cannot hold.
+ const menuItems = $derived.by((): Item[] => {
+ if (!job) return []
+ const kind = isScriptJob ? 'script' : 'flow'
+ return [
+ { displayName: viewLabel, icon: Code2, href: viewHref, hide: !isRunnableJob },
+ { displayName: 'Run again', icon: RefreshCw, action: runAgain, hide: !isRunnableJob },
+ {
+ displayName: 'dbt retry with same args',
+ icon: RefreshCw,
+ action: resumeDbtRun,
+ hide: !isRunnableJob || !dbtResumable
+ },
+ {
+ displayName: 'Run immediately with same args',
+ icon: RefreshCw,
+ action: () => runImmediately(),
+ hide: !isRunnableJob
+ },
+ {
+ displayName: 'Edit',
+ icon: Pen,
+ href: editHref,
+ action: () => ($initialArgsStore = job?.args),
+ disabled: !showEditButton,
+ hide: !canEdit
+ },
+ {
+ displayName: editInForkLabel(workspaceId, $userWorkspaces),
+ icon: Pen,
+ href: buildForkEditUrl(kind, job.script_path ?? '', workspaceId),
+ action: (e) => editInFork(e, kind, job?.script_path ?? ''),
+ hide: !canEditInFork
+ },
+ {
+ displayName: 'Run now',
+ icon: Play,
+ action: () => job?.id && runJobNow(job.id),
+ hide: !canRunNow
+ },
+ {
+ displayName: 'Edit schedule',
+ icon: Calendar,
+ action: () =>
+ job?.schedule_path && scheduleEditor?.openEdit(job.schedule_path, job.job_kind == 'flow'),
+ hide: !job.schedule_path
+ },
+ {
+ displayName: forkPreviewLabel,
+ icon: GitBranch,
+ action: forkPreview,
+ hide: !(isFlowPreview(job.job_kind) || isScriptPreview(job.job_kind))
+ },
+ {
+ displayName: 'Current runs',
+ icon: Activity,
+ action: () => persistentScriptDrawer?.open?.(persistentScriptDefinition),
+ hide: persistentScriptDefinition === undefined
+ },
+ {
+ displayName: 'View runs',
+ icon: List,
+ href: runsHref,
+ hide: !canDelete || !(job.job_kind === 'script' || job.job_kind === 'flow')
+ },
+ {
+ displayName: 'Copy link for members',
+ icon: Users,
+ action: () => job && shareReadLink(job.id),
+ separatorTop: true
+ },
+ {
+ displayName: 'Copy public link',
+ icon: Globe,
+ disabled: !canSharePublicly,
+ action: () => job && sharePublicLink(job.id)
+ },
+ {
+ displayName: 'Show flow debug info',
+ icon: EllipsisVertical,
+ action: debugInfo,
+ hide: !canDebug
+ },
+ {
+ displayName: forceCancel ? 'Force cancel' : 'Cancel',
+ icon: TimerOff,
+ type: 'delete',
+ action: cancel,
+ hide: !canCancel,
+ separatorTop: true
+ },
+ {
+ displayName: 'Delete result, logs and args (admin only)',
+ icon: Trash,
+ type: 'delete',
+ action: () => job?.id && deleteCompletedJob(job.id),
+ hide: !canDelete
+ }
+ ]
+ })
@@ -890,14 +1059,42 @@
{@render runActions()}
{:else}
-
+
{/if}
{#snippet runActions()}
- {@const isScript = job?.job_kind === 'script'}
- {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)}
- {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`}
- {#if job && 'deleted' in job && !job?.deleted && ($superadmin || (actingUser?.is_admin ?? false))}
+ {#if actionsInMenu}
+ {#if restartStep !== undefined && canRestart(restart)}
+ {@render flowRestartButton(restart, restartStep, 'button')}
+ {/if}
+ {#if canEdit && showEditButton}
+
+ {/if}
+
+ {#snippet buttonReplacement()}
+
+ {/snippet}
+
+ {:else}
+ {@render runActionsRow()}
+ {/if}
+ {/snippet}
+
+ {#snippet runActionsRow()}
+ {#if canDelete}
{/if}
- {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`}
-
- {@const viewHref =
- isScript && embedded
- ? `/scripts/get/${job?.script_path}?version=${job?.script_hash}`
- : `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`}
{#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined}
{
- // The form this lands on rebuilds the whole project. When resuming
- // THIS run is the cheaper thing to do, name it so the form can
- // offer that in one click rather than leaving the reader to know.
- const from = dbtResumable
- ? `${viewHref.includes('?') ? '&' : '?'}dbt_retry_from=${job?.id}`
- : ''
- onNavigate(
- viewHref +
- from +
- `#${computeSharableHash(job?.args, await getRerunTagOverride(job?.args))}`
- )
- }}
+ on:click={runAgain}
unifiedSize="sm"
variant="default"
iconOnly={compactBar}
@@ -1122,7 +1300,7 @@
{/if}
{#if job?.job_kind === 'script' || job?.job_kind === 'flow'}
{#if !actingUser?.operator}
- {#if editRights.canEditItem(isScript ? 'script' : 'flow', job?.script_path ?? '', {})}
+ {#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})}