@shipfox/client-workflows 15.0.0 → 16.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.css +4 -0
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +21 -0
- package/dist/components/job-detail/job-detail-header.d.ts +8 -1
- package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
- package/dist/components/job-detail/job-detail-header.js +25 -12
- package/dist/components/job-detail/job-detail-header.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 +17 -2
- package/dist/components/job-detail/job-detail-view.js.map +1 -1
- package/dist/components/workflow-run-summary/workflow-run-summary.js +1 -0
- package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
- package/dist/components/workflow-run-tabs/index.d.ts +3 -0
- package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/index.js +3 -0
- package/dist/components/workflow-run-tabs/index.js.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
- package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
- package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
- package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
- package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
- package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
- package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
- package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
- package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
- package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
- package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
- package/dist/components/workflow-run-view/run-workspace-nav.d.ts +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 +9 -3
- package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.d.ts +1 -3
- package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
- package/dist/components/workflow-run-view/workflow-run-view.js +150 -52
- package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
- package/dist/core/run-annotation.d.ts +73 -0
- package/dist/core/run-annotation.d.ts.map +1 -0
- package/dist/core/run-annotation.js +125 -0
- package/dist/core/run-annotation.js.map +1 -0
- package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
- package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
- package/dist/hooks/api/run-annotation-mapper.js +22 -0
- package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
- package/dist/hooks/api/run-annotations.d.ts +25 -0
- package/dist/hooks/api/run-annotations.d.ts.map +1 -0
- package/dist/hooks/api/run-annotations.js +78 -0
- package/dist/hooks/api/run-annotations.js.map +1 -0
- package/dist/routes/inputs.d.ts +7 -2
- package/dist/routes/inputs.d.ts.map +1 -1
- package/dist/routes/inputs.js +5 -6
- package/dist/routes/inputs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -9
- package/src/components/job-detail/job-detail-header.test.tsx +3 -0
- package/src/components/job-detail/job-detail-header.tsx +30 -5
- package/src/components/job-detail/job-detail-view.stories.tsx +5 -0
- package/src/components/job-detail/job-detail-view.tsx +18 -1
- package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -1
- package/src/components/workflow-run-tabs/index.ts +3 -0
- package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
- package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
- package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
- package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
- package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
- package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
- package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
- package/src/components/workflow-run-view/run-workspace-nav.test.tsx +44 -0
- package/src/components/workflow-run-view/run-workspace-nav.tsx +15 -2
- package/src/components/workflow-run-view/workflow-run-view.test.tsx +246 -20
- package/src/components/workflow-run-view/workflow-run-view.tsx +148 -30
- package/src/core/run-annotation.test.ts +315 -0
- package/src/core/run-annotation.ts +238 -0
- package/src/hooks/api/run-annotation-mapper.ts +30 -0
- package/src/hooks/api/run-annotations.ts +137 -0
- package/src/pages/workflow-job-detail-page.test.tsx +66 -0
- package/src/routes/inputs.ts +7 -2
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/core/workflow-run-tabs.d.ts +0 -9
- package/dist/core/workflow-run-tabs.d.ts.map +0 -1
- package/dist/core/workflow-run-tabs.js +0 -3
- package/dist/core/workflow-run-tabs.js.map +0 -1
- package/src/core/workflow-run-tabs.ts +0 -8
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {Job} from './workflow-run.js';
|
|
2
|
+
|
|
3
|
+
export type RunAnnotationStyle = 'default' | 'info' | 'success' | 'warning' | 'error';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The severities the run surfaces rank and filter by. The `default` style carries no severity:
|
|
7
|
+
* it counts toward the total and sorts last, but no severity filter selects it.
|
|
8
|
+
*/
|
|
9
|
+
export const RUN_ANNOTATION_SEVERITIES = ['error', 'warning', 'info', 'success'] as const;
|
|
10
|
+
|
|
11
|
+
export type RunAnnotationSeverity = (typeof RUN_ANNOTATION_SEVERITIES)[number];
|
|
12
|
+
|
|
13
|
+
const SEVERITY_RANK: Record<RunAnnotationStyle, number> = {
|
|
14
|
+
error: 0,
|
|
15
|
+
warning: 1,
|
|
16
|
+
info: 2,
|
|
17
|
+
success: 3,
|
|
18
|
+
default: 4,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const SEVERITY_SET = new Set<string>(RUN_ANNOTATION_SEVERITIES);
|
|
22
|
+
|
|
23
|
+
export interface RunAnnotation {
|
|
24
|
+
id: string;
|
|
25
|
+
jobId: string;
|
|
26
|
+
jobExecutionId: string;
|
|
27
|
+
originStepId: string;
|
|
28
|
+
originStepAttempt: number;
|
|
29
|
+
context: string;
|
|
30
|
+
style: RunAnnotationStyle;
|
|
31
|
+
sequence: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunAnnotationRecord extends RunAnnotation {
|
|
35
|
+
body: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Counts shown by the run rail and the severity summary line. */
|
|
39
|
+
export interface RunAnnotationSummary {
|
|
40
|
+
total: number;
|
|
41
|
+
error: number;
|
|
42
|
+
warning: number;
|
|
43
|
+
info: number;
|
|
44
|
+
success: number;
|
|
45
|
+
/** True when the read hit its page budget, so every count is a lower bound. */
|
|
46
|
+
truncated: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Everything the annotations list needs to route back to the step that emitted an annotation. */
|
|
50
|
+
export interface RunAnnotationOrigin {
|
|
51
|
+
jobId: string;
|
|
52
|
+
jobExecutionId: string;
|
|
53
|
+
stepId: string;
|
|
54
|
+
stepAttemptId: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RunAnnotationEntry {
|
|
58
|
+
annotation: RunAnnotationRecord;
|
|
59
|
+
jobName: string | null;
|
|
60
|
+
/** Only set when the job ran more than once, so a single-execution job stays uncluttered. */
|
|
61
|
+
executionLabel: string | null;
|
|
62
|
+
stepLabel: string | null;
|
|
63
|
+
attemptLabel: string;
|
|
64
|
+
/** `null` when the run attempt no longer contains the emitting step, so no link is offered. */
|
|
65
|
+
origin: RunAnnotationOrigin | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function annotationSeverity(style: RunAnnotationStyle): RunAnnotationSeverity | null {
|
|
69
|
+
return SEVERITY_SET.has(style) ? (style as RunAnnotationSeverity) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function summarizeRunAnnotations(
|
|
73
|
+
annotations: readonly RunAnnotation[],
|
|
74
|
+
{truncated = false}: {truncated?: boolean | undefined} = {},
|
|
75
|
+
): RunAnnotationSummary {
|
|
76
|
+
const summary: RunAnnotationSummary = {
|
|
77
|
+
total: annotations.length,
|
|
78
|
+
error: 0,
|
|
79
|
+
warning: 0,
|
|
80
|
+
info: 0,
|
|
81
|
+
success: 0,
|
|
82
|
+
truncated,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
for (const annotation of annotations) {
|
|
86
|
+
const severity = annotationSeverity(annotation.style);
|
|
87
|
+
if (severity) summary[severity] += 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return summary;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface BuildRunAnnotationListInput {
|
|
94
|
+
annotations: readonly RunAnnotationRecord[];
|
|
95
|
+
jobs: readonly Job[];
|
|
96
|
+
severity?: RunAnnotationSeverity | undefined;
|
|
97
|
+
jobId?: string | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Ranks annotations for display.
|
|
102
|
+
*
|
|
103
|
+
* Severity leads: emission order is a log affordance, not a summary affordance. `sequence` is
|
|
104
|
+
* only stable within one job execution, so the tie-break walks down job position and execution
|
|
105
|
+
* sequence before using it, and ends on the id so the order never depends on input order.
|
|
106
|
+
*/
|
|
107
|
+
export function buildRunAnnotationList({
|
|
108
|
+
annotations,
|
|
109
|
+
jobs,
|
|
110
|
+
severity,
|
|
111
|
+
jobId,
|
|
112
|
+
}: BuildRunAnnotationListInput): RunAnnotationEntry[] {
|
|
113
|
+
const index = indexJobs(jobs);
|
|
114
|
+
const entries: RunAnnotationEntry[] = [];
|
|
115
|
+
|
|
116
|
+
for (const annotation of annotations) {
|
|
117
|
+
if (jobId && annotation.jobId !== jobId) continue;
|
|
118
|
+
const annotationSeverityValue = annotationSeverity(annotation.style);
|
|
119
|
+
if (severity && annotationSeverityValue !== severity) continue;
|
|
120
|
+
entries.push(toEntry(annotation, index));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return entries.sort((left, right) => compareEntries(left, right, index));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Counts annotations owned by one job, for the job page's bounded reference chip. */
|
|
127
|
+
export function summarizeJobAnnotations(
|
|
128
|
+
annotations: readonly RunAnnotation[],
|
|
129
|
+
jobId: string,
|
|
130
|
+
options: {truncated?: boolean | undefined} = {},
|
|
131
|
+
): RunAnnotationSummary {
|
|
132
|
+
return summarizeRunAnnotations(
|
|
133
|
+
annotations.filter((annotation) => annotation.jobId === jobId),
|
|
134
|
+
options,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The loudest severity present, which tints a count chip. */
|
|
139
|
+
export function highestRunAnnotationSeverity(
|
|
140
|
+
summary: RunAnnotationSummary,
|
|
141
|
+
): RunAnnotationSeverity | null {
|
|
142
|
+
return RUN_ANNOTATION_SEVERITIES.find((severity) => summary[severity] > 0) ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface JobIndexEntry {
|
|
146
|
+
position: number;
|
|
147
|
+
name: string;
|
|
148
|
+
executionCount: number;
|
|
149
|
+
executions: Map<string, JobExecutionIndexEntry>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface JobExecutionIndexEntry {
|
|
153
|
+
sequence: number;
|
|
154
|
+
steps: Map<string, {label: string; attemptIds: Map<number, string>}>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function indexJobs(jobs: readonly Job[]): Map<string, JobIndexEntry> {
|
|
158
|
+
const index = new Map<string, JobIndexEntry>();
|
|
159
|
+
|
|
160
|
+
for (const job of jobs) {
|
|
161
|
+
const executions = new Map<string, JobExecutionIndexEntry>();
|
|
162
|
+
for (const execution of job.jobExecutions) {
|
|
163
|
+
const steps = new Map<string, {label: string; attemptIds: Map<number, string>}>();
|
|
164
|
+
for (const step of execution.steps) {
|
|
165
|
+
const attemptIds = new Map<number, string>();
|
|
166
|
+
for (const attempt of step.attempts) attemptIds.set(attempt.attempt, attempt.id);
|
|
167
|
+
steps.set(step.id, {label: step.name || step.key || step.id, attemptIds});
|
|
168
|
+
}
|
|
169
|
+
executions.set(execution.id, {sequence: execution.sequence, steps});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
index.set(job.id, {
|
|
173
|
+
position: job.position,
|
|
174
|
+
name: job.displayName,
|
|
175
|
+
executionCount: job.jobExecutions.length,
|
|
176
|
+
executions,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return index;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function toEntry(
|
|
184
|
+
annotation: RunAnnotationRecord,
|
|
185
|
+
index: Map<string, JobIndexEntry>,
|
|
186
|
+
): RunAnnotationEntry {
|
|
187
|
+
const job = index.get(annotation.jobId);
|
|
188
|
+
const execution = job?.executions.get(annotation.jobExecutionId);
|
|
189
|
+
const step = execution?.steps.get(annotation.originStepId);
|
|
190
|
+
const stepAttemptId = step?.attemptIds.get(annotation.originStepAttempt);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
annotation,
|
|
194
|
+
jobName: job?.name ?? null,
|
|
195
|
+
executionLabel:
|
|
196
|
+
execution && job && job.executionCount > 1 ? `execution #${execution.sequence}` : null,
|
|
197
|
+
stepLabel: step?.label ?? null,
|
|
198
|
+
attemptLabel: `attempt ${annotation.originStepAttempt}`,
|
|
199
|
+
origin: stepAttemptId
|
|
200
|
+
? {
|
|
201
|
+
jobId: annotation.jobId,
|
|
202
|
+
jobExecutionId: annotation.jobExecutionId,
|
|
203
|
+
stepId: annotation.originStepId,
|
|
204
|
+
stepAttemptId,
|
|
205
|
+
}
|
|
206
|
+
: null,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function compareEntries(
|
|
211
|
+
left: RunAnnotationEntry,
|
|
212
|
+
right: RunAnnotationEntry,
|
|
213
|
+
index: Map<string, JobIndexEntry>,
|
|
214
|
+
): number {
|
|
215
|
+
const bySeverity = SEVERITY_RANK[left.annotation.style] - SEVERITY_RANK[right.annotation.style];
|
|
216
|
+
if (bySeverity !== 0) return bySeverity;
|
|
217
|
+
|
|
218
|
+
const byJob = jobPosition(left, index) - jobPosition(right, index);
|
|
219
|
+
if (byJob !== 0) return byJob;
|
|
220
|
+
|
|
221
|
+
const byExecution = executionSequence(left, index) - executionSequence(right, index);
|
|
222
|
+
if (byExecution !== 0) return byExecution;
|
|
223
|
+
|
|
224
|
+
const bySequence = left.annotation.sequence - right.annotation.sequence;
|
|
225
|
+
if (bySequence !== 0) return bySequence;
|
|
226
|
+
|
|
227
|
+
return left.annotation.id.localeCompare(right.annotation.id);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** An unmatched job sorts last rather than first, so known provenance leads the list. */
|
|
231
|
+
function jobPosition(entry: RunAnnotationEntry, index: Map<string, JobIndexEntry>): number {
|
|
232
|
+
return index.get(entry.annotation.jobId)?.position ?? Number.MAX_SAFE_INTEGER;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function executionSequence(entry: RunAnnotationEntry, index: Map<string, JobIndexEntry>): number {
|
|
236
|
+
const job = index.get(entry.annotation.jobId);
|
|
237
|
+
return job?.executions.get(entry.annotation.jobExecutionId)?.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
238
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {AnnotationDto, ReadAnnotationsResponseDto} from '@shipfox/annotations-dto';
|
|
2
|
+
import type {RunAnnotationRecord} from '#core/run-annotation.js';
|
|
3
|
+
|
|
4
|
+
export interface RunAnnotationPage {
|
|
5
|
+
annotations: RunAnnotationRecord[];
|
|
6
|
+
hasMore: boolean;
|
|
7
|
+
nextCursor: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function toRunAnnotation(dto: AnnotationDto): RunAnnotationRecord {
|
|
11
|
+
return {
|
|
12
|
+
id: dto.id,
|
|
13
|
+
jobId: dto.job_id,
|
|
14
|
+
jobExecutionId: dto.job_execution_id,
|
|
15
|
+
originStepId: dto.origin_step_id,
|
|
16
|
+
originStepAttempt: dto.origin_step_attempt,
|
|
17
|
+
context: dto.context,
|
|
18
|
+
style: dto.style,
|
|
19
|
+
sequence: dto.sequence,
|
|
20
|
+
body: dto.body,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function toRunAnnotationPage(response: ReadAnnotationsResponseDto): RunAnnotationPage {
|
|
25
|
+
return {
|
|
26
|
+
annotations: response.annotations.map(toRunAnnotation),
|
|
27
|
+
hasMore: response.has_more,
|
|
28
|
+
nextCursor: response.next_cursor,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {READ_ANNOTATIONS_MAX_LIMIT, readAnnotationsResponseSchema} from '@shipfox/annotations-dto';
|
|
2
|
+
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
|
+
import {
|
|
4
|
+
type InfiniteData,
|
|
5
|
+
infiniteQueryOptions,
|
|
6
|
+
type UseInfiniteQueryOptions,
|
|
7
|
+
useInfiniteQuery,
|
|
8
|
+
} from '@tanstack/react-query';
|
|
9
|
+
import {useMemo} from 'react';
|
|
10
|
+
import {
|
|
11
|
+
type RunAnnotationRecord,
|
|
12
|
+
type RunAnnotationSummary,
|
|
13
|
+
summarizeRunAnnotations,
|
|
14
|
+
} from '#core/run-annotation.js';
|
|
15
|
+
import {type RunAnnotationPage, toRunAnnotationPage} from './run-annotation-mapper.js';
|
|
16
|
+
|
|
17
|
+
/** Matches the run detail poll, so annotations and run state never disagree by more than a tick. */
|
|
18
|
+
const ACTIVE_POLL_MS = 4_000;
|
|
19
|
+
|
|
20
|
+
export const runAnnotationsQueryKeys = {
|
|
21
|
+
all: ['run-annotations'] as const,
|
|
22
|
+
list: (workflowRunId: string, runAttempt?: number | undefined) =>
|
|
23
|
+
[...runAnnotationsQueryKeys.all, 'list', workflowRunId, runAttempt ?? null] as const,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RunAnnotationsQueryKey =
|
|
27
|
+
| ReturnType<typeof runAnnotationsQueryKeys.list>
|
|
28
|
+
| readonly ['run-annotations', 'list'];
|
|
29
|
+
|
|
30
|
+
type RunAnnotationsQueryOptions = UseInfiniteQueryOptions<
|
|
31
|
+
RunAnnotationPage,
|
|
32
|
+
Error,
|
|
33
|
+
InfiniteData<RunAnnotationPage, string | undefined>,
|
|
34
|
+
RunAnnotationsQueryKey,
|
|
35
|
+
string | undefined
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
async function listRunAnnotations({
|
|
39
|
+
workflowRunId,
|
|
40
|
+
runAttempt,
|
|
41
|
+
cursor,
|
|
42
|
+
signal,
|
|
43
|
+
}: {
|
|
44
|
+
workflowRunId: string;
|
|
45
|
+
runAttempt: number;
|
|
46
|
+
cursor?: string | undefined;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}): Promise<RunAnnotationPage> {
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
workflow_run_id: workflowRunId,
|
|
51
|
+
attempt: String(runAttempt),
|
|
52
|
+
limit: String(READ_ANNOTATIONS_MAX_LIMIT),
|
|
53
|
+
});
|
|
54
|
+
if (cursor) params.set('cursor', cursor);
|
|
55
|
+
|
|
56
|
+
return toRunAnnotationPage(
|
|
57
|
+
await checkedApiRequest(readAnnotationsResponseSchema, `/annotations?${params.toString()}`, {
|
|
58
|
+
signal,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunAnnotationsQueryInput {
|
|
64
|
+
workflowRunId: string | undefined;
|
|
65
|
+
runAttempt: number | undefined;
|
|
66
|
+
/** Poll while the run attempt is non-terminal, then settle. */
|
|
67
|
+
live?: boolean | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function runAnnotationsQueryOptions({
|
|
71
|
+
workflowRunId,
|
|
72
|
+
runAttempt,
|
|
73
|
+
live = false,
|
|
74
|
+
}: RunAnnotationsQueryInput): RunAnnotationsQueryOptions {
|
|
75
|
+
const enabled = Boolean(workflowRunId) && runAttempt !== undefined;
|
|
76
|
+
|
|
77
|
+
// Polling stops once the reader has paged past the first page: the cursor bounding page 2 was
|
|
78
|
+
// computed from page 1's last row, so a refetch that shifts that boundary can drop a range of
|
|
79
|
+
// annotations into a between-pages gap.
|
|
80
|
+
return infiniteQueryOptions({
|
|
81
|
+
queryKey: workflowRunId
|
|
82
|
+
? runAnnotationsQueryKeys.list(workflowRunId, runAttempt)
|
|
83
|
+
: ([...runAnnotationsQueryKeys.all, 'list'] as const),
|
|
84
|
+
enabled,
|
|
85
|
+
initialPageParam: undefined as string | undefined,
|
|
86
|
+
queryFn: ({pageParam, signal}) =>
|
|
87
|
+
listRunAnnotations({
|
|
88
|
+
workflowRunId: workflowRunId ?? '',
|
|
89
|
+
runAttempt: runAttempt ?? 1,
|
|
90
|
+
cursor: pageParam,
|
|
91
|
+
signal,
|
|
92
|
+
}),
|
|
93
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
94
|
+
staleTime: 2_000,
|
|
95
|
+
refetchOnWindowFocus: true,
|
|
96
|
+
refetchInterval: (query) => {
|
|
97
|
+
if (!live) return false;
|
|
98
|
+
const pages = query.state.data?.pages;
|
|
99
|
+
return pages && pages.length > 1 ? false : ACTIVE_POLL_MS;
|
|
100
|
+
},
|
|
101
|
+
refetchIntervalInBackground: false,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RunAnnotationsQueryResult {
|
|
106
|
+
query: ReturnType<
|
|
107
|
+
typeof useInfiniteQuery<
|
|
108
|
+
RunAnnotationPage,
|
|
109
|
+
Error,
|
|
110
|
+
InfiniteData<RunAnnotationPage, string | undefined>,
|
|
111
|
+
RunAnnotationsQueryKey,
|
|
112
|
+
string | undefined
|
|
113
|
+
>
|
|
114
|
+
>;
|
|
115
|
+
/** `undefined` until the first page resolves, so counts never render a speculative zero. */
|
|
116
|
+
annotations: RunAnnotationRecord[] | undefined;
|
|
117
|
+
summary: RunAnnotationSummary | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useRunAnnotationsQuery(input: RunAnnotationsQueryInput): RunAnnotationsQueryResult {
|
|
121
|
+
const query = useInfiniteQuery(runAnnotationsQueryOptions(input));
|
|
122
|
+
const pages = query.data?.pages;
|
|
123
|
+
|
|
124
|
+
const annotations = useMemo(
|
|
125
|
+
() => (pages ? pages.flatMap((page) => page.annotations) : undefined),
|
|
126
|
+
[pages],
|
|
127
|
+
);
|
|
128
|
+
const summary = useMemo(
|
|
129
|
+
() =>
|
|
130
|
+
annotations
|
|
131
|
+
? summarizeRunAnnotations(annotations, {truncated: pages?.at(-1)?.hasMore ?? false})
|
|
132
|
+
: undefined,
|
|
133
|
+
[annotations, pages],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return {query, annotations, summary};
|
|
137
|
+
}
|
|
@@ -33,8 +33,59 @@ const BACK_TO_SUMMARY_PATTERN = /Back to run summary/;
|
|
|
33
33
|
const RUN_MOVED_ON_PATTERN = /Run moved on to/;
|
|
34
34
|
const LINT_LINK_PATTERN = /lint/;
|
|
35
35
|
const RELEASE_LINK_PATTERN = /release/;
|
|
36
|
+
const ANNOTATION_LINK_PATTERN = /annotation/;
|
|
36
37
|
|
|
37
38
|
describe('WorkflowJobDetailPage', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jobAnnotations.value = [];
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('links the job to its annotations without rendering one', async () => {
|
|
44
|
+
jobAnnotations.value = [
|
|
45
|
+
{
|
|
46
|
+
id: 'aaaaaaaa-1111-4aaa-8aaa-000000000001',
|
|
47
|
+
job_id: JOB_ID,
|
|
48
|
+
job_execution_id: EXECUTION_ID,
|
|
49
|
+
origin_step_id: STEP_ID,
|
|
50
|
+
origin_step_attempt: 2,
|
|
51
|
+
context: 'smoke check',
|
|
52
|
+
style: 'error',
|
|
53
|
+
sequence: 1,
|
|
54
|
+
body: 'Task nine failed.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'aaaaaaaa-1111-4aaa-8aaa-000000000002',
|
|
58
|
+
job_id: SIBLING_JOB_ID,
|
|
59
|
+
job_execution_id: EXECUTION_ID,
|
|
60
|
+
origin_step_id: STEP_ID,
|
|
61
|
+
origin_step_attempt: 1,
|
|
62
|
+
context: 'other job',
|
|
63
|
+
style: 'info',
|
|
64
|
+
sequence: 2,
|
|
65
|
+
body: 'Not this job.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
|
|
69
|
+
|
|
70
|
+
renderJobPath(`?jobExecution=${EXECUTION_ID}&runAttempt=1`);
|
|
71
|
+
|
|
72
|
+
const chip = await screen.findByRole('link', {
|
|
73
|
+
name: 'View 1 annotation, highest severity error',
|
|
74
|
+
});
|
|
75
|
+
expect(chip.getAttribute('href')).toContain(`tab=annotations`);
|
|
76
|
+
expect(chip.getAttribute('href')).toContain(`job=${JOB_ID}`);
|
|
77
|
+
expect(screen.queryByText('Task nine failed.')).not.toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('omits the annotation chip when the job produced none', async () => {
|
|
81
|
+
configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
|
|
82
|
+
|
|
83
|
+
renderJobPath(`?jobExecution=${EXECUTION_ID}&runAttempt=1`);
|
|
84
|
+
|
|
85
|
+
await screen.findByRole('heading', {name: 'release'});
|
|
86
|
+
expect(screen.queryByRole('link', {name: ANNOTATION_LINK_PATTERN})).not.toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
38
89
|
test('resolves an exact job execution and step attempt from a deep link', async () => {
|
|
39
90
|
configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
|
|
40
91
|
|
|
@@ -247,10 +298,21 @@ function renderJobPath(path = '', jobId = JOB_ID) {
|
|
|
247
298
|
);
|
|
248
299
|
}
|
|
249
300
|
|
|
301
|
+
/** Annotations for the job page, which renders their count and never their bodies. */
|
|
302
|
+
const jobAnnotations: {value: unknown[]} = {value: []};
|
|
303
|
+
|
|
304
|
+
function annotationsResponse() {
|
|
305
|
+
return {annotations: jobAnnotations.value, has_more: false, next_cursor: null};
|
|
306
|
+
}
|
|
307
|
+
|
|
250
308
|
function jobDetailFetch(input: RequestInfo | URL) {
|
|
251
309
|
const request = input as Request;
|
|
252
310
|
const url = new URL(request.url);
|
|
253
311
|
|
|
312
|
+
if (url.pathname.endsWith('/annotations')) {
|
|
313
|
+
return Promise.resolve(jsonResponse(annotationsResponse()));
|
|
314
|
+
}
|
|
315
|
+
|
|
254
316
|
if (url.pathname.endsWith('/attempts')) {
|
|
255
317
|
return Promise.resolve(
|
|
256
318
|
jsonResponse(
|
|
@@ -287,6 +349,10 @@ function newerAttemptJobDetailFetch(input: RequestInfo | URL) {
|
|
|
287
349
|
const request = input as Request;
|
|
288
350
|
const url = new URL(request.url);
|
|
289
351
|
|
|
352
|
+
if (url.pathname.endsWith('/annotations')) {
|
|
353
|
+
return Promise.resolve(jsonResponse(annotationsResponse()));
|
|
354
|
+
}
|
|
355
|
+
|
|
290
356
|
if (url.pathname.endsWith('/attempts')) {
|
|
291
357
|
return Promise.resolve(
|
|
292
358
|
jsonResponse(
|
package/src/routes/inputs.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {RUN_ANNOTATION_SEVERITIES, type RunAnnotationSeverity} from '#core/run-annotation.js';
|
|
1
2
|
import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,9 +13,13 @@ export const WORKFLOW_RUN_TABS = ['summary', 'jobs', 'annotations', 'source'] as
|
|
|
12
13
|
|
|
13
14
|
export type WorkflowRunTab = (typeof WORKFLOW_RUN_TABS)[number];
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The `severity` parameter's vocabulary is the display severity set, not a second list: a URL
|
|
18
|
+
* that could name a severity the list cannot rank would be a filter with no matching rows.
|
|
19
|
+
*/
|
|
20
|
+
export const WORKFLOW_RUN_ANNOTATION_SEVERITIES = RUN_ANNOTATION_SEVERITIES;
|
|
16
21
|
|
|
17
|
-
export type WorkflowRunAnnotationSeverity =
|
|
22
|
+
export type WorkflowRunAnnotationSeverity = RunAnnotationSeverity;
|
|
18
23
|
|
|
19
24
|
export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
|
|
20
25
|
search?: string;
|