@shipfox/client-workflows 19.0.0 → 21.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +43 -0
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-view.js +231 -42
- package/dist/components/job-detail/job-detail-view.js.map +1 -1
- package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
- package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
- package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
- package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
- package/dist/components/step-list/step-list-model.d.ts +1 -0
- package/dist/components/step-list/step-list-model.d.ts.map +1 -1
- package/dist/components/step-list/step-list-model.js +1 -1
- package/dist/components/step-list/step-list-model.js.map +1 -1
- package/dist/components/step-list/step-list.d.ts +2 -1
- package/dist/components/step-list/step-list.d.ts.map +1 -1
- package/dist/components/step-list/step-list.js +24 -7
- package/dist/components/step-list/step-list.js.map +1 -1
- package/dist/components/workflow-run-duration-label.d.ts +8 -7
- package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
- package/dist/components/workflow-run-duration-label.js +15 -31
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.d.ts +9 -1
- package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
- package/dist/components/workflow-run-list/job-status-strip.js +22 -11
- package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
- package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-states.js +2 -2
- package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
- package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +22 -16
- package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +8 -35
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
- package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +43 -36
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
- package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
- package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
- package/dist/components/workflow-status/status-visuals.js +0 -9
- package/dist/components/workflow-status/status-visuals.js.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.js +0 -11
- package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +10 -55
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js +1 -94
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/core/workflow-run.d.ts +2 -2
- package/dist/core/workflow-run.d.ts.map +1 -1
- package/dist/core/workflow-run.js +1 -1
- package/dist/core/workflow-run.js.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
- package/dist/hooks/api/workflow-run-mapper.js +3 -1
- package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
- package/dist/hooks/api/workflow-runs.js +1 -0
- package/dist/hooks/api/workflow-runs.js.map +1 -1
- package/dist/pages/project-workflows-page.d.ts.map +1 -1
- package/dist/pages/project-workflows-page.js +3 -2
- package/dist/pages/project-workflows-page.js.map +1 -1
- package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
- package/dist/pages/workflow-job-detail-page.js +3 -2
- package/dist/pages/workflow-job-detail-page.js.map +1 -1
- package/dist/pages/workflow-run-detail-page.js +2 -1
- package/dist/pages/workflow-run-detail-page.js.map +1 -1
- package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
- package/dist/pages/workflow-run-list-page.js +9 -11
- package/dist/pages/workflow-run-list-page.js.map +1 -1
- package/dist/routes/job-detail.d.ts +1 -1
- package/dist/routes/job-detail.js +1 -1
- package/dist/routes/job-detail.js.map +1 -1
- package/dist/routes/run-detail.d.ts +1 -1
- package/dist/routes/run-detail.js +1 -1
- package/dist/routes/run-detail.js.map +1 -1
- package/dist/routes/runs.d.ts +1 -1
- package/dist/routes/runs.js +1 -1
- package/dist/routes/runs.js.map +1 -1
- package/dist/routes/workflows.d.ts +3 -0
- package/dist/routes/workflows.js +3 -0
- package/dist/routes/workflows.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/components/job-detail/job-detail-view.tsx +263 -47
- package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
- package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
- package/src/components/job-graph/job-graph.stories.tsx +1 -1
- package/src/components/job-graph/job-node.stories.tsx +1 -1
- package/src/components/step-list/step-list-model.ts +1 -1
- package/src/components/step-list/step-list.test.tsx +29 -1
- package/src/components/step-list/step-list.tsx +28 -9
- package/src/components/workflow-run-duration-label.tsx +21 -44
- package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
- package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
- package/src/components/workflow-run-list/workflow-run-list-states.tsx +2 -2
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +101 -4
- package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
- package/src/components/workflow-run-list/workflow-run-row.tsx +24 -19
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -40
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +75 -22
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +7 -31
- package/src/components/workflow-run-view/run-workspace-nav.tsx +1 -1
- package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
- package/src/components/workflow-run-view/workflow-run-view.stories.tsx +140 -0
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +38 -7
- package/src/components/workflow-run-view/workflow-run-view.tsx +45 -37
- package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
- package/src/components/workflow-status/status-visuals.ts +0 -4
- package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
- package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
- package/src/core/entities/workflow-run.ts +9 -136
- package/src/core/workflow-run.test.ts +2 -0
- package/src/core/workflow-run.ts +0 -8
- package/src/hooks/api/workflow-run-mapper.ts +2 -0
- package/src/hooks/api/workflow-runs.ts +1 -1
- package/src/pages/project-workflows-page.tsx +3 -2
- package/src/pages/workflow-job-detail-page.test.tsx +32 -0
- package/src/pages/workflow-job-detail-page.tsx +5 -1
- package/src/pages/workflow-run-detail-page.tsx +1 -1
- package/src/pages/workflow-run-list-page.tsx +9 -11
- package/src/pages/workflow-run-pages.test.tsx +26 -0
- package/src/routes/job-detail.tsx +1 -1
- package/src/routes/run-detail.tsx +1 -1
- package/src/routes/runs.tsx +1 -1
- package/src/routes/workflows.tsx +1 -0
- package/test/fixtures/workflow-run.ts +30 -3
- package/tsconfig.build.tsbuildinfo +1 -1
- package/src/core/entities/workflow-run-display.test.ts +0 -187
|
@@ -49,23 +49,54 @@ describe('WorkflowRunView', () => {
|
|
|
49
49
|
const summary = await screen.findByRole('region', {name: 'deploy-web'});
|
|
50
50
|
expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
|
|
51
51
|
expect(screen.getByRole('navigation', {name: 'Run workspace'})).toBeInTheDocument();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
).toHaveClass('
|
|
52
|
+
const workspaceLayout = screen
|
|
53
|
+
.getByRole('navigation', {name: 'Run workspace'})
|
|
54
|
+
.closest('[data-run-workspace-layout]');
|
|
55
|
+
expect(workspaceLayout).toHaveClass('border-t', 'border-border-neutral-base');
|
|
56
|
+
expect(workspaceLayout).not.toHaveClass('max-w-[1360px]');
|
|
57
|
+
expect(workspaceLayout?.querySelector('[data-run-workspace-frame]')).toHaveClass(
|
|
58
|
+
'mx-auto',
|
|
59
|
+
'w-full',
|
|
60
|
+
'max-w-[calc(240px_+_1120px)]',
|
|
61
|
+
'min-[768px]:flex-row',
|
|
62
|
+
);
|
|
57
63
|
expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
|
|
58
64
|
expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
|
|
59
65
|
expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
|
|
60
66
|
expect(screen.getByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
|
|
61
67
|
expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
|
|
62
|
-
expect(container.querySelector('[data-run-workspace-content]')).toHaveClass(
|
|
68
|
+
expect(container.querySelector('[data-run-workspace-content]')).toHaveClass('flex-1');
|
|
69
|
+
expect(container.querySelector('[data-run-workspace-content]')).not.toHaveClass(
|
|
63
70
|
'bg-background-neutral-base',
|
|
64
|
-
'flex-1',
|
|
65
71
|
);
|
|
66
72
|
expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
|
|
67
73
|
});
|
|
68
74
|
|
|
75
|
+
test.each([
|
|
76
|
+
{tab: undefined, region: 'All jobs summary'},
|
|
77
|
+
{tab: 'source', region: 'Workflow source'},
|
|
78
|
+
{tab: 'annotations', region: 'Run annotations'},
|
|
79
|
+
] as const)('does not cap the $region section at 1120px', async ({tab, region}) => {
|
|
80
|
+
configureRunFetch();
|
|
81
|
+
|
|
82
|
+
renderView({tab});
|
|
83
|
+
|
|
84
|
+
const section = await screen.findByRole('region', {name: region});
|
|
85
|
+
expect(section.firstElementChild).not.toHaveClass('max-w-[1120px]');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('keeps dedicated job content on the full-width data surface', async () => {
|
|
89
|
+
configureRunFetch();
|
|
90
|
+
|
|
91
|
+
renderView({jobContent: <div>Job logs</div>});
|
|
92
|
+
|
|
93
|
+
const jobContent = await screen.findByText('Job logs');
|
|
94
|
+
const workspaceFrame = jobContent.closest('[data-run-workspace-frame]');
|
|
95
|
+
|
|
96
|
+
expect(workspaceFrame).toHaveClass('w-full', 'min-[768px]:flex-row');
|
|
97
|
+
expect(workspaceFrame).not.toHaveClass('max-w-[calc(240px_+_1120px)]');
|
|
98
|
+
});
|
|
99
|
+
|
|
69
100
|
test('treats the removed Jobs tab URL as the graph Summary', async () => {
|
|
70
101
|
configureRunFetch();
|
|
71
102
|
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '@shipfox/react-ui/select';
|
|
12
12
|
import {toast} from '@shipfox/react-ui/toast';
|
|
13
13
|
import {Text} from '@shipfox/react-ui/typography';
|
|
14
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
14
15
|
import {useNavigate} from '@tanstack/react-router';
|
|
15
16
|
import {type ReactNode, useEffect, useMemo} from 'react';
|
|
16
17
|
import {buildRunAnnotationList, type RunAnnotationSummary} from '#core/run-annotation.js';
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
} from './workflow-run-states.js';
|
|
54
55
|
|
|
55
56
|
type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
|
|
57
|
+
const RUN_WORKSPACE_FRAME_CLASS_NAME = 'mx-auto w-full max-w-[calc(240px_+_1120px)]';
|
|
56
58
|
|
|
57
59
|
export interface WorkflowRunViewProps {
|
|
58
60
|
projectId: string;
|
|
@@ -157,6 +159,7 @@ function RunViewContent({
|
|
|
157
159
|
const activeSection = runWorkspaceSection(tab);
|
|
158
160
|
const cancelMutation = useCancelWorkflowRunMutation(runData);
|
|
159
161
|
const sourceSnapshot = runData?.sourceSnapshot ?? null;
|
|
162
|
+
const hasJobContent = Boolean(jobContent);
|
|
160
163
|
const resolvedSelection =
|
|
161
164
|
runData && selection ? resolveWorkflowRunSelection({run: runData, selection}) : undefined;
|
|
162
165
|
const highlightedLineRange = resolvedSelection?.step?.sourceLocation ?? null;
|
|
@@ -309,47 +312,52 @@ function RunViewContent({
|
|
|
309
312
|
|
|
310
313
|
<div
|
|
311
314
|
data-run-workspace-layout
|
|
312
|
-
className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-border-neutral-base
|
|
315
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-border-neutral-base"
|
|
313
316
|
>
|
|
314
|
-
{runData && workspaceSlug && projectSlug ? (
|
|
315
|
-
<RunWorkspaceNav
|
|
316
|
-
workspaceSlug={workspaceSlug}
|
|
317
|
-
projectSlug={projectSlug}
|
|
318
|
-
run={runData}
|
|
319
|
-
activeSection={activeSection}
|
|
320
|
-
currentJobId={activeJobId}
|
|
321
|
-
jobSearch={jobSearch}
|
|
322
|
-
annotationSummary={annotationSummary}
|
|
323
|
-
/>
|
|
324
|
-
) : (
|
|
325
|
-
<RunWorkspaceNavSkeleton />
|
|
326
|
-
)}
|
|
327
|
-
|
|
328
317
|
<div
|
|
329
|
-
data-run-workspace-
|
|
330
|
-
className=
|
|
318
|
+
data-run-workspace-frame
|
|
319
|
+
className={cn(
|
|
320
|
+
'flex min-h-0 min-w-0 w-full flex-1 flex-col min-[768px]:flex-row',
|
|
321
|
+
!hasJobContent && RUN_WORKSPACE_FRAME_CLASS_NAME,
|
|
322
|
+
)}
|
|
331
323
|
>
|
|
332
|
-
{runData &&
|
|
333
|
-
|
|
334
|
-
) : runData ? (
|
|
335
|
-
<RunSectionContent
|
|
336
|
-
section={activeSection}
|
|
337
|
-
run={runData}
|
|
338
|
-
annotations={annotations}
|
|
339
|
-
annotationSummary={annotationSummary}
|
|
324
|
+
{runData && workspaceSlug && projectSlug ? (
|
|
325
|
+
<RunWorkspaceNav
|
|
340
326
|
workspaceSlug={workspaceSlug}
|
|
341
327
|
projectSlug={projectSlug}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
sourceSnapshot={sourceSnapshot}
|
|
348
|
-
highlightedLineRange={highlightedLineRange}
|
|
328
|
+
run={runData}
|
|
329
|
+
activeSection={activeSection}
|
|
330
|
+
currentJobId={activeJobId}
|
|
331
|
+
jobSearch={jobSearch}
|
|
332
|
+
annotationSummary={annotationSummary}
|
|
349
333
|
/>
|
|
350
334
|
) : (
|
|
351
|
-
<
|
|
335
|
+
<RunWorkspaceNavSkeleton />
|
|
352
336
|
)}
|
|
337
|
+
|
|
338
|
+
<div data-run-workspace-content className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
339
|
+
{runData && jobContent ? (
|
|
340
|
+
jobContent
|
|
341
|
+
) : runData ? (
|
|
342
|
+
<RunSectionContent
|
|
343
|
+
section={activeSection}
|
|
344
|
+
run={runData}
|
|
345
|
+
annotations={annotations}
|
|
346
|
+
annotationSummary={annotationSummary}
|
|
347
|
+
workspaceSlug={workspaceSlug}
|
|
348
|
+
projectSlug={projectSlug}
|
|
349
|
+
selection={selection}
|
|
350
|
+
selectedJobId={selection?.jobId}
|
|
351
|
+
onSelectGraphJob={selectGraphJob}
|
|
352
|
+
onSelectAnnotationJob={selectAnnotationJob}
|
|
353
|
+
onClearAnnotationFilters={clearAnnotationFilters}
|
|
354
|
+
sourceSnapshot={sourceSnapshot}
|
|
355
|
+
highlightedLineRange={highlightedLineRange}
|
|
356
|
+
/>
|
|
357
|
+
) : (
|
|
358
|
+
<div className="min-h-0 flex-1 overflow-auto p-panel">{loadingOrError}</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
353
361
|
</div>
|
|
354
362
|
</div>
|
|
355
363
|
</div>
|
|
@@ -395,7 +403,7 @@ function RunSectionContent({
|
|
|
395
403
|
aria-label="All jobs summary"
|
|
396
404
|
className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
|
|
397
405
|
>
|
|
398
|
-
<div className="mx-auto flex w-full
|
|
406
|
+
<div className="mx-auto flex w-full flex-col gap-group px-frame">
|
|
399
407
|
<Text as="h2" className="sr-only">
|
|
400
408
|
All jobs summary
|
|
401
409
|
</Text>
|
|
@@ -431,7 +439,7 @@ function RunSectionContent({
|
|
|
431
439
|
aria-label="Workflow source"
|
|
432
440
|
className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
|
|
433
441
|
>
|
|
434
|
-
<div className="mx-auto flex min-h-full w-full
|
|
442
|
+
<div className="mx-auto flex min-h-full w-full flex-col px-frame">
|
|
435
443
|
<Text as="h2" className="sr-only">
|
|
436
444
|
Workflow source
|
|
437
445
|
</Text>
|
|
@@ -526,7 +534,7 @@ function RunAnnotationsSection({
|
|
|
526
534
|
aria-label="Run annotations"
|
|
527
535
|
className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
|
|
528
536
|
>
|
|
529
|
-
<div className="mx-auto flex w-full
|
|
537
|
+
<div className="mx-auto flex w-full flex-col gap-group px-frame">
|
|
530
538
|
<Text as="h2" className="sr-only">
|
|
531
539
|
Annotations
|
|
532
540
|
</Text>
|
|
@@ -666,7 +674,7 @@ function RunWorkspaceNavSkeleton() {
|
|
|
666
674
|
return (
|
|
667
675
|
<aside
|
|
668
676
|
aria-label="Loading run navigation"
|
|
669
|
-
className="hidden w-240 shrink-0 border-r border-border-neutral-base
|
|
677
|
+
className="hidden w-240 shrink-0 border-r border-border-neutral-base p-panel-compact min-[768px]:block"
|
|
670
678
|
>
|
|
671
679
|
<div className="flex flex-col gap-group">
|
|
672
680
|
<div className="h-32 rounded-4 bg-background-components-subtle" />
|
|
@@ -63,8 +63,8 @@ export function WorkflowSourceContent({
|
|
|
63
63
|
value={item.filename}
|
|
64
64
|
className={cn(
|
|
65
65
|
'min-h-full px-0 pb-0',
|
|
66
|
-
'[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base
|
|
67
|
-
'[&_code]:!text-sm [&_code]:!text-foreground-
|
|
66
|
+
'[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base',
|
|
67
|
+
'[&_code]:!text-sm [&_code]:!text-foreground-contrast-primary [&_.line]:!text-sm [&_.line]:before:!text-sm [&_.line]:before:!text-foreground-contrast-secondary',
|
|
68
68
|
)}
|
|
69
69
|
>
|
|
70
70
|
<CodeBlockContent
|
|
@@ -18,10 +18,6 @@ export function getWorkflowStatusVisual(status: WorkflowDisplayStatus): Workflow
|
|
|
18
18
|
switch (status) {
|
|
19
19
|
case 'pending':
|
|
20
20
|
return {kind: 'pending', label: 'Pending', dot: 'neutral', badge: 'neutral'};
|
|
21
|
-
// Waiting on a runner is the awaiting-* state the warning tone was held for: the run is
|
|
22
|
-
// live but nothing is executing, which is the operator's problem to see, not ours to hide.
|
|
23
|
-
case 'queued':
|
|
24
|
-
return {kind: 'queued', label: 'Queued', dot: 'warning', badge: 'warning'};
|
|
25
21
|
case 'running':
|
|
26
22
|
return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
|
|
27
23
|
case 'listening':
|
|
@@ -89,24 +89,6 @@ export function WorkflowStatusIcon({
|
|
|
89
89
|
<Icon name="pulseLine" size={Math.max(8, Math.round(size * 0.7))} className="text-white" />
|
|
90
90
|
</span>
|
|
91
91
|
);
|
|
92
|
-
} else if (visual.kind === 'queued') {
|
|
93
|
-
// An hourglass in the same filled disc the listening state uses, so waiting reads as its
|
|
94
|
-
// own shape rather than as a pending ring that happens to be a different colour.
|
|
95
|
-
glyph = (
|
|
96
|
-
<span
|
|
97
|
-
className={cn(
|
|
98
|
-
'inline-flex shrink-0 items-center justify-center rounded-full bg-current',
|
|
99
|
-
box,
|
|
100
|
-
toneByVariant.warning,
|
|
101
|
-
)}
|
|
102
|
-
>
|
|
103
|
-
<Icon
|
|
104
|
-
name="hourglassLine"
|
|
105
|
-
size={Math.max(8, Math.round(size * 0.7))}
|
|
106
|
-
className="text-white"
|
|
107
|
-
/>
|
|
108
|
-
</span>
|
|
109
|
-
);
|
|
110
92
|
} else if (visual.kind === 'running') {
|
|
111
93
|
glyph = <Dot variant="info" ripple={ripple} className={box} />;
|
|
112
94
|
} else if (visual.kind === 'pending') {
|
|
@@ -6,21 +6,17 @@ import {
|
|
|
6
6
|
type ListenerStatus,
|
|
7
7
|
WORKFLOW_JOB_STATUSES,
|
|
8
8
|
} from './job.js';
|
|
9
|
-
import {
|
|
10
|
-
elapsedTimeFromTimestamps,
|
|
11
|
-
type JobExecutionDisplayDuration,
|
|
12
|
-
type JobExecutionStatus,
|
|
13
|
-
} from './job-execution.js';
|
|
9
|
+
import type {JobExecutionStatus} from './job-execution.js';
|
|
14
10
|
import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
|
|
15
11
|
|
|
16
12
|
export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
17
13
|
export type WorkflowRunRerunMode = 'all' | 'failed';
|
|
18
14
|
export type WorkflowStatus = WorkflowRunStatus | (typeof WORKFLOW_JOB_STATUSES)[number];
|
|
19
15
|
/**
|
|
20
|
-
* `listening`
|
|
21
|
-
*
|
|
16
|
+
* `listening` is display-only: the API never returns it, and it is derived from the listener
|
|
17
|
+
* state a job already carries.
|
|
22
18
|
*/
|
|
23
|
-
export type WorkflowDisplayStatus = WorkflowStatus | 'listening'
|
|
19
|
+
export type WorkflowDisplayStatus = WorkflowStatus | 'listening';
|
|
24
20
|
|
|
25
21
|
export const WORKFLOW_RUN_STATUSES = [
|
|
26
22
|
'pending',
|
|
@@ -34,7 +30,6 @@ export const WORKFLOW_DISPLAY_STATUSES = [
|
|
|
34
30
|
...WORKFLOW_RUN_STATUSES,
|
|
35
31
|
...WORKFLOW_JOB_STATUSES,
|
|
36
32
|
'listening',
|
|
37
|
-
'queued',
|
|
38
33
|
] as const satisfies readonly WorkflowDisplayStatus[];
|
|
39
34
|
|
|
40
35
|
export const TERMINAL_WORKFLOW_RUN_STATUSES = [
|
|
@@ -87,11 +82,14 @@ export interface WorkflowRunJobStatusCount {
|
|
|
87
82
|
* of them.
|
|
88
83
|
*
|
|
89
84
|
* `preview` is capped by the API, so `preview.length` is not the job count and the strip
|
|
90
|
-
* reads `total` and `statusCounts` for anything it says rather than anything it draws.
|
|
85
|
+
* reads `total` and `statusCounts` for anything it says rather than anything it draws. The API
|
|
86
|
+
* also reports whether any job execution has started because a terminal `cancelled` job can have
|
|
87
|
+
* started work even though its job status does not preserve that distinction.
|
|
91
88
|
*/
|
|
92
89
|
export interface WorkflowRunJobs {
|
|
93
90
|
preview: WorkflowRunJobSummary[];
|
|
94
91
|
statusCounts: WorkflowRunJobStatusCount[];
|
|
92
|
+
hasStartedJobExecution: boolean;
|
|
95
93
|
total: number;
|
|
96
94
|
}
|
|
97
95
|
|
|
@@ -132,6 +130,7 @@ export interface WorkflowRunDetail extends WorkflowRun {
|
|
|
132
130
|
latestAttempt: number;
|
|
133
131
|
runAttempt: WorkflowRunAttempt;
|
|
134
132
|
jobs: Job[];
|
|
133
|
+
hasStartedJobExecution: boolean;
|
|
135
134
|
}
|
|
136
135
|
|
|
137
136
|
export interface WorkflowRunListPage {
|
|
@@ -198,129 +197,3 @@ export function isWorkflowRunTerminal(status: WorkflowRunStatus): boolean {
|
|
|
198
197
|
export function isWorkflowStatus(status: string): status is WorkflowStatus {
|
|
199
198
|
return WORKFLOW_STATUSES.has(status as WorkflowStatus);
|
|
200
199
|
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* A run's headline duration, split the way a job's already is. An attempt's `startedAt` marks
|
|
204
|
-
* when the orchestrator picked the run up, not when work began, so a run whose jobs are all
|
|
205
|
-
* still waiting for a runner reads as queue time rather than as run time.
|
|
206
|
-
*/
|
|
207
|
-
export type WorkflowRunDisplayDuration = JobExecutionDisplayDuration;
|
|
208
|
-
|
|
209
|
-
/** What every surface shows for a run, derived together so status and duration cannot disagree. */
|
|
210
|
-
export interface WorkflowRunDisplay {
|
|
211
|
-
status: WorkflowDisplayStatus;
|
|
212
|
-
duration: WorkflowRunDisplayDuration | null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* The evidence a surface can offer about a run's progress.
|
|
217
|
-
*
|
|
218
|
-
* `jobStatuses` is what every surface has: the list carries status counts, the detail carries
|
|
219
|
-
* whole jobs. `firstStartedAt` is the detail's extra precision, and where it is absent the
|
|
220
|
-
* attempt's own mark stands in.
|
|
221
|
-
*/
|
|
222
|
-
export interface WorkflowRunProgress {
|
|
223
|
-
runStatus: WorkflowRunStatus;
|
|
224
|
-
startedAt: string | null;
|
|
225
|
-
finishedAt: string | null;
|
|
226
|
-
jobStatuses: JobDisplayStatus[];
|
|
227
|
-
firstStartedAt?: string | null | undefined;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** When the run's earliest execution began, or null while none of them has. */
|
|
231
|
-
export function workflowRunFirstStartedAt(jobs: Job[]): string | null {
|
|
232
|
-
let earliest: string | null = null;
|
|
233
|
-
let earliestMs = Number.POSITIVE_INFINITY;
|
|
234
|
-
|
|
235
|
-
for (const job of jobs) {
|
|
236
|
-
for (const {startedAt} of job.jobExecutions) {
|
|
237
|
-
if (startedAt === null) continue;
|
|
238
|
-
const startedMs = new Date(startedAt).getTime();
|
|
239
|
-
if (!Number.isFinite(startedMs) || startedMs >= earliestMs) continue;
|
|
240
|
-
earliest = startedAt;
|
|
241
|
-
earliestMs = startedMs;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return earliest;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Whether any work has begun. A run with no jobs on hand is a run whose jobs were not fetched,
|
|
250
|
-
* which is no evidence that nothing started, so it counts as started rather than claiming a
|
|
251
|
-
* queue this surface cannot see.
|
|
252
|
-
*/
|
|
253
|
-
function workflowRunHasStarted({jobStatuses, firstStartedAt}: WorkflowRunProgress): boolean {
|
|
254
|
-
if (firstStartedAt != null) return true;
|
|
255
|
-
if (jobStatuses.length === 0) return true;
|
|
256
|
-
return jobStatuses.some((status) => status !== 'pending' && status !== 'skipped');
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* The single rule behind every run readout. An attempt's `startedAt` marks when the
|
|
261
|
-
* orchestrator picked the run up, not when work began, so a run whose jobs are all still
|
|
262
|
-
* waiting reads as `Queued` for queue time. Calling that "running for 2h" sends an operator to
|
|
263
|
-
* debug a build that never began.
|
|
264
|
-
*/
|
|
265
|
-
export function deriveWorkflowRunDisplay(progress: WorkflowRunProgress): WorkflowRunDisplay {
|
|
266
|
-
const {runStatus, startedAt, finishedAt, firstStartedAt} = progress;
|
|
267
|
-
const hasStarted = workflowRunHasStarted(progress);
|
|
268
|
-
const status = runStatus === 'running' && !hasStarted ? 'queued' : runStatus;
|
|
269
|
-
|
|
270
|
-
if (startedAt === null) return {status, duration: null};
|
|
271
|
-
|
|
272
|
-
// A run cancelled before anything started keeps its queue reading rather than reporting a
|
|
273
|
-
// run that never was.
|
|
274
|
-
const time = elapsedTimeFromTimestamps({from: firstStartedAt ?? startedAt, to: finishedAt});
|
|
275
|
-
if (time === null) return {status, duration: null};
|
|
276
|
-
|
|
277
|
-
return {status, duration: {kind: hasStarted ? 'run' : 'queue', ...time}};
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** The run detail's reading: whole jobs, so queue and run time split on the first execution. */
|
|
281
|
-
export function workflowRunDetailDisplay(run: {
|
|
282
|
-
runAttempt: Pick<WorkflowRunAttemptSummary, 'status' | 'startedAt' | 'finishedAt'>;
|
|
283
|
-
jobs: Job[];
|
|
284
|
-
}): WorkflowRunDisplay {
|
|
285
|
-
return deriveWorkflowRunDisplay({
|
|
286
|
-
runStatus: run.runAttempt.status,
|
|
287
|
-
startedAt: run.runAttempt.startedAt,
|
|
288
|
-
finishedAt: run.runAttempt.finishedAt,
|
|
289
|
-
jobStatuses: run.jobs.map((job) => job.status),
|
|
290
|
-
firstStartedAt: workflowRunFirstStartedAt(run.jobs),
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* The run list's reading: status counts cover every job, not just the drawn preview, so a row
|
|
296
|
-
* reaches the same verdict as the detail page without fetching a single timestamp more.
|
|
297
|
-
*/
|
|
298
|
-
export function workflowRunListItemDisplay(run: WorkflowRunListItem): WorkflowRunDisplay {
|
|
299
|
-
return deriveWorkflowRunDisplay({
|
|
300
|
-
runStatus: run.status,
|
|
301
|
-
startedAt: run.runAttempt.startedAt,
|
|
302
|
-
finishedAt: run.runAttempt.finishedAt,
|
|
303
|
-
jobStatuses: run.jobs.statusCounts.filter(({count}) => count > 0).map(({status}) => status),
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* The job the run is waiting on: the one queued longest without starting. Named only so a
|
|
309
|
-
* queued run says what it waits for instead of showing a number with no subject.
|
|
310
|
-
*/
|
|
311
|
-
export function workflowRunBlockingJob(jobs: Job[]): Job | null {
|
|
312
|
-
let blocking: Job | null = null;
|
|
313
|
-
let queuedMs = Number.POSITIVE_INFINITY;
|
|
314
|
-
|
|
315
|
-
for (const job of jobs) {
|
|
316
|
-
for (const {queuedAt, startedAt, finishedAt} of job.jobExecutions) {
|
|
317
|
-
if (queuedAt === null || startedAt !== null || finishedAt !== null) continue;
|
|
318
|
-
const candidateMs = new Date(queuedAt).getTime();
|
|
319
|
-
if (!Number.isFinite(candidateMs) || candidateMs >= queuedMs) continue;
|
|
320
|
-
blocking = job;
|
|
321
|
-
queuedMs = candidateMs;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return blocking;
|
|
326
|
-
}
|
|
@@ -103,6 +103,7 @@ describe('workflow run model mapping', () => {
|
|
|
103
103
|
latest_attempt: 4,
|
|
104
104
|
started_at: '2026-05-07T01:01:10.000Z',
|
|
105
105
|
finished_at: null,
|
|
106
|
+
has_started_job_execution: true,
|
|
106
107
|
});
|
|
107
108
|
|
|
108
109
|
const run = toWorkflowRunListItem(dto);
|
|
@@ -122,6 +123,7 @@ describe('workflow run model mapping', () => {
|
|
|
122
123
|
expect(run).not.toHaveProperty('startedAt');
|
|
123
124
|
expect(run).not.toHaveProperty('finishedAt');
|
|
124
125
|
expect(run).not.toHaveProperty('displayDuration');
|
|
126
|
+
expect(run.jobs.hasStartedJobExecution).toBe(true);
|
|
125
127
|
expect(run.runAttempt.displayDuration).toEqual({
|
|
126
128
|
state: 'live',
|
|
127
129
|
fromIso: '2026-05-07T01:01:10.000Z',
|
package/src/core/workflow-run.ts
CHANGED
|
@@ -59,14 +59,11 @@ export type {
|
|
|
59
59
|
WorkflowDisplayStatus,
|
|
60
60
|
WorkflowRun,
|
|
61
61
|
WorkflowRunDetail,
|
|
62
|
-
WorkflowRunDisplay,
|
|
63
|
-
WorkflowRunDisplayDuration,
|
|
64
62
|
WorkflowRunJobStatusCount,
|
|
65
63
|
WorkflowRunJobSummary,
|
|
66
64
|
WorkflowRunJobs,
|
|
67
65
|
WorkflowRunListItem,
|
|
68
66
|
WorkflowRunListPage,
|
|
69
|
-
WorkflowRunProgress,
|
|
70
67
|
WorkflowRunRecord,
|
|
71
68
|
WorkflowRunRerunMode,
|
|
72
69
|
WorkflowRunStatus,
|
|
@@ -75,19 +72,14 @@ export type {
|
|
|
75
72
|
WorkflowStatus,
|
|
76
73
|
} from './entities/workflow-run.js';
|
|
77
74
|
export {
|
|
78
|
-
deriveWorkflowRunDisplay,
|
|
79
75
|
isWorkflowRunTerminal,
|
|
80
76
|
isWorkflowStatus,
|
|
81
77
|
TERMINAL_WORKFLOW_RUN_STATUSES,
|
|
82
78
|
WORKFLOW_DISPLAY_STATUSES,
|
|
83
79
|
WORKFLOW_RUN_STATUSES,
|
|
84
80
|
workflowRunActor,
|
|
85
|
-
workflowRunBlockingJob,
|
|
86
81
|
workflowRunBranchLabel,
|
|
87
82
|
workflowRunCommitLabel,
|
|
88
|
-
workflowRunDetailDisplay,
|
|
89
|
-
workflowRunFirstStartedAt,
|
|
90
|
-
workflowRunListItemDisplay,
|
|
91
83
|
workflowRunTriggerDisplayLabel,
|
|
92
84
|
workflowRunTriggerLabel,
|
|
93
85
|
} from './entities/workflow-run.js';
|
|
@@ -119,6 +119,7 @@ export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunL
|
|
|
119
119
|
position: job.position,
|
|
120
120
|
})),
|
|
121
121
|
statusCounts,
|
|
122
|
+
hasStartedJobExecution: dto.has_started_job_execution ?? true,
|
|
122
123
|
// Derived rather than sent: the counts already cover every job, and a separate total
|
|
123
124
|
// would be a second source of truth that could disagree with them.
|
|
124
125
|
total: statusCounts.reduce((sum, entry) => sum + entry.count, 0),
|
|
@@ -146,6 +147,7 @@ export function toWorkflowRunDetail(dto: WorkflowRunDetailResponseDto): Workflow
|
|
|
146
147
|
latestAttempt: dto.latest_attempt,
|
|
147
148
|
runAttempt: toWorkflowRunAttempt(dto.run_attempt),
|
|
148
149
|
jobs: dto.jobs.map(toJob),
|
|
150
|
+
hasStartedJobExecution: dto.has_started_job_execution ?? true,
|
|
149
151
|
};
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -358,7 +358,7 @@ function buildTempRun({
|
|
|
358
358
|
updatedAt: createdAt,
|
|
359
359
|
isTemporary: true,
|
|
360
360
|
// The optimistic row genuinely has no jobs yet: the server has not planned the graph.
|
|
361
|
-
jobs: {preview: [], statusCounts: [], total: 0},
|
|
361
|
+
jobs: {preview: [], statusCounts: [], hasStartedJobExecution: false, total: 0},
|
|
362
362
|
runAttempt: new WorkflowRunAttemptSummary({
|
|
363
363
|
workflowRunId: id,
|
|
364
364
|
attempt: 1,
|
|
@@ -12,6 +12,7 @@ import {Callout} from '@shipfox/react-ui/callout';
|
|
|
12
12
|
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
13
13
|
import {Icon, type IconName} from '@shipfox/react-ui/icon';
|
|
14
14
|
import {LoadErrorState} from '@shipfox/react-ui/load-error-state';
|
|
15
|
+
import {Panel} from '@shipfox/react-ui/panel';
|
|
15
16
|
import {RelativeTime, RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
|
|
16
17
|
import {
|
|
17
18
|
Sheet,
|
|
@@ -195,7 +196,7 @@ function WorkflowDefinitionsList({
|
|
|
195
196
|
|
|
196
197
|
return (
|
|
197
198
|
<>
|
|
198
|
-
<
|
|
199
|
+
<Panel className="hidden md:flex">
|
|
199
200
|
<Table>
|
|
200
201
|
<TableHeader>
|
|
201
202
|
<TableRow>
|
|
@@ -265,7 +266,7 @@ function WorkflowDefinitionsList({
|
|
|
265
266
|
})}
|
|
266
267
|
</TableBody>
|
|
267
268
|
</Table>
|
|
268
|
-
</
|
|
269
|
+
</Panel>
|
|
269
270
|
|
|
270
271
|
<div className="flex flex-col rounded-8 border border-border-neutral-base md:hidden">
|
|
271
272
|
{definitions.map((definition) => {
|
|
@@ -41,6 +41,19 @@ describe('WorkflowJobDetailPage', () => {
|
|
|
41
41
|
jobAnnotations.value = [];
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test('lets the job detail route inherit its shell canvas', async () => {
|
|
45
|
+
configureApiClient({fetchImpl: vi.fn(() => new Promise<Response>(() => undefined))});
|
|
46
|
+
|
|
47
|
+
const {container} = renderJobPath();
|
|
48
|
+
|
|
49
|
+
expect(await screen.findByRole('region', {name: 'Loading workflow run'})).toBeInTheDocument();
|
|
50
|
+
|
|
51
|
+
const pageRoot = container.querySelector('[data-workflow-page-root="job-detail"]');
|
|
52
|
+
|
|
53
|
+
expect(pageRoot).not.toBeNull();
|
|
54
|
+
expect(pageRoot).not.toHaveClass('bg-background-subtle-base');
|
|
55
|
+
});
|
|
56
|
+
|
|
44
57
|
test('links the job to its annotations without rendering one', async () => {
|
|
45
58
|
jobAnnotations.value = [
|
|
46
59
|
{
|
|
@@ -88,6 +101,7 @@ describe('WorkflowJobDetailPage', () => {
|
|
|
88
101
|
});
|
|
89
102
|
|
|
90
103
|
test('resolves an exact job execution and step attempt from a deep link', async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
91
105
|
configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
|
|
92
106
|
|
|
93
107
|
renderJobPath(
|
|
@@ -104,6 +118,24 @@ describe('WorkflowJobDetailPage', () => {
|
|
|
104
118
|
await screen.findByRole('region', {name: 'tests output, attempt 2'}),
|
|
105
119
|
).toBeInTheDocument();
|
|
106
120
|
expect(screen.getByRole('region', {name: 'release logs'})).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByRole('textbox', {name: 'Search logs'})).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByRole('button', {name: 'Refresh logs'})).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByRole('button', {name: 'Log settings'})).toBeInTheDocument();
|
|
124
|
+
const testsAttempt = screen.getByRole('button', {name: 'tests, Succeeded, attempt 2'});
|
|
125
|
+
await user.click(testsAttempt);
|
|
126
|
+
await waitFor(() =>
|
|
127
|
+
expect(
|
|
128
|
+
screen.queryByRole('region', {name: 'tests output, attempt 2'}),
|
|
129
|
+
).not.toBeInTheDocument(),
|
|
130
|
+
);
|
|
131
|
+
expect(screen.getByRole('textbox', {name: 'Search logs'})).toBeDisabled();
|
|
132
|
+
expect(screen.getByRole('button', {name: 'Refresh logs'})).toBeDisabled();
|
|
133
|
+
|
|
134
|
+
await user.click(testsAttempt);
|
|
135
|
+
expect(
|
|
136
|
+
await screen.findByRole('region', {name: 'tests output, attempt 2'}),
|
|
137
|
+
).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByRole('textbox', {name: 'Search logs'})).not.toBeDisabled();
|
|
107
139
|
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
|
108
140
|
expect(screen.getByRole('img', {name: 'Job status: Succeeded'})).toBeInTheDocument();
|
|
109
141
|
expect(screen.getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
|
|
@@ -56,7 +56,10 @@ export function WorkflowJobDetailPage({
|
|
|
56
56
|
[navigate],
|
|
57
57
|
);
|
|
58
58
|
return (
|
|
59
|
-
<div
|
|
59
|
+
<div
|
|
60
|
+
data-workflow-page-root="job-detail"
|
|
61
|
+
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
|
62
|
+
>
|
|
60
63
|
<WorkflowRunView
|
|
61
64
|
projectId={projectId}
|
|
62
65
|
workspaceSlug={workspaceSlug}
|
|
@@ -67,6 +70,7 @@ export function WorkflowJobDetailPage({
|
|
|
67
70
|
jobSearch={search}
|
|
68
71
|
jobContent={
|
|
69
72
|
<JobDetailView
|
|
73
|
+
key={jobId}
|
|
70
74
|
workspaceSlug={workspaceSlug}
|
|
71
75
|
projectSlug={projectSlug}
|
|
72
76
|
workflowRunId={workflowRunId}
|
|
@@ -17,7 +17,7 @@ export function WorkflowRunDetailPage({
|
|
|
17
17
|
search = {},
|
|
18
18
|
}: WorkflowRunDetailPageProps) {
|
|
19
19
|
return (
|
|
20
|
-
<div className="flex min-h-0 flex-1 overflow-hidden
|
|
20
|
+
<div data-workflow-page-root="run-detail" className="flex min-h-0 flex-1 overflow-hidden">
|
|
21
21
|
<WorkflowRunView
|
|
22
22
|
projectId={projectId}
|
|
23
23
|
workspaceSlug={workspaceSlug}
|