@shipfox/client-workflows 14.0.1 → 15.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/.storybook/preview.tsx +7 -2
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +21 -0
- package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
- package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
- package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
- package/dist/components/job-detail/job-detail-header.d.ts +8 -0
- package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
- package/dist/components/job-detail/job-detail-header.js +111 -0
- package/dist/components/job-detail/job-detail-header.js.map +1 -0
- package/dist/components/job-detail/job-detail-view.d.ts +16 -0
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
- package/dist/components/job-detail/job-detail-view.js +405 -0
- package/dist/components/job-detail/job-detail-view.js.map +1 -0
- package/dist/components/job-detail/job-empty-states.d.ts +10 -0
- package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
- package/dist/components/job-detail/job-empty-states.js +195 -0
- package/dist/components/job-detail/job-empty-states.js.map +1 -0
- package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
- package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
- package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
- package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
- package/dist/components/job-detail/job-selection.d.ts +24 -0
- package/dist/components/job-detail/job-selection.d.ts.map +1 -0
- package/dist/components/job-detail/job-selection.js +66 -0
- package/dist/components/job-detail/job-selection.js.map +1 -0
- package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
- package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
- package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
- package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
- package/dist/components/job-graph/job-graph-content.js +1 -1
- package/dist/components/job-graph/job-graph-content.js.map +1 -1
- package/dist/components/step-list/step-list-model.js +2 -2
- package/dist/components/step-list/step-list-model.js.map +1 -1
- package/dist/components/step-list/step-list.d.ts +1 -0
- package/dist/components/step-list/step-list.d.ts.map +1 -1
- package/dist/components/step-list/step-list.js +29 -4
- package/dist/components/step-list/step-list.js.map +1 -1
- package/dist/components/workflow-run-duration-label.d.ts +9 -4
- package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
- package/dist/components/workflow-run-duration-label.js +27 -7
- package/dist/components/workflow-run-duration-label.js.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
- package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
- 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 +68 -64
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-tabs/index.d.ts +0 -3
- package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/index.js +0 -3
- package/dist/components/workflow-run-tabs/index.js.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
- package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
- package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
- package/dist/components/workflow-run-view/index.d.ts +0 -1
- package/dist/components/workflow-run-view/index.d.ts.map +1 -1
- package/dist/components/workflow-run-view/index.js +0 -1
- package/dist/components/workflow-run-view/index.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
- package/dist/components/workflow-run-view/run-workspace-nav.js +271 -0
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
- package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
- package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -8
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +250 -236
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
- package/dist/components/workflow-status/status-visuals.js +10 -2
- package/dist/components/workflow-status/status-visuals.js.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
- package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
- package/dist/components/workflow-status/workflow-status-icon.js +13 -2
- package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
- package/dist/core/entities/job-execution.d.ts +9 -0
- package/dist/core/entities/job-execution.d.ts.map +1 -1
- package/dist/core/entities/job-execution.js +13 -3
- package/dist/core/entities/job-execution.js.map +1 -1
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/step-attempt.d.ts.map +1 -1
- package/dist/core/entities/step.d.ts +2 -0
- package/dist/core/entities/step.d.ts.map +1 -1
- package/dist/core/entities/step.js +13 -0
- package/dist/core/entities/step.js.map +1 -1
- package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +56 -1
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/workflow-run.js +98 -0
- package/dist/core/entities/workflow-run.js.map +1 -1
- package/dist/core/workflow-run.d.ts +4 -4
- package/dist/core/workflow-run.d.ts.map +1 -1
- package/dist/core/workflow-run.js +3 -3
- package/dist/core/workflow-run.js.map +1 -1
- package/dist/feature.d.ts +20 -16
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +5 -0
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/workflow-runs.d.ts +5 -3
- package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
- package/dist/hooks/api/workflow-runs.js +5 -4
- package/dist/hooks/api/workflow-runs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pages/workflow-job-detail-page.d.ts +11 -0
- package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
- package/dist/pages/workflow-job-detail-page.js +66 -0
- package/dist/pages/workflow-job-detail-page.js.map +1 -0
- package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
- package/dist/pages/workflow-run-detail-page.js +4 -37
- package/dist/pages/workflow-run-detail-page.js.map +1 -1
- package/dist/routes/inputs.d.ts +47 -34
- package/dist/routes/inputs.d.ts.map +1 -1
- package/dist/routes/inputs.js +69 -37
- package/dist/routes/inputs.js.map +1 -1
- package/dist/routes/job-detail.d.ts +10 -0
- package/dist/routes/job-detail.d.ts.map +1 -0
- package/dist/routes/job-detail.js +27 -0
- package/dist/routes/job-detail.js.map +1 -0
- package/dist/routes/project-route.d.ts +1 -1
- package/dist/routes/run-detail.d.ts.map +1 -1
- package/dist/routes/runs.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
- package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
- package/src/components/job-detail/job-detail-header.test.tsx +31 -0
- package/src/components/job-detail/job-detail-header.tsx +111 -0
- package/src/components/job-detail/job-detail-view.stories.tsx +526 -0
- package/src/components/job-detail/job-detail-view.tsx +450 -0
- package/src/components/job-detail/job-empty-states.test.ts +27 -0
- package/src/components/job-detail/job-empty-states.tsx +248 -0
- package/src/components/job-detail/job-selection.test.ts +188 -0
- package/src/components/job-detail/job-selection.ts +96 -0
- package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
- package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
- package/src/components/job-graph/job-graph-content.tsx +1 -1
- package/src/components/step-list/step-list-model.test.ts +1 -1
- package/src/components/step-list/step-list-model.ts +2 -2
- package/src/components/step-list/step-list.stories.tsx +1 -1
- package/src/components/step-list/step-list.test.tsx +33 -0
- package/src/components/step-list/step-list.tsx +40 -9
- package/src/components/workflow-run-duration-label.tsx +50 -12
- package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
- package/src/components/workflow-run-tabs/index.ts +0 -3
- package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
- package/src/components/workflow-run-view/index.ts +0 -1
- package/src/components/workflow-run-view/run-workspace-nav.test.tsx +242 -0
- package/src/components/workflow-run-view/run-workspace-nav.tsx +301 -0
- package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +62 -1136
- package/src/components/workflow-run-view/workflow-run-view.tsx +330 -321
- package/src/components/workflow-status/status-visuals.ts +5 -2
- package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
- package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
- package/src/core/entities/job-execution.ts +17 -2
- package/src/core/entities/step.ts +26 -0
- package/src/core/entities/workflow-run-display.test.ts +159 -0
- package/src/core/entities/workflow-run.ts +139 -1
- package/src/core/workflow-run.ts +16 -1
- package/src/feature.ts +5 -0
- package/src/hooks/api/workflow-runs.ts +6 -2
- package/src/index.ts +1 -0
- package/src/page-harness-budget.test.ts +1 -0
- package/src/pages/workflow-job-detail-page.test.tsx +539 -0
- package/src/pages/workflow-job-detail-page.tsx +84 -0
- package/src/pages/workflow-run-detail-page.tsx +3 -43
- package/src/pages/workflow-run-pages.test.tsx +77 -332
- package/src/routes/inputs.test.ts +56 -6
- package/src/routes/inputs.ts +64 -17
- package/src/routes/job-detail.tsx +29 -0
- package/test/pages.tsx +25 -3
- package/test/render.tsx +6 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
- package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
- package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
- package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
- package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
- package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
- package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
- package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
- package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
- package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
- package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
- package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
- package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
- package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
- package/dist/components/workflow-run-view/job-card.d.ts +0 -17
- package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
- package/dist/components/workflow-run-view/job-card.js +0 -496
- package/dist/components/workflow-run-view/job-card.js.map +0 -1
- package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
- package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
- package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
- package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
- package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
- package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
- package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
- package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
- package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
- package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
- package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
- package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
- package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
- package/src/components/workflow-run-view/job-card.test.tsx +0 -115
- package/src/components/workflow-run-view/job-card.tsx +0 -614
- /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
- /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
- /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
- /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
- /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
- /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
- /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
|
@@ -7,17 +7,23 @@ import {
|
|
|
7
7
|
import {Button} from '@shipfox/react-ui/button';
|
|
8
8
|
import {Callout} from '@shipfox/react-ui/callout';
|
|
9
9
|
import {Text} from '@shipfox/react-ui/typography';
|
|
10
|
-
import {type
|
|
10
|
+
import {type RefObject, useEffect, useRef} from 'react';
|
|
11
|
+
import {JobExecutionTimeText} from './job-execution-time-text.js';
|
|
11
12
|
|
|
12
13
|
const TAIL_FOLLOW_THRESHOLD_PX = 24;
|
|
13
14
|
const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
|
|
14
15
|
const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
|
|
15
|
-
const
|
|
16
|
+
const defaultLogSurfaceClasses = 'rounded-8';
|
|
16
17
|
|
|
17
18
|
export interface StepAttemptLogPanelProps {
|
|
18
19
|
stepId: string;
|
|
19
20
|
attempt: number;
|
|
20
21
|
attemptStatus: string;
|
|
22
|
+
/** The start time used by the page's quiet waiting-for-output state. */
|
|
23
|
+
attemptStartedAt?: string | undefined;
|
|
24
|
+
/** The page scroll column used by the uncapped job-detail log surface. */
|
|
25
|
+
pageScrollRef: RefObject<HTMLElement | null>;
|
|
26
|
+
surfaceClassName?: string | undefined;
|
|
21
27
|
initialErrorRetryCount?: number | undefined;
|
|
22
28
|
initialErrorRetryDelayMs?: number | undefined;
|
|
23
29
|
}
|
|
@@ -26,10 +32,12 @@ export function StepAttemptLogPanel({
|
|
|
26
32
|
stepId,
|
|
27
33
|
attempt,
|
|
28
34
|
attemptStatus,
|
|
35
|
+
attemptStartedAt,
|
|
36
|
+
pageScrollRef,
|
|
37
|
+
surfaceClassName = defaultLogSurfaceClasses,
|
|
29
38
|
initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
|
|
30
39
|
initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
|
|
31
40
|
}: StepAttemptLogPanelProps) {
|
|
32
|
-
const panelRef = useRef<HTMLDivElement>(null);
|
|
33
41
|
const shouldFollowTailRef = useRef(true);
|
|
34
42
|
const missingStreamRetryCount = attemptStatus === 'running' ? undefined : initialErrorRetryCount;
|
|
35
43
|
const retryMissingStream = attemptStatus === 'running' || isTerminalAttemptStatus(attemptStatus);
|
|
@@ -48,13 +56,25 @@ export function StepAttemptLogPanel({
|
|
|
48
56
|
const initialError = query.isError && query.data === undefined && !missingActiveStream;
|
|
49
57
|
const staleError = query.isError && query.data !== undefined;
|
|
50
58
|
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const scrollElement = pageScrollRef.current;
|
|
61
|
+
if (!scrollElement) return undefined;
|
|
62
|
+
|
|
63
|
+
const onScroll = () => {
|
|
64
|
+
shouldFollowTailRef.current = isNearPageBottom(scrollElement);
|
|
65
|
+
};
|
|
66
|
+
scrollElement.addEventListener('scroll', onScroll, {passive: true});
|
|
67
|
+
onScroll();
|
|
68
|
+
return () => scrollElement.removeEventListener('scroll', onScroll);
|
|
69
|
+
}, [pageScrollRef]);
|
|
70
|
+
|
|
51
71
|
useEffect(() => {
|
|
52
72
|
if (recordCount === 0) return undefined;
|
|
53
73
|
if (anchorToFailure) return undefined;
|
|
54
74
|
if (!shouldFollowTailRef.current) return undefined;
|
|
55
75
|
|
|
56
76
|
const frame = scheduleAnimationFrame(() => {
|
|
57
|
-
const scrollElement =
|
|
77
|
+
const scrollElement = pageScrollRef.current;
|
|
58
78
|
if (!scrollElement) return;
|
|
59
79
|
scrollElement.scrollTop = scrollElement.scrollHeight;
|
|
60
80
|
});
|
|
@@ -62,20 +82,17 @@ export function StepAttemptLogPanel({
|
|
|
62
82
|
return () => {
|
|
63
83
|
cancelScheduledFrame(frame);
|
|
64
84
|
};
|
|
65
|
-
}, [anchorToFailure, recordCount]);
|
|
66
|
-
|
|
67
|
-
function handleLogScroll(event: UIEvent<HTMLDivElement>) {
|
|
68
|
-
const element = event.currentTarget;
|
|
69
|
-
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
70
|
-
shouldFollowTailRef.current = distanceFromBottom <= TAIL_FOLLOW_THRESHOLD_PX;
|
|
71
|
-
}
|
|
85
|
+
}, [anchorToFailure, pageScrollRef, recordCount]);
|
|
72
86
|
|
|
73
87
|
if (query.isPending) {
|
|
74
|
-
return <StepLogsLoadingSurface label="Loading logs" />;
|
|
88
|
+
return <StepLogsLoadingSurface label="Loading logs" className={surfaceClassName} />;
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
if (missingActiveStream) {
|
|
78
|
-
|
|
92
|
+
if (attemptStatus === 'running' && attemptStartedAt) {
|
|
93
|
+
return <StepLogsWaitingSurface startedAt={attemptStartedAt} className={surfaceClassName} />;
|
|
94
|
+
}
|
|
95
|
+
return <StepLogsLoadingSurface label="Waiting for logs" className={surfaceClassName} />;
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
if (initialError) {
|
|
@@ -83,7 +100,7 @@ export function StepAttemptLogPanel({
|
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
return (
|
|
86
|
-
<div
|
|
103
|
+
<div className="flex min-w-0 flex-col gap-8">
|
|
87
104
|
{staleError ? (
|
|
88
105
|
<Callout role="alert" type="warning" className="px-10 py-8">
|
|
89
106
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-8">
|
|
@@ -104,17 +121,29 @@ export function StepAttemptLogPanel({
|
|
|
104
121
|
records={records}
|
|
105
122
|
emptyState={query.data?.complete ? 'complete' : 'pending'}
|
|
106
123
|
anchorToFailure={anchorToFailure}
|
|
107
|
-
className={
|
|
108
|
-
onScroll={handleLogScroll}
|
|
124
|
+
className={surfaceClassName}
|
|
109
125
|
/>
|
|
110
126
|
</div>
|
|
111
127
|
);
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
function StepLogsLoadingSurface({label}: {label: string}) {
|
|
130
|
+
function StepLogsLoadingSurface({label, className}: {label: string; className: string}) {
|
|
115
131
|
return (
|
|
116
132
|
<div role="status" aria-label={label}>
|
|
117
|
-
<LogViewSkeleton className={
|
|
133
|
+
<LogViewSkeleton className={className} />
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
|
|
139
|
+
return (
|
|
140
|
+
<div role="status" aria-label="Waiting for logs" className={className}>
|
|
141
|
+
<Text size="xs" className="px-10 py-12 text-foreground-neutral-muted">
|
|
142
|
+
Waiting for output ·{' '}
|
|
143
|
+
<span className="font-code tabular-nums" aria-hidden="true">
|
|
144
|
+
<JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
|
|
145
|
+
</span>
|
|
146
|
+
</Text>
|
|
118
147
|
</div>
|
|
119
148
|
);
|
|
120
149
|
}
|
|
@@ -134,6 +163,11 @@ function cancelScheduledFrame(frame: number) {
|
|
|
134
163
|
window.clearTimeout(frame);
|
|
135
164
|
}
|
|
136
165
|
|
|
166
|
+
function isNearPageBottom(element: HTMLElement): boolean {
|
|
167
|
+
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
168
|
+
return distanceFromBottom <= TAIL_FOLLOW_THRESHOLD_PX;
|
|
169
|
+
}
|
|
170
|
+
|
|
137
171
|
function isTerminalAttemptStatus(status: string): boolean {
|
|
138
172
|
return status === 'succeeded' || status === 'failed' || status === 'cancelled';
|
|
139
173
|
}
|
|
@@ -81,7 +81,7 @@ export function JobGraphContent({
|
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
83
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
84
|
-
<div className="min-h-0 overflow-auto bg-
|
|
84
|
+
<div className="min-h-0 overflow-auto bg-transparent">
|
|
85
85
|
<div className="relative" style={{width: contentWidth, minHeight: contentHeight}}>
|
|
86
86
|
<GraphEdges model={model} hoveredJobId={hoveredJobId} />
|
|
87
87
|
<div
|
|
@@ -108,7 +108,7 @@ describe('buildStepListModel', () => {
|
|
|
108
108
|
expect(result.entries.map((entry) => entry.step.label)).toEqual(['build']);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
test('
|
|
111
|
+
test('does not land on a failed entry when no attempt is running', () => {
|
|
112
112
|
const step = makeStep({
|
|
113
113
|
attempts: [
|
|
114
114
|
makeAttempt({attempt: 1, status: 'failed'}),
|
|
@@ -60,7 +60,7 @@ export function buildStepListModel({
|
|
|
60
60
|
jobName: job.name ?? job.key,
|
|
61
61
|
jobExecutionId: selectedJobExecution.id,
|
|
62
62
|
stepCount: steps.length,
|
|
63
|
-
activeEntryId:
|
|
63
|
+
activeEntryId: landingEntryId(entries),
|
|
64
64
|
entries,
|
|
65
65
|
};
|
|
66
66
|
}
|
|
@@ -192,7 +192,7 @@ function compareEntries(left: StepListEntryModel, right: StepListEntryModel): nu
|
|
|
192
192
|
);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
function
|
|
195
|
+
function landingEntryId(entries: readonly StepListEntryModel[]): string | undefined {
|
|
196
196
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
197
197
|
const entry = entries[index];
|
|
198
198
|
if (entry?.statusVisual.kind === 'running') return entry.id;
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
workflowStepAttemptDto,
|
|
19
19
|
workflowStepDto,
|
|
20
20
|
} from '#test/fixtures/workflow-run.js';
|
|
21
|
-
import {AgentConfigFailureCallout as AgentConfigFailureCalloutView} from '../
|
|
21
|
+
import {AgentConfigFailureCallout as AgentConfigFailureCalloutView} from '../job-detail/agent-config-failure-callout.js';
|
|
22
22
|
import {StepList} from './step-list.js';
|
|
23
23
|
|
|
24
24
|
const AGENTS_LINK_NAME = 'Configure Agents';
|
|
@@ -280,6 +280,39 @@ describe('StepList', () => {
|
|
|
280
280
|
expect(screen.getByText(`logs for ${deployAttempt.id}`)).toBeInTheDocument();
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
test('does not replace the landing selection when a live job reports a new candidate', () => {
|
|
284
|
+
const firstAttempt = makeAttempt({status: 'running'});
|
|
285
|
+
const secondAttempt = makeAttempt({status: 'running'});
|
|
286
|
+
const firstStep = makeStep({name: 'build', status: 'running', attempts: [firstAttempt]});
|
|
287
|
+
const secondStep = makeStep({
|
|
288
|
+
name: 'deploy',
|
|
289
|
+
position: 1,
|
|
290
|
+
status: 'running',
|
|
291
|
+
attempts: [secondAttempt],
|
|
292
|
+
});
|
|
293
|
+
const job = makeJob({steps: [firstStep, secondStep]});
|
|
294
|
+
const {rerender} = render(
|
|
295
|
+
<StepList
|
|
296
|
+
job={job}
|
|
297
|
+
autoSelectActiveAttempt
|
|
298
|
+
defaultSelectedAttemptId={firstAttempt.id}
|
|
299
|
+
renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
|
|
300
|
+
/>,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
rerender(
|
|
304
|
+
<StepList
|
|
305
|
+
job={job}
|
|
306
|
+
autoSelectActiveAttempt
|
|
307
|
+
defaultSelectedAttemptId={secondAttempt.id}
|
|
308
|
+
renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
|
|
309
|
+
/>,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(screen.getByText(`logs for ${firstAttempt.id}`)).toBeInTheDocument();
|
|
313
|
+
expect(screen.queryByText(`logs for ${secondAttempt.id}`)).not.toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
283
316
|
test('does not auto-open when no attempt is running', () => {
|
|
284
317
|
render(
|
|
285
318
|
<StepList
|
|
@@ -40,6 +40,7 @@ export interface StepExpandedContext {
|
|
|
40
40
|
sourceLocation: StepSourceLocation | null;
|
|
41
41
|
attempt: number;
|
|
42
42
|
attemptId: string;
|
|
43
|
+
attemptStartedAt: string;
|
|
43
44
|
attemptError: Record<string, unknown> | null;
|
|
44
45
|
attemptStatus: string;
|
|
45
46
|
carriedOver: boolean;
|
|
@@ -119,15 +120,15 @@ function StepListContent({
|
|
|
119
120
|
);
|
|
120
121
|
const [userSelectedAttempt, setUserSelectedAttempt] = useState(false);
|
|
121
122
|
const lastNotifiedSelectedAttemptId = useRef<string | null>(null);
|
|
123
|
+
const autoSelectedAttemptIdRef = useRef<string | undefined>(undefined);
|
|
124
|
+
const previousSelectedAttemptIdRef = useRef<string | null | undefined>(selectedAttemptId);
|
|
122
125
|
const shouldUseControlledCollapsedState =
|
|
123
126
|
selectedAttemptId === null && lastNotifiedSelectedAttemptId.current === null;
|
|
124
|
-
const
|
|
125
|
-
selectedAttemptId === undefined &&
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
? [model.activeEntryId]
|
|
130
|
-
: [];
|
|
127
|
+
const autoSelectedAttemptId =
|
|
128
|
+
selectedAttemptId === undefined && autoSelectActiveAttempt && !userSelectedAttempt
|
|
129
|
+
? (autoSelectedAttemptIdRef.current ?? model.activeEntryId)
|
|
130
|
+
: undefined;
|
|
131
|
+
const autoSelectedAttemptIds = autoSelectedAttemptId ? [autoSelectedAttemptId] : [];
|
|
131
132
|
const selectedAttemptIds = shouldUseControlledCollapsedState
|
|
132
133
|
? []
|
|
133
134
|
: localSelectedAttemptIds.length > 0
|
|
@@ -136,12 +137,41 @@ function StepListContent({
|
|
|
136
137
|
const hasExpandedContent = renderExpandedStep !== undefined;
|
|
137
138
|
|
|
138
139
|
useEffect(() => {
|
|
139
|
-
if (selectedAttemptId !== undefined)
|
|
140
|
+
if (selectedAttemptId !== undefined) {
|
|
141
|
+
previousSelectedAttemptIdRef.current = selectedAttemptId;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A changing landing candidate is normal while a live job polls. Only reset the local
|
|
146
|
+
// accordion when the parent has just cleared a previously controlled URL selection.
|
|
147
|
+
const wasControlled = previousSelectedAttemptIdRef.current !== undefined;
|
|
148
|
+
previousSelectedAttemptIdRef.current = undefined;
|
|
149
|
+
if (!wasControlled) return;
|
|
140
150
|
|
|
141
151
|
setLocalSelectedAttemptIds(defaultSelectedAttemptId ? [defaultSelectedAttemptId] : []);
|
|
142
152
|
setUserSelectedAttempt(false);
|
|
153
|
+
autoSelectedAttemptIdRef.current = undefined;
|
|
143
154
|
}, [defaultSelectedAttemptId, selectedAttemptId]);
|
|
144
155
|
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (
|
|
158
|
+
selectedAttemptId !== undefined ||
|
|
159
|
+
!autoSelectActiveAttempt ||
|
|
160
|
+
userSelectedAttempt ||
|
|
161
|
+
localSelectedAttemptIds.length > 0 ||
|
|
162
|
+
autoSelectedAttemptIdRef.current !== undefined
|
|
163
|
+
) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (model.activeEntryId) autoSelectedAttemptIdRef.current = model.activeEntryId;
|
|
167
|
+
}, [
|
|
168
|
+
autoSelectActiveAttempt,
|
|
169
|
+
localSelectedAttemptIds.length,
|
|
170
|
+
model.activeEntryId,
|
|
171
|
+
selectedAttemptId,
|
|
172
|
+
userSelectedAttempt,
|
|
173
|
+
]);
|
|
174
|
+
|
|
145
175
|
useEffect(() => {
|
|
146
176
|
if (selectedAttemptId === undefined) return;
|
|
147
177
|
|
|
@@ -216,6 +246,7 @@ function StepListContent({
|
|
|
216
246
|
sourceLocation: entry.step.sourceLocation,
|
|
217
247
|
attempt: entry.attempt,
|
|
218
248
|
attemptId: entry.id,
|
|
249
|
+
attemptStartedAt: entry.startedAt,
|
|
219
250
|
attemptError: entry.error,
|
|
220
251
|
attemptStatus: entry.statusVisual.kind,
|
|
221
252
|
carriedOver: entry.carriedOver,
|
|
@@ -454,7 +485,7 @@ function StepAttemptChip({attempt}: {attempt: StepAttemptModel}) {
|
|
|
454
485
|
<div className="flex shrink-0 items-center gap-4" aria-hidden="true">
|
|
455
486
|
<span
|
|
456
487
|
className={cn(
|
|
457
|
-
'inline-flex h-18 min-w-24 items-center justify-center rounded-4 border px-
|
|
488
|
+
'inline-flex h-18 min-w-24 items-center justify-center rounded-4 border px-6 font-code text-xs leading-16 text-foreground-neutral-base',
|
|
458
489
|
attemptChipClasses[attempt.statusVisual.badge ?? 'neutral'],
|
|
459
490
|
)}
|
|
460
491
|
>
|
|
@@ -2,28 +2,50 @@ import {Icon} from '@shipfox/react-ui/icon';
|
|
|
2
2
|
import {useTimeTick} from '@shipfox/react-ui/time-ticker';
|
|
3
3
|
import {Code} from '@shipfox/react-ui/typography';
|
|
4
4
|
import {cn, humanDuration} from '@shipfox/react-ui/utils';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
WorkflowRunAttemptDisplayDuration,
|
|
7
|
+
WorkflowRunDisplayDuration,
|
|
8
|
+
} from '#core/workflow-run.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The run list reads an attempt's own marks and cannot tell queue from run, so an unkinded
|
|
12
|
+
* duration still renders as run time. Only surfaces holding the run's jobs pass a kind.
|
|
13
|
+
*/
|
|
14
|
+
export type WorkflowRunDurationDisplay =
|
|
15
|
+
| WorkflowRunAttemptDisplayDuration
|
|
16
|
+
| WorkflowRunDisplayDuration;
|
|
17
|
+
|
|
18
|
+
type DurationKind = 'queue' | 'run';
|
|
19
|
+
|
|
20
|
+
const GLYPH_BY_KIND = {queue: 'hourglassLine', run: 'timerLine'} as const;
|
|
21
|
+
const LIVE_VERB_BY_KIND = {queue: 'queued', run: 'running'} as const;
|
|
22
|
+
const FIXED_VERB_BY_KIND = {queue: 'queued', run: 'ran'} as const;
|
|
6
23
|
|
|
7
24
|
export function WorkflowRunDurationLabel({
|
|
8
25
|
duration,
|
|
9
26
|
className,
|
|
10
27
|
}: {
|
|
11
|
-
duration:
|
|
28
|
+
duration: WorkflowRunDurationDisplay | null;
|
|
12
29
|
className?: string | undefined;
|
|
13
30
|
}) {
|
|
14
31
|
if (duration === null) return null;
|
|
32
|
+
const kind = durationKind(duration);
|
|
15
33
|
|
|
16
34
|
switch (duration.state) {
|
|
17
35
|
case 'fixed': {
|
|
18
36
|
const display = formatFixedDurationLabel(duration.elapsed);
|
|
19
37
|
return (
|
|
20
|
-
<DurationText
|
|
38
|
+
<DurationText
|
|
39
|
+
className={className}
|
|
40
|
+
kind={kind}
|
|
41
|
+
ariaLabel={`${FIXED_VERB_BY_KIND[kind]} ${display}`}
|
|
42
|
+
>
|
|
21
43
|
{display}
|
|
22
44
|
</DurationText>
|
|
23
45
|
);
|
|
24
46
|
}
|
|
25
47
|
case 'live':
|
|
26
|
-
return <LiveDurationText duration={duration} className={className} />;
|
|
48
|
+
return <LiveDurationText duration={duration} kind={kind} className={className} />;
|
|
27
49
|
default: {
|
|
28
50
|
const exhaustive: never = duration;
|
|
29
51
|
return exhaustive;
|
|
@@ -32,22 +54,23 @@ export function WorkflowRunDurationLabel({
|
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
export function useWorkflowRunDurationAccessibleLabel(
|
|
35
|
-
duration:
|
|
57
|
+
duration: WorkflowRunDurationDisplay | null,
|
|
36
58
|
): string | undefined {
|
|
37
59
|
useTimeTick();
|
|
38
60
|
return workflowRunDurationAccessibleLabel(duration);
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
export function workflowRunDurationAccessibleLabel(
|
|
42
|
-
duration:
|
|
64
|
+
duration: WorkflowRunDurationDisplay | null,
|
|
43
65
|
): string | undefined {
|
|
44
66
|
if (duration === null) return undefined;
|
|
67
|
+
const kind = durationKind(duration);
|
|
45
68
|
|
|
46
69
|
switch (duration.state) {
|
|
47
70
|
case 'live':
|
|
48
|
-
return
|
|
71
|
+
return `${LIVE_VERB_BY_KIND[kind]} ${humanDuration(duration.fromIso)}`;
|
|
49
72
|
case 'fixed':
|
|
50
|
-
return
|
|
73
|
+
return `${FIXED_VERB_BY_KIND[kind]} ${formatFixedDurationLabel(duration.elapsed)}`;
|
|
51
74
|
default: {
|
|
52
75
|
const exhaustive: never = duration;
|
|
53
76
|
return exhaustive;
|
|
@@ -55,17 +78,27 @@ export function workflowRunDurationAccessibleLabel(
|
|
|
55
78
|
}
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
function durationKind(duration: WorkflowRunDurationDisplay): DurationKind {
|
|
82
|
+
return 'kind' in duration ? duration.kind : 'run';
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
function LiveDurationText({
|
|
59
86
|
duration,
|
|
87
|
+
kind,
|
|
60
88
|
className,
|
|
61
89
|
}: {
|
|
62
|
-
duration: Extract<
|
|
90
|
+
duration: Extract<WorkflowRunDurationDisplay, {state: 'live'}>;
|
|
91
|
+
kind: DurationKind;
|
|
63
92
|
className?: string | undefined;
|
|
64
93
|
}) {
|
|
65
94
|
useTimeTick();
|
|
66
95
|
const display = humanDuration(duration.fromIso);
|
|
67
96
|
return (
|
|
68
|
-
<DurationText
|
|
97
|
+
<DurationText
|
|
98
|
+
className={className}
|
|
99
|
+
kind={kind}
|
|
100
|
+
ariaLabel={`${LIVE_VERB_BY_KIND[kind]} ${display}`}
|
|
101
|
+
>
|
|
69
102
|
{display}
|
|
70
103
|
</DurationText>
|
|
71
104
|
);
|
|
@@ -74,10 +107,12 @@ function LiveDurationText({
|
|
|
74
107
|
function DurationText({
|
|
75
108
|
children,
|
|
76
109
|
className,
|
|
110
|
+
kind,
|
|
77
111
|
ariaLabel,
|
|
78
112
|
}: {
|
|
79
113
|
children: string;
|
|
80
114
|
className?: string | undefined;
|
|
115
|
+
kind: DurationKind;
|
|
81
116
|
ariaLabel?: string | undefined;
|
|
82
117
|
}) {
|
|
83
118
|
return (
|
|
@@ -90,7 +125,10 @@ function DurationText({
|
|
|
90
125
|
className,
|
|
91
126
|
)}
|
|
92
127
|
>
|
|
93
|
-
|
|
128
|
+
{/* The glyph is the only visible mark of queue vs run: the status badge already writes
|
|
129
|
+
"Queued", and the numeric columns are fixed width. The verb rides the accessible
|
|
130
|
+
name instead. */}
|
|
131
|
+
<Icon name={GLYPH_BY_KIND[kind]} className="size-12 shrink-0" aria-hidden="true" />
|
|
94
132
|
{children}
|
|
95
133
|
</Code>
|
|
96
134
|
);
|
|
@@ -104,7 +142,7 @@ function formatFixedDurationLabel({
|
|
|
104
142
|
hours = 0,
|
|
105
143
|
minutes = 0,
|
|
106
144
|
seconds = 0,
|
|
107
|
-
}: Extract<
|
|
145
|
+
}: Extract<WorkflowRunDurationDisplay, {state: 'fixed'}>['elapsed']): string {
|
|
108
146
|
const totalDays = years * 365 + months * 30 + weeks * 7 + days;
|
|
109
147
|
const totalHours = totalDays * 24 + hours;
|
|
110
148
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
workflowRunActor,
|
|
21
21
|
workflowRunBranchLabel,
|
|
22
22
|
workflowRunCommitLabel,
|
|
23
|
+
workflowRunListItemDisplay,
|
|
23
24
|
} from '#core/workflow-run.js';
|
|
24
25
|
import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
|
|
25
26
|
import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
|
|
@@ -68,8 +69,9 @@ export function WorkflowRunRow({
|
|
|
68
69
|
workspaceSlug?: string | undefined;
|
|
69
70
|
projectSlug?: string | undefined;
|
|
70
71
|
}) {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
72
|
+
const display = workflowRunListItemDisplay(run);
|
|
73
|
+
const durationLabel = useWorkflowRunDurationAccessibleLabel(display.duration);
|
|
74
|
+
const statusLabel = getWorkflowStatusVisual(display.status).label;
|
|
73
75
|
const runNumberLabel = formatWorkflowRunNumberLabel(run);
|
|
74
76
|
const branch = workflowRunBranchLabel(run);
|
|
75
77
|
const commit = workflowRunCommitLabel(run);
|
|
@@ -77,7 +79,7 @@ export function WorkflowRunRow({
|
|
|
77
79
|
|
|
78
80
|
const body = (
|
|
79
81
|
<>
|
|
80
|
-
<WorkflowStatusIcon status={
|
|
82
|
+
<WorkflowStatusIcon status={display.status} size={14} className="shrink-0" />
|
|
81
83
|
|
|
82
84
|
<div className="flex min-w-0 flex-1 flex-col gap-2 @min-[976px]:flex-row @min-[976px]:items-center @min-[976px]:gap-12">
|
|
83
85
|
<span className="flex min-w-0 items-center gap-8 @min-[976px]:flex-1">
|
|
@@ -103,7 +105,7 @@ export function WorkflowRunRow({
|
|
|
103
105
|
<JobStatusStrip jobs={run.jobs} />
|
|
104
106
|
</span>
|
|
105
107
|
<span className="flex w-64 justify-end">
|
|
106
|
-
<WorkflowRunDurationLabel duration={
|
|
108
|
+
<WorkflowRunDurationLabel duration={display.duration} />
|
|
107
109
|
</span>
|
|
108
110
|
<Code
|
|
109
111
|
variant="label"
|
|
@@ -18,6 +18,7 @@ import {WorkflowRunAttempt, type WorkflowRunStatus} from '#core/workflow-run.js'
|
|
|
18
18
|
import {
|
|
19
19
|
runAttemptsResponseDto,
|
|
20
20
|
workflowJobDto,
|
|
21
|
+
workflowJobExecutionDto,
|
|
21
22
|
workflowRunAttemptDto,
|
|
22
23
|
workflowRunDetail,
|
|
23
24
|
} from '#test/fixtures/workflow-run.js';
|
|
@@ -202,6 +203,19 @@ export const Durations: Story = {
|
|
|
202
203
|
),
|
|
203
204
|
};
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* A live attempt whose jobs have not started. The header reports the wait rather than a run
|
|
208
|
+
* that never began, and names the job the run is blocked on once there is more than one.
|
|
209
|
+
*/
|
|
210
|
+
export const Queued: Story = {
|
|
211
|
+
render: () => (
|
|
212
|
+
<div className="flex flex-col">
|
|
213
|
+
<WorkflowRunSummary run={queuedRun('release-queued', ['build'])} />
|
|
214
|
+
<WorkflowRunSummary run={queuedRun('release-queued-fan-out', ['build', 'lint', 'test'])} />
|
|
215
|
+
</div>
|
|
216
|
+
),
|
|
217
|
+
};
|
|
218
|
+
|
|
205
219
|
export const Cancellable: Story = {
|
|
206
220
|
args: {
|
|
207
221
|
run: workflowRunDetail({status: 'running'}),
|
|
@@ -209,6 +223,30 @@ export const Cancellable: Story = {
|
|
|
209
223
|
},
|
|
210
224
|
};
|
|
211
225
|
|
|
226
|
+
function queuedRun(name: string, jobNames: string[]) {
|
|
227
|
+
return workflowRunDetail({
|
|
228
|
+
status: 'running',
|
|
229
|
+
name,
|
|
230
|
+
run_attempt: workflowRunAttemptDto({
|
|
231
|
+
status: 'running',
|
|
232
|
+
created_at: RUN_STARTED_AT,
|
|
233
|
+
started_at: RUN_STARTED_AT,
|
|
234
|
+
finished_at: null,
|
|
235
|
+
}),
|
|
236
|
+
jobs: jobNames.map((jobName, index) =>
|
|
237
|
+
workflowJobDto({
|
|
238
|
+
name: jobName,
|
|
239
|
+
status: 'pending',
|
|
240
|
+
job_executions: [
|
|
241
|
+
workflowJobExecutionDto({
|
|
242
|
+
queued_at: new Date(Date.parse(RUN_STARTED_AT) + index * 1000).toISOString(),
|
|
243
|
+
}),
|
|
244
|
+
],
|
|
245
|
+
}),
|
|
246
|
+
),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
212
250
|
export const Cancelling: Story = {
|
|
213
251
|
args: {
|
|
214
252
|
run: workflowRunDetail({status: 'running'}),
|