@shipfox/client-workflows 20.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 +29 -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-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 +13 -10
- 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 +1 -1
- 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/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 +7 -7
- 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-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 +53 -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 +17 -13
- package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -2
- package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +2 -1
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -4
- 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/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/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": "21.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -31,12 +31,12 @@
|
|
|
31
31
|
"@shipfox/api-definitions-dto": "12.3.0",
|
|
32
32
|
"@shipfox/api-workflows-dto": "12.7.0",
|
|
33
33
|
"@shipfox/client-api": "6.0.1",
|
|
34
|
-
"@shipfox/client-logs": "
|
|
35
|
-
"@shipfox/client-projects": "
|
|
36
|
-
"@shipfox/client-shell": "
|
|
37
|
-
"@shipfox/client-triggers": "
|
|
38
|
-
"@shipfox/client-ui": "
|
|
39
|
-
"@shipfox/react-ui": "
|
|
34
|
+
"@shipfox/client-logs": "21.0.0",
|
|
35
|
+
"@shipfox/client-projects": "21.0.0",
|
|
36
|
+
"@shipfox/client-shell": "21.0.0",
|
|
37
|
+
"@shipfox/client-triggers": "21.0.0",
|
|
38
|
+
"@shipfox/client-ui": "21.0.0",
|
|
39
|
+
"@shipfox/react-ui": "2.0.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
import {ApiError} from '@shipfox/client-api';
|
|
5
5
|
import {QueryLoadError} from '@shipfox/client-ui';
|
|
6
|
+
import {Badge} from '@shipfox/react-ui/badge';
|
|
7
|
+
import {IconButton} from '@shipfox/react-ui/button';
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuCheckboxItem,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuLabel,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '@shipfox/react-ui/dropdown-menu';
|
|
6
15
|
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
7
16
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
17
|
+
import {Panel, PanelActions, PanelBody, PanelHeader} from '@shipfox/react-ui/panel';
|
|
18
|
+
import {SearchInline} from '@shipfox/react-ui/search';
|
|
8
19
|
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
9
20
|
import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
|
|
10
21
|
import {Text} from '@shipfox/react-ui/typography';
|
|
11
22
|
import {Link} from '@tanstack/react-router';
|
|
12
|
-
import {type RefObject, useEffect, useRef, useState} from 'react';
|
|
23
|
+
import {type RefObject, useCallback, useEffect, useRef, useState} from 'react';
|
|
13
24
|
import type {RunAnnotationSummary} from '#core/run-annotation.js';
|
|
14
25
|
import {summarizeJobAnnotations} from '#core/run-annotation.js';
|
|
15
26
|
import {isWorkflowRunTerminal, type Job, type JobExecution} from '#core/workflow-run.js';
|
|
@@ -22,6 +33,11 @@ import {
|
|
|
22
33
|
workflowRunSearchParams,
|
|
23
34
|
} from '#routes/inputs.js';
|
|
24
35
|
import {type StepExpandedContext, StepList} from '../step-list/index.js';
|
|
36
|
+
import {
|
|
37
|
+
buildStepListModel,
|
|
38
|
+
getStepStatusVisual,
|
|
39
|
+
type StepListModel,
|
|
40
|
+
} from '../step-list/step-list-model.js';
|
|
25
41
|
import {
|
|
26
42
|
WorkflowRunNotFound,
|
|
27
43
|
WorkflowRunStaleError,
|
|
@@ -71,6 +87,12 @@ export function JobDetailView({
|
|
|
71
87
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
72
88
|
const pageScrollRef = useRef<HTMLDivElement>(null);
|
|
73
89
|
const landingSelectionRef = useRef<FrozenLandingSelection | undefined>(undefined);
|
|
90
|
+
const [logSearch, setLogSearch] = useState('');
|
|
91
|
+
const [wrapLogs, setWrapLogs] = useState(false);
|
|
92
|
+
const [showLineNumbers, setShowLineNumbers] = useState(true);
|
|
93
|
+
const [expandedLogAttemptIds, setExpandedLogAttemptIds] = useState<readonly string[]>([]);
|
|
94
|
+
const [logRefreshTokens, setLogRefreshTokens] = useState<Record<string, number>>({});
|
|
95
|
+
const [logFetchingByAttemptId, setLogFetchingByAttemptId] = useState<Record<string, boolean>>({});
|
|
74
96
|
const hasLoadedData = query.data !== undefined;
|
|
75
97
|
// Reuse the run workspace's bounded annotation read for the job header chip. The separate
|
|
76
98
|
// summary query below stays counts-only and is scoped to the inspector's selected execution.
|
|
@@ -112,6 +134,13 @@ export function JobDetailView({
|
|
|
112
134
|
heading?.focus({preventScroll: true});
|
|
113
135
|
}, [hasLoadedData, jobId]);
|
|
114
136
|
|
|
137
|
+
const handleLogFetchingChange = useCallback((attemptId: string, isFetching: boolean) => {
|
|
138
|
+
setLogFetchingByAttemptId((current) => {
|
|
139
|
+
if (current[attemptId] === isFetching) return current;
|
|
140
|
+
return {...current, [attemptId]: isFetching};
|
|
141
|
+
});
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
115
144
|
if (query.isPending) return <JobDetailSkeleton />;
|
|
116
145
|
|
|
117
146
|
if (query.isError && query.data === undefined) {
|
|
@@ -139,6 +168,7 @@ export function JobDetailView({
|
|
|
139
168
|
const resolvedSelection = resolveWorkflowJobSelection({job, selection: search});
|
|
140
169
|
const selectedJobExecution = resolvedSelection.jobExecution;
|
|
141
170
|
const hasExplicitStep = Boolean(search.stepId && resolvedSelection.step);
|
|
171
|
+
const stepListModel = buildStepListModel({job, jobExecution: selectedJobExecution});
|
|
142
172
|
const currentLandingSelection = workflowJobLandingSelection(selectedJobExecution);
|
|
143
173
|
if (selectedJobExecution) {
|
|
144
174
|
const frozenLanding = landingSelectionRef.current;
|
|
@@ -165,11 +195,22 @@ export function JobDetailView({
|
|
|
165
195
|
}
|
|
166
196
|
const landingSelection = landingSelectionRef.current?.selection;
|
|
167
197
|
const selectedAttemptId = hasExplicitStep ? resolvedSelection.selectedAttemptId : undefined;
|
|
168
|
-
const runningSelection = runningStepSelection(selectedJobExecution);
|
|
198
|
+
const runningSelection = runningStepSelection(selectedJobExecution, stepListModel);
|
|
169
199
|
const selectedStepId = resolvedSelection.step?.id ?? landingSelection?.stepId;
|
|
170
200
|
const selectedAttemptForNotice = hasExplicitStep
|
|
171
201
|
? resolvedSelection.selectedAttemptId
|
|
172
202
|
: landingSelection?.attemptId;
|
|
203
|
+
const expandedLogSelection = findExpandedLogSelection(
|
|
204
|
+
selectedJobExecution,
|
|
205
|
+
expandedLogAttemptIds,
|
|
206
|
+
stepListModel,
|
|
207
|
+
);
|
|
208
|
+
const selectedLogStep = expandedLogSelection?.step;
|
|
209
|
+
const selectedLogAttempt = expandedLogSelection?.attempt;
|
|
210
|
+
const selectedLogStatus = selectedLogAttempt?.status ?? selectedLogStep?.status;
|
|
211
|
+
const logIsFetching = Boolean(
|
|
212
|
+
selectedLogAttempt && logFetchingByAttemptId[selectedLogAttempt.id],
|
|
213
|
+
);
|
|
173
214
|
const showRetargetNotice =
|
|
174
215
|
runningSelection !== undefined &&
|
|
175
216
|
(selectedStepId !== runningSelection.stepId ||
|
|
@@ -224,6 +265,14 @@ export function JobDetailView({
|
|
|
224
265
|
});
|
|
225
266
|
}
|
|
226
267
|
|
|
268
|
+
function refreshLogs() {
|
|
269
|
+
if (!selectedLogAttempt) return;
|
|
270
|
+
setLogRefreshTokens((current) => ({
|
|
271
|
+
...current,
|
|
272
|
+
[selectedLogAttempt.id]: (current[selectedLogAttempt.id] ?? 0) + 1,
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
|
|
227
276
|
return (
|
|
228
277
|
<TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
|
|
229
278
|
<div ref={rootRef} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
|
@@ -255,49 +304,77 @@ export function JobDetailView({
|
|
|
255
304
|
) : undefined
|
|
256
305
|
}
|
|
257
306
|
/>
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
307
|
+
<Panel data-job-log-panel className="min-w-0">
|
|
308
|
+
<JobLogPanelHeader
|
|
309
|
+
stepLabel={expandedLogSelection?.stepLabel}
|
|
310
|
+
attempt={selectedLogAttempt?.attempt}
|
|
311
|
+
status={selectedLogStatus}
|
|
312
|
+
search={logSearch}
|
|
313
|
+
onSearchChange={setLogSearch}
|
|
314
|
+
onRefresh={refreshLogs}
|
|
315
|
+
refreshing={logIsFetching}
|
|
316
|
+
showLineNumbers={showLineNumbers}
|
|
317
|
+
onShowLineNumbersChange={setShowLineNumbers}
|
|
318
|
+
wrap={wrapLogs}
|
|
319
|
+
onWrapChange={setWrapLogs}
|
|
320
|
+
disabled={!selectedLogAttempt}
|
|
321
|
+
/>
|
|
322
|
+
<PanelBody className="min-w-0 p-0">
|
|
323
|
+
<Text as="h2" className="sr-only">
|
|
324
|
+
Logs
|
|
325
|
+
</Text>
|
|
326
|
+
{selectedJobExecution ? (
|
|
327
|
+
<>
|
|
328
|
+
<MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
|
|
329
|
+
<StepList
|
|
330
|
+
job={job}
|
|
331
|
+
jobExecution={selectedJobExecution}
|
|
332
|
+
selectedAttemptId={selectedAttemptId}
|
|
333
|
+
defaultSelectedAttemptId={landingSelection?.attemptId}
|
|
334
|
+
onSelectedAttemptChange={selectAttempt}
|
|
335
|
+
onExpandedAttemptIdsChange={setExpandedLogAttemptIds}
|
|
336
|
+
inspectorOpenAttemptId={inspectorOpenAttemptId}
|
|
337
|
+
onInspectorOpenChange={onInspectorOpenChange}
|
|
338
|
+
autoSelectActiveAttempt
|
|
339
|
+
emptyState={emptyStateForJob(job, selectedJobExecution)}
|
|
340
|
+
showHeader={false}
|
|
341
|
+
className="rounded-none border-0 bg-transparent shadow-none"
|
|
342
|
+
renderExpandedStep={(context) => (
|
|
343
|
+
<ExpandedStep
|
|
344
|
+
context={context}
|
|
345
|
+
pageScrollRef={pageScrollRef}
|
|
346
|
+
search={logSearch}
|
|
347
|
+
wrap={wrapLogs}
|
|
348
|
+
showLineNumbers={showLineNumbers}
|
|
349
|
+
attemptId={context.attemptId}
|
|
350
|
+
refreshToken={logRefreshTokens[context.attemptId] ?? 0}
|
|
351
|
+
onFetchingChange={handleLogFetchingChange}
|
|
352
|
+
/>
|
|
353
|
+
)}
|
|
354
|
+
renderInspector={(entry) => (
|
|
355
|
+
<StepInspectorSheet
|
|
356
|
+
entry={entry}
|
|
357
|
+
open
|
|
358
|
+
onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
|
|
359
|
+
workspaceSlug={workspaceSlug}
|
|
360
|
+
projectSlug={projectSlug}
|
|
361
|
+
workflowRunId={run.id}
|
|
362
|
+
runAttempt={run.runAttempt.attempt}
|
|
363
|
+
jobId={job.id}
|
|
364
|
+
annotationCount={annotationCountForStep(
|
|
365
|
+
annotationSummaryQuery.data,
|
|
366
|
+
entry.step.id,
|
|
367
|
+
entry.attempt,
|
|
368
|
+
)}
|
|
369
|
+
/>
|
|
293
370
|
)}
|
|
294
371
|
/>
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
372
|
+
</>
|
|
373
|
+
) : (
|
|
374
|
+
<EmptyStateForMissingExecution job={job} />
|
|
375
|
+
)}
|
|
376
|
+
</PanelBody>
|
|
377
|
+
</Panel>
|
|
301
378
|
</section>
|
|
302
379
|
{showRetargetNotice && runningSelection ? (
|
|
303
380
|
<div
|
|
@@ -336,9 +413,21 @@ export function JobDetailView({
|
|
|
336
413
|
function ExpandedStep({
|
|
337
414
|
context,
|
|
338
415
|
pageScrollRef,
|
|
416
|
+
search,
|
|
417
|
+
wrap,
|
|
418
|
+
showLineNumbers,
|
|
419
|
+
attemptId,
|
|
420
|
+
refreshToken,
|
|
421
|
+
onFetchingChange,
|
|
339
422
|
}: {
|
|
340
423
|
context: StepExpandedContext;
|
|
341
424
|
pageScrollRef: RefObject<HTMLDivElement | null>;
|
|
425
|
+
search: string;
|
|
426
|
+
wrap: boolean;
|
|
427
|
+
showLineNumbers: boolean;
|
|
428
|
+
attemptId: string;
|
|
429
|
+
refreshToken: number;
|
|
430
|
+
onFetchingChange: (attemptId: string, isFetching: boolean) => void;
|
|
342
431
|
}) {
|
|
343
432
|
if (context.carriedOver) return <CarriedOverStepPanel />;
|
|
344
433
|
|
|
@@ -347,7 +436,7 @@ function ExpandedStep({
|
|
|
347
436
|
role="region"
|
|
348
437
|
tabIndex={0}
|
|
349
438
|
aria-label={`${context.stepLabel} output, attempt ${context.attempt}`}
|
|
350
|
-
className="flex min-w-0 flex-col border-t border-border-neutral-base bg-background-
|
|
439
|
+
className="flex min-w-0 flex-col border-t border-border-neutral-base bg-background-contrast-base outline-none focus-visible:shadow-border-interactive-with-active"
|
|
351
440
|
>
|
|
352
441
|
<StepAttemptLogPanel
|
|
353
442
|
stepId={context.stepId}
|
|
@@ -355,12 +444,116 @@ function ExpandedStep({
|
|
|
355
444
|
attemptStatus={context.attemptStatus}
|
|
356
445
|
attemptStartedAt={context.attemptStartedAt}
|
|
357
446
|
pageScrollRef={pageScrollRef}
|
|
358
|
-
|
|
447
|
+
search={search}
|
|
448
|
+
wrap={wrap}
|
|
449
|
+
showLineNumbers={showLineNumbers}
|
|
450
|
+
attemptId={attemptId}
|
|
451
|
+
refreshToken={refreshToken}
|
|
452
|
+
onFetchingChange={onFetchingChange}
|
|
359
453
|
/>
|
|
360
454
|
</section>
|
|
361
455
|
);
|
|
362
456
|
}
|
|
363
457
|
|
|
458
|
+
function JobLogPanelHeader({
|
|
459
|
+
stepLabel,
|
|
460
|
+
attempt,
|
|
461
|
+
status,
|
|
462
|
+
search,
|
|
463
|
+
onSearchChange,
|
|
464
|
+
onRefresh,
|
|
465
|
+
refreshing,
|
|
466
|
+
showLineNumbers,
|
|
467
|
+
onShowLineNumbersChange,
|
|
468
|
+
wrap,
|
|
469
|
+
onWrapChange,
|
|
470
|
+
disabled,
|
|
471
|
+
}: {
|
|
472
|
+
stepLabel: string | undefined;
|
|
473
|
+
attempt: number | undefined;
|
|
474
|
+
status: string | undefined;
|
|
475
|
+
search: string;
|
|
476
|
+
onSearchChange: (value: string) => void;
|
|
477
|
+
onRefresh: () => void;
|
|
478
|
+
refreshing: boolean;
|
|
479
|
+
showLineNumbers: boolean;
|
|
480
|
+
onShowLineNumbersChange: (value: boolean) => void;
|
|
481
|
+
wrap: boolean;
|
|
482
|
+
onWrapChange: (value: boolean) => void;
|
|
483
|
+
disabled: boolean;
|
|
484
|
+
}) {
|
|
485
|
+
const statusVisual = status ? getStepStatusVisual(status) : undefined;
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<PanelHeader className="flex flex-wrap items-center gap-group">
|
|
489
|
+
<div className="min-w-0 flex-1">
|
|
490
|
+
<Text size="sm" bold className="truncate text-foreground-neutral-base">
|
|
491
|
+
{stepLabel ?? 'Logs'}
|
|
492
|
+
</Text>
|
|
493
|
+
{statusVisual ? (
|
|
494
|
+
<div className="flex min-w-0 items-center gap-inline text-foreground-neutral-muted">
|
|
495
|
+
<Badge variant={statusVisual.badge} size="2xs" radius="rounded">
|
|
496
|
+
{statusVisual.label}
|
|
497
|
+
</Badge>
|
|
498
|
+
{attempt ? (
|
|
499
|
+
<span className="font-code text-xs leading-20 tabular-nums">attempt {attempt}</span>
|
|
500
|
+
) : null}
|
|
501
|
+
</div>
|
|
502
|
+
) : (
|
|
503
|
+
<Text size="xs" className="text-foreground-neutral-muted">
|
|
504
|
+
Select a step to view its logs.
|
|
505
|
+
</Text>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
<PanelActions className="min-w-0 flex-wrap justify-end">
|
|
509
|
+
<SearchInline
|
|
510
|
+
value={search}
|
|
511
|
+
onChange={(event) => onSearchChange(event.target.value)}
|
|
512
|
+
aria-label="Search logs"
|
|
513
|
+
placeholder="Search logs"
|
|
514
|
+
size="small"
|
|
515
|
+
className="w-180 max-w-full"
|
|
516
|
+
disabled={disabled}
|
|
517
|
+
/>
|
|
518
|
+
<IconButton
|
|
519
|
+
type="button"
|
|
520
|
+
variant="transparent"
|
|
521
|
+
size="sm"
|
|
522
|
+
icon="refreshLine"
|
|
523
|
+
aria-label="Refresh logs"
|
|
524
|
+
onClick={onRefresh}
|
|
525
|
+
disabled={disabled || refreshing}
|
|
526
|
+
isLoading={refreshing}
|
|
527
|
+
/>
|
|
528
|
+
<DropdownMenu>
|
|
529
|
+
<DropdownMenuTrigger asChild>
|
|
530
|
+
<IconButton
|
|
531
|
+
type="button"
|
|
532
|
+
variant="transparent"
|
|
533
|
+
size="sm"
|
|
534
|
+
icon="settings3Line"
|
|
535
|
+
aria-label="Log settings"
|
|
536
|
+
disabled={disabled}
|
|
537
|
+
/>
|
|
538
|
+
</DropdownMenuTrigger>
|
|
539
|
+
<DropdownMenuContent align="end" size="sm">
|
|
540
|
+
<DropdownMenuLabel>Log display</DropdownMenuLabel>
|
|
541
|
+
<DropdownMenuCheckboxItem
|
|
542
|
+
checked={showLineNumbers}
|
|
543
|
+
onCheckedChange={onShowLineNumbersChange}
|
|
544
|
+
>
|
|
545
|
+
Line numbers
|
|
546
|
+
</DropdownMenuCheckboxItem>
|
|
547
|
+
<DropdownMenuCheckboxItem checked={wrap} onCheckedChange={onWrapChange}>
|
|
548
|
+
Wrap long lines
|
|
549
|
+
</DropdownMenuCheckboxItem>
|
|
550
|
+
</DropdownMenuContent>
|
|
551
|
+
</DropdownMenu>
|
|
552
|
+
</PanelActions>
|
|
553
|
+
</PanelHeader>
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
364
557
|
function EmptyStateForMissingExecution({job}: {job: Job}) {
|
|
365
558
|
const emptyState = emptyStateForMissingExecution(job);
|
|
366
559
|
return (
|
|
@@ -442,7 +635,7 @@ function NewerAttemptNotice({
|
|
|
442
635
|
);
|
|
443
636
|
}
|
|
444
637
|
|
|
445
|
-
function runningStepSelection(jobExecution: JobExecution | undefined) {
|
|
638
|
+
function runningStepSelection(jobExecution: JobExecution | undefined, model: StepListModel) {
|
|
446
639
|
if (!jobExecution) return undefined;
|
|
447
640
|
const steps = [...jobExecution.steps].sort(
|
|
448
641
|
(left, right) => left.position - right.position || left.id.localeCompare(right.id),
|
|
@@ -455,7 +648,11 @@ function runningStepSelection(jobExecution: JobExecution | undefined) {
|
|
|
455
648
|
.reverse()
|
|
456
649
|
.find((candidate) => candidate.status === 'running');
|
|
457
650
|
if (attempt)
|
|
458
|
-
return {
|
|
651
|
+
return {
|
|
652
|
+
stepId: step.id,
|
|
653
|
+
attemptId: attempt.id,
|
|
654
|
+
stepLabel: model.entries.find((entry) => entry.id === attempt.id)?.step.label ?? 'Step',
|
|
655
|
+
};
|
|
459
656
|
}
|
|
460
657
|
return undefined;
|
|
461
658
|
}
|
|
@@ -468,6 +665,25 @@ function findAttempt(jobExecution: JobExecution, attemptId: string) {
|
|
|
468
665
|
return undefined;
|
|
469
666
|
}
|
|
470
667
|
|
|
668
|
+
function findExpandedLogSelection(
|
|
669
|
+
jobExecution: JobExecution | undefined,
|
|
670
|
+
attemptIds: readonly string[],
|
|
671
|
+
model: StepListModel,
|
|
672
|
+
) {
|
|
673
|
+
if (!jobExecution) return undefined;
|
|
674
|
+
|
|
675
|
+
for (let index = attemptIds.length - 1; index >= 0; index -= 1) {
|
|
676
|
+
const attemptId = attemptIds[index];
|
|
677
|
+
if (!attemptId) continue;
|
|
678
|
+
const match = findAttempt(jobExecution, attemptId);
|
|
679
|
+
const attempt = match?.step.attempts.find((candidate) => candidate.id === attemptId);
|
|
680
|
+
const entry = model.entries.find((candidate) => candidate.id === attemptId);
|
|
681
|
+
if (match && attempt && entry) return {step: match.step, attempt, stepLabel: entry.step.label};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
471
687
|
function annotationCountForStep(
|
|
472
688
|
summary: RunAnnotationSummary | undefined,
|
|
473
689
|
stepId: string,
|
|
@@ -2,7 +2,7 @@ import {configureApiClient} from '@shipfox/client-api';
|
|
|
2
2
|
import {type StepLogSnapshot, stepLogsQueryKeys} from '@shipfox/client-logs';
|
|
3
3
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
4
4
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
5
|
-
import {createRef
|
|
5
|
+
import {createRef} from 'react';
|
|
6
6
|
import {inlineLogBody, outputLine} from '#test/fixtures/logs.js';
|
|
7
7
|
import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
|
|
8
8
|
|
|
@@ -49,25 +49,28 @@ function renderPanel(
|
|
|
49
49
|
new QueryClient({
|
|
50
50
|
defaultOptions: {queries: {retry: false}},
|
|
51
51
|
});
|
|
52
|
-
const wrapper = ({children}: {children: ReactNode}) => (
|
|
53
|
-
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
54
|
-
);
|
|
55
52
|
const pageScrollRef = createRef<HTMLDivElement>();
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
queryClient,
|
|
59
|
-
...render(
|
|
53
|
+
const renderPanelUi = (panelProps: Partial<Parameters<typeof StepAttemptLogPanel>[0]>) => (
|
|
54
|
+
<QueryClientProvider client={queryClient}>
|
|
60
55
|
<div ref={pageScrollRef}>
|
|
61
56
|
<StepAttemptLogPanel
|
|
62
57
|
stepId={STEP_ID}
|
|
63
58
|
attempt={1}
|
|
64
59
|
attemptStatus="running"
|
|
65
60
|
pageScrollRef={pageScrollRef}
|
|
66
|
-
{...
|
|
61
|
+
{...panelProps}
|
|
67
62
|
/>
|
|
68
|
-
</div
|
|
69
|
-
|
|
70
|
-
|
|
63
|
+
</div>
|
|
64
|
+
</QueryClientProvider>
|
|
65
|
+
);
|
|
66
|
+
const rendered = render(renderPanelUi(props));
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
queryClient,
|
|
70
|
+
...rendered,
|
|
71
|
+
rerenderPanel: (nextProps: Partial<Parameters<typeof StepAttemptLogPanel>[0]> = {}) => {
|
|
72
|
+
rendered.rerender(renderPanelUi({...props, ...nextProps}));
|
|
73
|
+
},
|
|
71
74
|
};
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -240,6 +243,21 @@ describe('StepAttemptLogPanel', () => {
|
|
|
240
243
|
expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
|
|
241
244
|
});
|
|
242
245
|
|
|
246
|
+
test('refetches the expanded attempt when its refresh token changes', async () => {
|
|
247
|
+
const fetchImpl = vi
|
|
248
|
+
.fn()
|
|
249
|
+
.mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('first\n'), 1)))
|
|
250
|
+
.mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('second\n'), 2)));
|
|
251
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
252
|
+
const {rerenderPanel} = renderPanel({attemptStatus: 'succeeded', refreshToken: 0});
|
|
253
|
+
|
|
254
|
+
expect(await screen.findByText('first')).toBeInTheDocument();
|
|
255
|
+
rerenderPanel({refreshToken: 1});
|
|
256
|
+
|
|
257
|
+
expect(await screen.findByText('second')).toBeInTheDocument();
|
|
258
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
243
261
|
test('does not follow new log output after the user scrolls away from the tail', async () => {
|
|
244
262
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
245
263
|
callback(0);
|
|
@@ -13,7 +13,7 @@ import {JobExecutionTimeText} from './job-execution-time-text.js';
|
|
|
13
13
|
const TAIL_FOLLOW_THRESHOLD_PX = 24;
|
|
14
14
|
const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
|
|
15
15
|
const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
|
|
16
|
-
const defaultLogSurfaceClasses = 'rounded-
|
|
16
|
+
const defaultLogSurfaceClasses = 'rounded-none border-0 bg-background-contrast-base shadow-none';
|
|
17
17
|
|
|
18
18
|
export interface StepAttemptLogPanelProps {
|
|
19
19
|
stepId: string;
|
|
@@ -24,6 +24,12 @@ export interface StepAttemptLogPanelProps {
|
|
|
24
24
|
/** The page scroll column used by the uncapped job-detail log surface. */
|
|
25
25
|
pageScrollRef: RefObject<HTMLElement | null>;
|
|
26
26
|
surfaceClassName?: string | undefined;
|
|
27
|
+
search?: string | undefined;
|
|
28
|
+
wrap?: boolean | undefined;
|
|
29
|
+
showLineNumbers?: boolean | undefined;
|
|
30
|
+
attemptId?: string | undefined;
|
|
31
|
+
refreshToken?: number | undefined;
|
|
32
|
+
onFetchingChange?: ((attemptId: string, isFetching: boolean) => void) | undefined;
|
|
27
33
|
initialErrorRetryCount?: number | undefined;
|
|
28
34
|
initialErrorRetryDelayMs?: number | undefined;
|
|
29
35
|
}
|
|
@@ -35,6 +41,12 @@ export function StepAttemptLogPanel({
|
|
|
35
41
|
attemptStartedAt,
|
|
36
42
|
pageScrollRef,
|
|
37
43
|
surfaceClassName = defaultLogSurfaceClasses,
|
|
44
|
+
search = '',
|
|
45
|
+
wrap = false,
|
|
46
|
+
showLineNumbers = true,
|
|
47
|
+
attemptId,
|
|
48
|
+
refreshToken = 0,
|
|
49
|
+
onFetchingChange,
|
|
38
50
|
initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
|
|
39
51
|
initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
|
|
40
52
|
}: StepAttemptLogPanelProps) {
|
|
@@ -55,6 +67,18 @@ export function StepAttemptLogPanel({
|
|
|
55
67
|
retryMissingStream && query.data === undefined && isMissingStepLogStreamError(query.error);
|
|
56
68
|
const initialError = query.isError && query.data === undefined && !missingActiveStream;
|
|
57
69
|
const staleError = query.isError && query.data !== undefined;
|
|
70
|
+
const lastRefreshTokenRef = useRef(refreshToken);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (refreshToken === lastRefreshTokenRef.current) return;
|
|
74
|
+
lastRefreshTokenRef.current = refreshToken;
|
|
75
|
+
void query.refetchLogs();
|
|
76
|
+
}, [query.refetchLogs, refreshToken]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!attemptId) return;
|
|
80
|
+
onFetchingChange?.(attemptId, query.isFetching);
|
|
81
|
+
}, [attemptId, onFetchingChange, query.isFetching]);
|
|
58
82
|
|
|
59
83
|
useEffect(() => {
|
|
60
84
|
const scrollElement = pageScrollRef.current;
|
|
@@ -85,18 +109,32 @@ export function StepAttemptLogPanel({
|
|
|
85
109
|
}, [anchorToFailure, pageScrollRef, recordCount]);
|
|
86
110
|
|
|
87
111
|
if (query.isPending) {
|
|
88
|
-
return
|
|
112
|
+
return (
|
|
113
|
+
<StepLogsLoadingSurface
|
|
114
|
+
label="Loading logs"
|
|
115
|
+
className={surfaceClassName}
|
|
116
|
+
wrap={wrap}
|
|
117
|
+
showLineNumbers={showLineNumbers}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
if (missingActiveStream) {
|
|
92
123
|
if (attemptStatus === 'running' && attemptStartedAt) {
|
|
93
124
|
return <StepLogsWaitingSurface startedAt={attemptStartedAt} className={surfaceClassName} />;
|
|
94
125
|
}
|
|
95
|
-
return
|
|
126
|
+
return (
|
|
127
|
+
<StepLogsLoadingSurface
|
|
128
|
+
label="Waiting for logs"
|
|
129
|
+
className={surfaceClassName}
|
|
130
|
+
wrap={wrap}
|
|
131
|
+
showLineNumbers={showLineNumbers}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
96
134
|
}
|
|
97
135
|
|
|
98
136
|
if (initialError) {
|
|
99
|
-
return <StepLogsError retrying={query.isFetching} onRetry={() => void query.
|
|
137
|
+
return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetchLogs()} />;
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
return (
|
|
@@ -115,7 +153,7 @@ export function StepAttemptLogPanel({
|
|
|
115
153
|
size="2xs"
|
|
116
154
|
variant="secondary"
|
|
117
155
|
isLoading={query.isFetching}
|
|
118
|
-
onClick={() => void query.
|
|
156
|
+
onClick={() => void query.refetchLogs()}
|
|
119
157
|
>
|
|
120
158
|
Retry
|
|
121
159
|
</Button>
|
|
@@ -124,19 +162,32 @@ export function StepAttemptLogPanel({
|
|
|
124
162
|
) : null}
|
|
125
163
|
<LogView
|
|
126
164
|
records={records}
|
|
165
|
+
search={search}
|
|
166
|
+
wrap={wrap}
|
|
167
|
+
showLineNumbers={showLineNumbers}
|
|
127
168
|
emptyState={query.data?.complete ? 'complete' : 'pending'}
|
|
128
169
|
anchorToFailure={anchorToFailure}
|
|
129
|
-
ariaLive={attemptStatus === 'running' ? 'polite' : 'off'}
|
|
170
|
+
ariaLive={search.trim() ? 'off' : attemptStatus === 'running' ? 'polite' : 'off'}
|
|
130
171
|
className={surfaceClassName}
|
|
131
172
|
/>
|
|
132
173
|
</div>
|
|
133
174
|
);
|
|
134
175
|
}
|
|
135
176
|
|
|
136
|
-
function StepLogsLoadingSurface({
|
|
177
|
+
function StepLogsLoadingSurface({
|
|
178
|
+
label,
|
|
179
|
+
className,
|
|
180
|
+
wrap,
|
|
181
|
+
showLineNumbers,
|
|
182
|
+
}: {
|
|
183
|
+
label: string;
|
|
184
|
+
className: string;
|
|
185
|
+
wrap: boolean;
|
|
186
|
+
showLineNumbers: boolean;
|
|
187
|
+
}) {
|
|
137
188
|
return (
|
|
138
189
|
<div role="status" aria-label={label}>
|
|
139
|
-
<LogViewSkeleton className={className} />
|
|
190
|
+
<LogViewSkeleton className={className} wrap={wrap} showLineNumbers={showLineNumbers} />
|
|
140
191
|
</div>
|
|
141
192
|
);
|
|
142
193
|
}
|
|
@@ -144,7 +195,7 @@ function StepLogsLoadingSurface({label, className}: {label: string; className: s
|
|
|
144
195
|
function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
|
|
145
196
|
return (
|
|
146
197
|
<div role="status" aria-label="Waiting for logs" className={className}>
|
|
147
|
-
<Text size="xs" className="px-tight py-row text-foreground-
|
|
198
|
+
<Text size="xs" className="px-tight py-row text-foreground-contrast-secondary">
|
|
148
199
|
Waiting for output ·{' '}
|
|
149
200
|
<span className="font-code tabular-nums" aria-hidden="true">
|
|
150
201
|
<JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
|
|
@@ -188,7 +239,14 @@ function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => v
|
|
|
188
239
|
>
|
|
189
240
|
<div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
|
|
190
241
|
<Text size="xs">Could not load logs.</Text>
|
|
191
|
-
<Button
|
|
242
|
+
<Button
|
|
243
|
+
type="button"
|
|
244
|
+
size="2xs"
|
|
245
|
+
variant="secondary"
|
|
246
|
+
isLoading={retrying}
|
|
247
|
+
disabled={retrying}
|
|
248
|
+
onClick={onRetry}
|
|
249
|
+
>
|
|
192
250
|
Retry
|
|
193
251
|
</Button>
|
|
194
252
|
</div>
|