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()} +