@shipfox/client-workflows 17.0.0 → 19.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/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +23 -0
- package/dist/components/job-detail/job-context-panel.d.ts.map +1 -1
- package/dist/components/job-detail/job-context-panel.js +5 -1
- package/dist/components/job-detail/job-context-panel.js.map +1 -1
- package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-view.js +34 -27
- package/dist/components/job-detail/job-detail-view.js.map +1 -1
- package/dist/components/job-detail/job-empty-states.d.ts +4 -0
- package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
- package/dist/components/job-detail/job-empty-states.js +43 -3
- package/dist/components/job-detail/job-empty-states.js.map +1 -1
- package/dist/components/job-detail/step-troubleshooting.js +4 -0
- package/dist/components/job-detail/step-troubleshooting.js.map +1 -1
- package/dist/components/step-list/step-list-model.d.ts.map +1 -1
- package/dist/components/step-list/step-list-model.js +1 -0
- package/dist/components/step-list/step-list-model.js.map +1 -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 +20 -7
- package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
- package/dist/components/workflow-run-view/run-workspace-nav.js +6 -5
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
- package/dist/core/entities/job-execution.d.ts +2 -0
- package/dist/core/entities/job-execution.d.ts.map +1 -1
- package/dist/core/entities/job-execution.js.map +1 -1
- package/dist/core/entities/job.d.ts +6 -3
- package/dist/core/entities/job.d.ts.map +1 -1
- package/dist/core/entities/job.js +8 -1
- package/dist/core/entities/job.js.map +1 -1
- package/dist/core/entities/workflow-run.d.ts +7 -4
- package/dist/core/entities/workflow-run.d.ts.map +1 -1
- package/dist/core/entities/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 +12 -1
- package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/job-detail/job-context-panel.tsx +4 -0
- package/src/components/job-detail/job-detail-view.tsx +37 -33
- package/src/components/job-detail/job-empty-states.test.ts +105 -1
- package/src/components/job-detail/job-empty-states.tsx +56 -2
- package/src/components/job-detail/step-troubleshooting.tsx +4 -0
- package/src/components/job-graph/job-node.test.tsx +2 -2
- package/src/components/step-list/step-list-model.ts +1 -0
- package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
- package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +73 -0
- package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -0
- package/src/components/workflow-run-view/run-workspace-nav.tsx +13 -6
- package/src/core/entities/job-execution.ts +2 -0
- package/src/core/entities/job-status.test.ts +47 -1
- package/src/core/entities/job.ts +18 -4
- package/src/core/entities/workflow-run-display.test.ts +28 -0
- package/src/core/entities/workflow-run.ts +18 -4
- package/src/core/workflow-run.test.ts +13 -0
- package/src/hooks/api/workflow-run-mapper.ts +19 -1
- package/test/fixtures/workflow-run.ts +32 -12
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-workflows",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "19.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"date-fns": "^4.1.0",
|
|
30
30
|
"@shipfox/annotations-dto": "12.3.0",
|
|
31
31
|
"@shipfox/api-definitions-dto": "12.3.0",
|
|
32
|
-
"@shipfox/api-workflows-dto": "12.
|
|
32
|
+
"@shipfox/api-workflows-dto": "12.6.0",
|
|
33
33
|
"@shipfox/client-api": "6.0.1",
|
|
34
34
|
"@shipfox/client-logs": "17.0.0",
|
|
35
35
|
"@shipfox/client-projects": "17.0.0",
|
|
@@ -27,6 +27,7 @@ export function JobContextPanel({job, execution}: {job: Job; execution: JobExecu
|
|
|
27
27
|
execution.triggerEvents.length ||
|
|
28
28
|
job.success ||
|
|
29
29
|
statusReason ||
|
|
30
|
+
execution.statusReasonMessage ||
|
|
30
31
|
trace?.length ||
|
|
31
32
|
hasTiming,
|
|
32
33
|
);
|
|
@@ -114,6 +115,9 @@ function JobContextSheet({
|
|
|
114
115
|
{statusReason ? (
|
|
115
116
|
<ContextValue label="Status reason" value={humanize(statusReason)} />
|
|
116
117
|
) : null}
|
|
118
|
+
{execution.statusReasonMessage ? (
|
|
119
|
+
<ContextValue label="Failure details" value={execution.statusReasonMessage} />
|
|
120
|
+
) : null}
|
|
117
121
|
{execution.triggerEvents.length ? (
|
|
118
122
|
<JsonCode
|
|
119
123
|
title={`Trigger events (${execution.triggerEvents.length})`}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
emptyStateForJob,
|
|
34
34
|
emptyStateForMissingExecution,
|
|
35
35
|
jobSucceededSummary,
|
|
36
|
+
MaterializedOutputFailureNotice,
|
|
36
37
|
} from './job-empty-states.js';
|
|
37
38
|
import {
|
|
38
39
|
resolveWorkflowJobSelection,
|
|
@@ -258,39 +259,42 @@ export function JobDetailView({
|
|
|
258
259
|
Logs
|
|
259
260
|
</Text>
|
|
260
261
|
{selectedJobExecution ? (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
262
|
+
<>
|
|
263
|
+
<MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
|
|
264
|
+
<StepList
|
|
265
|
+
job={job}
|
|
266
|
+
jobExecution={selectedJobExecution}
|
|
267
|
+
selectedAttemptId={selectedAttemptId}
|
|
268
|
+
defaultSelectedAttemptId={landingSelection?.attemptId}
|
|
269
|
+
onSelectedAttemptChange={selectAttempt}
|
|
270
|
+
inspectorOpenAttemptId={inspectorOpenAttemptId}
|
|
271
|
+
onInspectorOpenChange={onInspectorOpenChange}
|
|
272
|
+
autoSelectActiveAttempt
|
|
273
|
+
emptyState={emptyStateForJob(job, selectedJobExecution)}
|
|
274
|
+
showHeader={false}
|
|
275
|
+
className="rounded-none border-0 bg-transparent"
|
|
276
|
+
renderExpandedStep={(context) => (
|
|
277
|
+
<ExpandedStep context={context} pageScrollRef={pageScrollRef} />
|
|
278
|
+
)}
|
|
279
|
+
renderInspector={(entry) => (
|
|
280
|
+
<StepInspectorSheet
|
|
281
|
+
entry={entry}
|
|
282
|
+
open
|
|
283
|
+
onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
|
|
284
|
+
workspaceSlug={workspaceSlug}
|
|
285
|
+
projectSlug={projectSlug}
|
|
286
|
+
workflowRunId={run.id}
|
|
287
|
+
runAttempt={run.runAttempt.attempt}
|
|
288
|
+
jobId={job.id}
|
|
289
|
+
annotationCount={annotationCountForStep(
|
|
290
|
+
annotationSummaryQuery.data,
|
|
291
|
+
entry.step.id,
|
|
292
|
+
entry.attempt,
|
|
293
|
+
)}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
/>
|
|
297
|
+
</>
|
|
294
298
|
) : (
|
|
295
299
|
<EmptyStateForMissingExecution job={job} />
|
|
296
300
|
)}
|
|
@@ -3,7 +3,13 @@ import {
|
|
|
3
3
|
workflowJobExecutionDto,
|
|
4
4
|
workflowStepDto,
|
|
5
5
|
} from '#test/fixtures/workflow-run.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
emptyStateForJob,
|
|
8
|
+
emptyStateForMissingExecution,
|
|
9
|
+
jobSucceededSummary,
|
|
10
|
+
outputFailureDescriptionForExecution,
|
|
11
|
+
skippedJobDescription,
|
|
12
|
+
} from './job-empty-states.js';
|
|
7
13
|
|
|
8
14
|
describe('jobSucceededSummary', () => {
|
|
9
15
|
test('counts only succeeded steps when skipped steps are present', () => {
|
|
@@ -25,3 +31,101 @@ describe('jobSucceededSummary', () => {
|
|
|
25
31
|
expect(jobSucceededSummary(job, execution)).toBe('1 step succeeded');
|
|
26
32
|
});
|
|
27
33
|
});
|
|
34
|
+
|
|
35
|
+
describe('skippedJobDescription', () => {
|
|
36
|
+
test('explains when materialized output exceeds the configured size limit', () => {
|
|
37
|
+
expect(skippedJobDescription('output_too_large')).toBe(
|
|
38
|
+
'The materialized job output exceeded its configured size limit.',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('materialized output failure descriptions', () => {
|
|
44
|
+
const fallback =
|
|
45
|
+
'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
|
|
46
|
+
|
|
47
|
+
test('uses the server-authored message for an execution with recorded steps', () => {
|
|
48
|
+
const job = workflowJob({
|
|
49
|
+
status: 'failed',
|
|
50
|
+
status_reason: 'output_invalid',
|
|
51
|
+
job_executions: [
|
|
52
|
+
workflowJobExecutionDto({
|
|
53
|
+
status: 'failed',
|
|
54
|
+
status_reason: 'output_invalid',
|
|
55
|
+
status_reason_message: 'Job output "payload" cannot be persisted as JSON: undefined.',
|
|
56
|
+
steps: [workflowStepDto({status: 'succeeded'})],
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const execution = job.jobExecutions[0];
|
|
61
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
62
|
+
|
|
63
|
+
expect(outputFailureDescriptionForExecution(execution)).toBe(
|
|
64
|
+
'Job output "payload" cannot be persisted as JSON: undefined.',
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('keeps the generic fallback for old output-invalid rows without a message', () => {
|
|
69
|
+
const job = workflowJob({
|
|
70
|
+
status: 'failed',
|
|
71
|
+
status_reason: 'output_invalid',
|
|
72
|
+
job_executions: [
|
|
73
|
+
workflowJobExecutionDto({
|
|
74
|
+
status: 'failed',
|
|
75
|
+
status_reason: 'output_invalid',
|
|
76
|
+
steps: [workflowStepDto({status: 'succeeded'})],
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const execution = job.jobExecutions[0];
|
|
81
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
82
|
+
|
|
83
|
+
expect(outputFailureDescriptionForExecution(execution)).toBe(fallback);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('uses the message in the empty state when output materialization failed before steps', () => {
|
|
87
|
+
const job = workflowJob({
|
|
88
|
+
status: 'failed',
|
|
89
|
+
status_reason: 'output_invalid',
|
|
90
|
+
job_executions: [
|
|
91
|
+
workflowJobExecutionDto({
|
|
92
|
+
status: 'failed',
|
|
93
|
+
status_reason: 'output_invalid',
|
|
94
|
+
status_reason_message: 'Job outputs cannot define more than 10 entries (found 11)',
|
|
95
|
+
steps: [],
|
|
96
|
+
}),
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const execution = job.jobExecutions[0];
|
|
100
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
101
|
+
|
|
102
|
+
expect(emptyStateForJob(job, execution)).toMatchObject({
|
|
103
|
+
description: 'Job outputs cannot define more than 10 entries (found 11)',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('keeps output failures scoped to the selected execution', () => {
|
|
108
|
+
const job = workflowJob({
|
|
109
|
+
status: 'failed',
|
|
110
|
+
status_reason: 'output_invalid',
|
|
111
|
+
job_executions: [
|
|
112
|
+
workflowJobExecutionDto({
|
|
113
|
+
status: 'succeeded',
|
|
114
|
+
status_reason: null,
|
|
115
|
+
steps: [workflowStepDto({status: 'succeeded'})],
|
|
116
|
+
}),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
const execution = job.jobExecutions[0];
|
|
120
|
+
if (!execution) throw new Error('Expected a job execution');
|
|
121
|
+
|
|
122
|
+
expect(outputFailureDescriptionForExecution(execution)).toBeUndefined();
|
|
123
|
+
expect(
|
|
124
|
+
emptyStateForMissingExecution(
|
|
125
|
+
workflowJob({status: 'failed', status_reason: 'output_invalid'}),
|
|
126
|
+
),
|
|
127
|
+
).toMatchObject({
|
|
128
|
+
description: fallback,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {Callout, CalloutContent, CalloutDescription, CalloutTitle} from '@shipfox/react-ui/callout';
|
|
1
2
|
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
2
3
|
import type {Job, JobExecution, Step, StepError} from '#core/workflow-run.js';
|
|
3
4
|
import {
|
|
@@ -9,6 +10,44 @@ import {
|
|
|
9
10
|
import type {StepListEmptyState} from '../step-list/index.js';
|
|
10
11
|
import {formatJobExecutionTime} from './job-execution-time-text.js';
|
|
11
12
|
|
|
13
|
+
const MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION =
|
|
14
|
+
'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
|
|
15
|
+
const OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION =
|
|
16
|
+
'The materialized job output exceeded its configured size limit. Review the failure details before re-running the workflow.';
|
|
17
|
+
|
|
18
|
+
export function outputFailureDescriptionForExecution(
|
|
19
|
+
jobExecution: JobExecution,
|
|
20
|
+
): string | undefined {
|
|
21
|
+
if (jobExecution.steps.length === 0) return undefined;
|
|
22
|
+
|
|
23
|
+
if (jobExecution.statusReason === 'output_invalid') {
|
|
24
|
+
return jobExecution.statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
|
|
25
|
+
}
|
|
26
|
+
if (jobExecution.statusReason === 'output_too_large') {
|
|
27
|
+
return jobExecution.statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function MaterializedOutputFailureNotice({jobExecution}: {jobExecution: JobExecution}) {
|
|
33
|
+
const description = outputFailureDescriptionForExecution(jobExecution);
|
|
34
|
+
if (!description) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Callout
|
|
38
|
+
role="alert"
|
|
39
|
+
type="error"
|
|
40
|
+
variant="secondary"
|
|
41
|
+
className="border-b border-border-neutral-base px-row py-row"
|
|
42
|
+
>
|
|
43
|
+
<CalloutContent>
|
|
44
|
+
<CalloutTitle>Job output could not be persisted</CalloutTitle>
|
|
45
|
+
<CalloutDescription>{description}</CalloutDescription>
|
|
46
|
+
</CalloutContent>
|
|
47
|
+
</Callout>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
export function emptyStateForJob(
|
|
13
52
|
job: Job,
|
|
14
53
|
jobExecution: JobExecution,
|
|
@@ -64,7 +103,11 @@ export function emptyStateForJob(
|
|
|
64
103
|
if (displayStatus === 'failed') {
|
|
65
104
|
return {
|
|
66
105
|
title: 'Job failed before its first step started',
|
|
67
|
-
description: preStepFailureDescription(
|
|
106
|
+
description: preStepFailureDescription(
|
|
107
|
+
jobExecution.statusReason ?? job.statusReason,
|
|
108
|
+
runner,
|
|
109
|
+
jobExecution.statusReasonMessage,
|
|
110
|
+
),
|
|
68
111
|
status: displayStatus,
|
|
69
112
|
};
|
|
70
113
|
}
|
|
@@ -159,14 +202,21 @@ export function skippedJobDescription(reason: Job['statusReason']): string {
|
|
|
159
202
|
case 'run_cancelled':
|
|
160
203
|
case 'timed_out':
|
|
161
204
|
case 'runner_lost':
|
|
205
|
+
case 'output_invalid':
|
|
162
206
|
case 'step_failed':
|
|
163
207
|
case 'unknown':
|
|
164
208
|
case null:
|
|
165
209
|
return 'This job did not start.';
|
|
210
|
+
case 'output_too_large':
|
|
211
|
+
return 'The materialized job output exceeded its configured size limit.';
|
|
166
212
|
}
|
|
167
213
|
}
|
|
168
214
|
|
|
169
|
-
function preStepFailureDescription(
|
|
215
|
+
function preStepFailureDescription(
|
|
216
|
+
reason: string | null,
|
|
217
|
+
runner: string[] | null = null,
|
|
218
|
+
statusReasonMessage: string | null = null,
|
|
219
|
+
): string {
|
|
170
220
|
const runnerCopy = runner?.length ? ` Required runner labels: ${runner.join(', ')}.` : '';
|
|
171
221
|
|
|
172
222
|
switch (reason) {
|
|
@@ -179,6 +229,10 @@ function preStepFailureDescription(reason: string | null, runner: string[] | nul
|
|
|
179
229
|
return 'The execution ended while the run was being cancelled.';
|
|
180
230
|
case 'step_failed':
|
|
181
231
|
return `The execution failed before step details were recorded.${runnerCopy} Review run annotations before re-running the workflow.`;
|
|
232
|
+
case 'output_too_large':
|
|
233
|
+
return statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
|
|
234
|
+
case 'output_invalid':
|
|
235
|
+
return statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
|
|
182
236
|
case 'condition_errored':
|
|
183
237
|
return 'The job condition could not be evaluated. Review run annotations before re-running the workflow.';
|
|
184
238
|
case 'dependency_not_completed':
|
|
@@ -442,6 +442,8 @@ function failureTitle(reason: string | JobStatusReason): string {
|
|
|
442
442
|
return 'Agent harness was unavailable';
|
|
443
443
|
case 'runner_lost':
|
|
444
444
|
return 'Runner stopped responding';
|
|
445
|
+
case 'output_too_large':
|
|
446
|
+
return 'Job output exceeded its size limit';
|
|
445
447
|
case 'timed_out':
|
|
446
448
|
return 'Step timed out';
|
|
447
449
|
case 'step_failed':
|
|
@@ -489,6 +491,8 @@ function failureDescription(reason: string | JobStatusReason): string {
|
|
|
489
491
|
return 'The runner could not start the agent harness.';
|
|
490
492
|
case 'runner_lost':
|
|
491
493
|
return 'The runner stopped responding before the step completed.';
|
|
494
|
+
case 'output_too_large':
|
|
495
|
+
return 'The materialized job output exceeded its configured size limit.';
|
|
492
496
|
case 'timed_out':
|
|
493
497
|
return 'The step exceeded its configured time limit.';
|
|
494
498
|
case 'dependency_not_completed':
|
|
@@ -332,7 +332,7 @@ describe('JobNode status indicator', () => {
|
|
|
332
332
|
expect(screen.getByRole('button', {name: 'deploy-prod, Succeeded'})).toBeInTheDocument();
|
|
333
333
|
});
|
|
334
334
|
|
|
335
|
-
test('shows a running lifecycle without a running step as
|
|
335
|
+
test('shows a running lifecycle without a running step as running', () => {
|
|
336
336
|
const node = makeNode({
|
|
337
337
|
name: 'deploy',
|
|
338
338
|
status: 'pending',
|
|
@@ -341,7 +341,7 @@ describe('JobNode status indicator', () => {
|
|
|
341
341
|
|
|
342
342
|
renderNode(node);
|
|
343
343
|
|
|
344
|
-
expect(screen.getByRole('button', {name: 'deploy,
|
|
344
|
+
expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
|
|
345
345
|
});
|
|
346
346
|
|
|
347
347
|
test('shows a pending lifecycle as running while a step is active', () => {
|
|
@@ -77,6 +77,7 @@ export function emptyJobExecutionForJob(job: Job): JobExecution {
|
|
|
77
77
|
name: job.name ?? job.key,
|
|
78
78
|
status: job.status === 'skipped' ? 'cancelled' : job.status,
|
|
79
79
|
statusReason: job.statusReason,
|
|
80
|
+
statusReasonMessage: null,
|
|
80
81
|
runner: job.runner,
|
|
81
82
|
outputs: job.outputs,
|
|
82
83
|
triggerEvents: [],
|
|
@@ -3,7 +3,8 @@ import {Code, Text} from '@shipfox/react-ui/typography';
|
|
|
3
3
|
import {cn} from '@shipfox/react-ui/utils';
|
|
4
4
|
import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
|
|
5
5
|
import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
|
|
6
|
-
import
|
|
6
|
+
import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
|
|
7
|
+
import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* How many glyphs fit the strip's column before it starts costing the run name width.
|
|
@@ -33,16 +34,17 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
|
|
|
33
34
|
|
|
34
35
|
// Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
|
|
35
36
|
// A strip that hides a failure behind eight green discs would be worse than no strip at all.
|
|
36
|
-
const STATUS_SEVERITY: Record<
|
|
37
|
+
const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
|
|
37
38
|
failed: 5,
|
|
38
39
|
running: 4,
|
|
40
|
+
listening: 4,
|
|
39
41
|
pending: 3,
|
|
40
42
|
cancelled: 2,
|
|
41
43
|
skipped: 1,
|
|
42
44
|
succeeded: 0,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
|
-
const NOTABLE_STATUSES: readonly
|
|
47
|
+
const NOTABLE_STATUSES: readonly JobDisplayStatus[] = ['failed', 'running', 'listening'];
|
|
46
48
|
|
|
47
49
|
export interface JobStatusStripProps {
|
|
48
50
|
jobs: WorkflowRunJobs;
|
|
@@ -80,7 +82,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
|
|
|
80
82
|
{visible.map((job) => (
|
|
81
83
|
<WorkflowStatusIcon
|
|
82
84
|
key={job.id}
|
|
83
|
-
status={job
|
|
85
|
+
status={displayStatus(job)}
|
|
84
86
|
size={GLYPH_SIZE}
|
|
85
87
|
// One ripple per running job, on every row, would turn a calm list into a
|
|
86
88
|
// field of pulses. The run's own glyph already carries the live edge.
|
|
@@ -121,7 +123,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
|
|
|
121
123
|
// can be named, but the count of those left over is read off the totals, so a failure past
|
|
122
124
|
// the preview is still accounted for rather than silently missing.
|
|
123
125
|
const named = jobs.preview
|
|
124
|
-
.filter((job) => NOTABLE_STATUSES.includes(job
|
|
126
|
+
.filter((job) => NOTABLE_STATUSES.includes(displayStatus(job)))
|
|
125
127
|
.slice(0, MAX_TOOLTIP_JOB_NAMES);
|
|
126
128
|
const notableTotal = NOTABLE_STATUSES.reduce((total, status) => total + countOf(jobs, status), 0);
|
|
127
129
|
const remaining = notableTotal - named.length;
|
|
@@ -133,7 +135,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
|
|
|
133
135
|
</Text>
|
|
134
136
|
{named.map((job) => (
|
|
135
137
|
<Code as="span" variant="label" key={job.id} className="block truncate">
|
|
136
|
-
{getWorkflowStatusVisual(job
|
|
138
|
+
{getWorkflowStatusVisual(displayStatus(job)).label.toLowerCase()} · {job.name ?? job.key}
|
|
137
139
|
</Code>
|
|
138
140
|
))}
|
|
139
141
|
{remaining > 0 ? (
|
|
@@ -162,15 +164,16 @@ export function jobStatusSummary(jobs: WorkflowRunJobs): string {
|
|
|
162
164
|
function worstHiddenStatus(
|
|
163
165
|
jobs: WorkflowRunJobs,
|
|
164
166
|
visible: readonly WorkflowRunJobSummary[],
|
|
165
|
-
):
|
|
167
|
+
): JobDisplayStatus | null {
|
|
166
168
|
const hidden = new Map(jobs.statusCounts.map(({status, count}) => [status, count]));
|
|
167
169
|
for (const job of visible) {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
const status = displayStatus(job);
|
|
171
|
+
const remaining = (hidden.get(status) ?? 0) - 1;
|
|
172
|
+
if (remaining > 0) hidden.set(status, remaining);
|
|
173
|
+
else hidden.delete(status);
|
|
171
174
|
}
|
|
172
175
|
|
|
173
|
-
let worst:
|
|
176
|
+
let worst: JobDisplayStatus | null = null;
|
|
174
177
|
for (const [status, count] of hidden) {
|
|
175
178
|
if (count <= 0) continue;
|
|
176
179
|
if (worst === null || STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) worst = status;
|
|
@@ -184,6 +187,16 @@ export function overflowCountLabel(hiddenCount: number): string {
|
|
|
184
187
|
: COMPACT_COUNT_FORMAT.format(hiddenCount);
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
function countOf(jobs: WorkflowRunJobs, status:
|
|
190
|
+
function countOf(jobs: WorkflowRunJobs, status: JobDisplayStatus): number {
|
|
188
191
|
return jobs.statusCounts.find((entry) => entry.status === status)?.count ?? 0;
|
|
189
192
|
}
|
|
193
|
+
|
|
194
|
+
function displayStatus(job: WorkflowRunJobSummary): JobDisplayStatus {
|
|
195
|
+
return deriveJobDisplayStatus({
|
|
196
|
+
mode: job.mode,
|
|
197
|
+
status: job.status,
|
|
198
|
+
listenerStatus: job.listenerStatus,
|
|
199
|
+
executionStatus: job.executionStatus,
|
|
200
|
+
jobExecutions: [],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -307,6 +307,53 @@ describe('WorkflowRunListView', () => {
|
|
|
307
307
|
).toBeInTheDocument();
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
+
test('shows an executing one-shot job instead of its pending verdict', async () => {
|
|
311
|
+
const {jobs} = workflowRunJobsFixture(['pending']);
|
|
312
|
+
renderListView([
|
|
313
|
+
run('running', 'deploy-web', 'run-1', {
|
|
314
|
+
jobs: jobs.map((job) => ({...job, execution_status: 'running'})),
|
|
315
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
316
|
+
job_display_status_counts: [{status: 'running', count: 1}],
|
|
317
|
+
}),
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 running'});
|
|
321
|
+
expect(within(strip).getByLabelText('Running')).toBeInTheDocument();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('shows an active listener as listening in the job strip', async () => {
|
|
325
|
+
const {jobs} = workflowRunJobsFixture(['running']);
|
|
326
|
+
renderListView([
|
|
327
|
+
run('running', 'event-driven', 'run-1', {
|
|
328
|
+
jobs: jobs.map((job) => ({
|
|
329
|
+
...job,
|
|
330
|
+
mode: 'listening',
|
|
331
|
+
listener_status: 'listening',
|
|
332
|
+
execution_status: null,
|
|
333
|
+
})),
|
|
334
|
+
job_status_counts: [{status: 'running', count: 1}],
|
|
335
|
+
job_display_status_counts: [{status: 'listening', count: 1}],
|
|
336
|
+
}),
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 listening'});
|
|
340
|
+
expect(within(strip).getByLabelText('Listening')).toBeInTheDocument();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('shows a failed execution when the job verdict is still pending', async () => {
|
|
344
|
+
const {jobs} = workflowRunJobsFixture(['pending']);
|
|
345
|
+
renderListView([
|
|
346
|
+
run('running', 'deploy-web', 'run-1', {
|
|
347
|
+
jobs: jobs.map((job) => ({...job, execution_status: 'failed'})),
|
|
348
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
349
|
+
job_display_status_counts: [{status: 'failed', count: 1}],
|
|
350
|
+
}),
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
const strip = await screen.findByRole('img', {name: '1 job: 1 failed'});
|
|
354
|
+
expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
|
|
355
|
+
});
|
|
356
|
+
|
|
310
357
|
// The link's aria-label replaces its contents, so the strip's own label is not spoken
|
|
311
358
|
// when the row is announced as a link. Where a run failed has to survive that.
|
|
312
359
|
test('carries the job breakdown into the row link name', async () => {
|
|
@@ -355,6 +402,32 @@ describe('WorkflowRunListView', () => {
|
|
|
355
402
|
expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
|
|
356
403
|
});
|
|
357
404
|
|
|
405
|
+
test('reports listening jobs in the overflow glyph and tooltip', async () => {
|
|
406
|
+
const {jobs} = workflowRunJobsFixture(
|
|
407
|
+
Array.from({length: 16}, () => 'pending') as JobStatusDto[],
|
|
408
|
+
);
|
|
409
|
+
renderListView([
|
|
410
|
+
run('running', 'event-driven', 'run-listener', {
|
|
411
|
+
jobs: jobs.map((job) => ({
|
|
412
|
+
...job,
|
|
413
|
+
mode: 'listening',
|
|
414
|
+
listener_status: 'listening',
|
|
415
|
+
execution_status: null,
|
|
416
|
+
})),
|
|
417
|
+
job_status_counts: [{status: 'pending', count: 20}],
|
|
418
|
+
job_display_status_counts: [{status: 'listening', count: 20}],
|
|
419
|
+
}),
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
|
|
423
|
+
expect(screen.getByText('+13')).toBeInTheDocument();
|
|
424
|
+
expect(within(strip).getAllByLabelText('Listening')).toHaveLength(8);
|
|
425
|
+
|
|
426
|
+
const user = userEvent.setup();
|
|
427
|
+
await user.hover(strip);
|
|
428
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent('and 14 more');
|
|
429
|
+
});
|
|
430
|
+
|
|
358
431
|
// Nothing caps a workflow's job count, so an exact overflow count is unbounded in width
|
|
359
432
|
// and would eventually paint over the duration column beside it.
|
|
360
433
|
test('abbreviates an overflow count too wide to print exactly', async () => {
|
|
@@ -101,6 +101,12 @@ type Story = StoryObj<typeof meta>;
|
|
|
101
101
|
|
|
102
102
|
export const Playground: Story = {};
|
|
103
103
|
|
|
104
|
+
export const ExecutionStates: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
runs: [makeExecutionStateRun(), makeListeningStateRun()],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
104
110
|
/**
|
|
105
111
|
* The row under its one-line threshold, at the 720px a 768px viewport leaves the column.
|
|
106
112
|
*
|
|
@@ -229,3 +235,28 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
|
|
|
229
235
|
</div>
|
|
230
236
|
);
|
|
231
237
|
}
|
|
238
|
+
|
|
239
|
+
function makeExecutionStateRun(): WorkflowRunListItem {
|
|
240
|
+
const fixture = workflowRunJobsFixture(['pending']);
|
|
241
|
+
return sequencedWorkflowRunListItem('running', 'one-shot-executing', 1, {
|
|
242
|
+
...fixture,
|
|
243
|
+
jobs: fixture.jobs.map((job) => ({...job, execution_status: 'running'})),
|
|
244
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
245
|
+
job_display_status_counts: [{status: 'running', count: 1}],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function makeListeningStateRun(): WorkflowRunListItem {
|
|
250
|
+
const fixture = workflowRunJobsFixture(['pending']);
|
|
251
|
+
return sequencedWorkflowRunListItem('running', 'event-driven-listener', 3, {
|
|
252
|
+
...fixture,
|
|
253
|
+
jobs: fixture.jobs.map((job) => ({
|
|
254
|
+
...job,
|
|
255
|
+
mode: 'listening',
|
|
256
|
+
listener_status: 'listening',
|
|
257
|
+
execution_status: null,
|
|
258
|
+
})),
|
|
259
|
+
job_status_counts: [{status: 'pending', count: 1}],
|
|
260
|
+
job_display_status_counts: [{status: 'listening', count: 1}],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -53,8 +53,12 @@ export function RunWorkspaceNav({
|
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
55
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
56
|
-
<aside className="w-full shrink-0 border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
|
|
57
|
-
<Collapsible
|
|
56
|
+
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
|
|
57
|
+
<Collapsible
|
|
58
|
+
open={mobileOpen}
|
|
59
|
+
onOpenChange={setMobileOpen}
|
|
60
|
+
className="flex min-h-0 flex-col min-[768px]:flex-1"
|
|
61
|
+
>
|
|
58
62
|
<CollapsibleTrigger asChild>
|
|
59
63
|
<button
|
|
60
64
|
type="button"
|
|
@@ -82,7 +86,7 @@ export function RunWorkspaceNav({
|
|
|
82
86
|
</CollapsibleTrigger>
|
|
83
87
|
<div
|
|
84
88
|
className={cn(
|
|
85
|
-
'max-h-[50vh] overflow-y-auto p-tight min-[768px]:
|
|
89
|
+
'flex min-h-0 max-h-[50vh] flex-col overflow-y-auto p-tight scrollbar min-[768px]:min-h-0 min-[768px]:max-h-none min-[768px]:flex-1 min-[768px]:flex min-[768px]:overflow-hidden min-[768px]:p-[12px]',
|
|
86
90
|
!mobileOpen && 'hidden',
|
|
87
91
|
)}
|
|
88
92
|
>
|
|
@@ -136,7 +140,7 @@ function RunWorkspaceNavContent({
|
|
|
136
140
|
}, [currentJobId, mobileOpen]);
|
|
137
141
|
|
|
138
142
|
return (
|
|
139
|
-
<nav aria-label="Run workspace" className="flex min-w-0 flex-col gap-group">
|
|
143
|
+
<nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col gap-group">
|
|
140
144
|
<ul>
|
|
141
145
|
<li>
|
|
142
146
|
<RunSectionLink
|
|
@@ -151,7 +155,10 @@ function RunWorkspaceNavContent({
|
|
|
151
155
|
</li>
|
|
152
156
|
</ul>
|
|
153
157
|
|
|
154
|
-
<section
|
|
158
|
+
<section
|
|
159
|
+
aria-labelledby="run-workspace-jobs-heading"
|
|
160
|
+
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
|
161
|
+
>
|
|
155
162
|
<div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
|
|
156
163
|
<Text
|
|
157
164
|
as="h2"
|
|
@@ -166,7 +173,7 @@ function RunWorkspaceNavContent({
|
|
|
166
173
|
{jobs.length}
|
|
167
174
|
</Text>
|
|
168
175
|
</div>
|
|
169
|
-
<ol className="
|
|
176
|
+
<ol className="min-h-0 flex-1 overflow-y-auto scrollbar">
|
|
170
177
|
{jobs.map((job) => {
|
|
171
178
|
const current = job.id === currentJobId;
|
|
172
179
|
const execution = defaultJobExecution(job);
|