create-interview-cockpit 0.17.3 → 0.19.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.
@@ -0,0 +1,432 @@
1
+ import { useMemo, useState } from "react";
2
+ import { ChevronDown, ChevronRight } from "lucide-react";
3
+ import { parse as parseYaml } from "yaml";
4
+ import type { GhaJobSnapshot, GhaJobStatus, GhaStepSnapshot } from "../api";
5
+
6
+ // ─── Types ───────────────────────────────────────────────────────────────
7
+
8
+ interface DagJob {
9
+ id: string;
10
+ label: string;
11
+ key: string;
12
+ needs: string[];
13
+ }
14
+
15
+ interface DagLayout {
16
+ columns: number[];
17
+ jobs: DagJob[];
18
+ }
19
+
20
+ // Parse a single workflow YAML and pull out the jobs graph. Returns null when
21
+ // the YAML is invalid or doesn't declare any jobs — the caller falls back to
22
+ // a flat layout built from runtime observations.
23
+ function parseWorkflowJobs(yaml: string | undefined): DagJob[] | null {
24
+ if (!yaml) return null;
25
+ let doc: unknown;
26
+ try {
27
+ doc = parseYaml(yaml);
28
+ } catch {
29
+ return null;
30
+ }
31
+ if (!doc || typeof doc !== "object") return null;
32
+ const jobsRaw = (doc as Record<string, unknown>).jobs;
33
+ if (!jobsRaw || typeof jobsRaw !== "object") return null;
34
+
35
+ const jobs: DagJob[] = [];
36
+ for (const [key, value] of Object.entries(
37
+ jobsRaw as Record<string, unknown>,
38
+ )) {
39
+ if (!value || typeof value !== "object") continue;
40
+ const obj = value as Record<string, unknown>;
41
+ const label = typeof obj.name === "string" && obj.name ? obj.name : key;
42
+ const needsField = obj.needs;
43
+ const needs: string[] = Array.isArray(needsField)
44
+ ? needsField.filter((n): n is string => typeof n === "string")
45
+ : typeof needsField === "string"
46
+ ? [needsField]
47
+ : [];
48
+ jobs.push({ id: key, key, label, needs });
49
+ }
50
+ return jobs;
51
+ }
52
+
53
+ // Topological layering — every job lives in column max(needs)+1 so edges
54
+ // always point left-to-right. Same shape GitHub uses for its workflow run.
55
+ function computeLayout(jobs: DagJob[]): DagLayout {
56
+ const columns: number[] = jobs.map(() => 0);
57
+ const keyToIdx = new Map(jobs.map((j, i) => [j.key, i]));
58
+
59
+ for (let pass = 0; pass < jobs.length + 1; pass++) {
60
+ let changed = false;
61
+ jobs.forEach((job, idx) => {
62
+ let col = 0;
63
+ for (const need of job.needs) {
64
+ const needIdx = keyToIdx.get(need);
65
+ if (needIdx === undefined) continue;
66
+ col = Math.max(col, columns[needIdx] + 1);
67
+ }
68
+ if (col !== columns[idx]) {
69
+ columns[idx] = col;
70
+ changed = true;
71
+ }
72
+ });
73
+ if (!changed) break;
74
+ }
75
+ return { columns, jobs };
76
+ }
77
+
78
+ // Merge declared jobs with runtime statuses; runtime keys may include matrix
79
+ // suffixes like `build-1`, so we collapse them onto the base key when
80
+ // possible and surface any extras as their own nodes.
81
+ function applyRuntimeStatuses(
82
+ declared: DagJob[],
83
+ runtime: GhaJobSnapshot[],
84
+ ): {
85
+ jobs: DagJob[];
86
+ statusByKey: Record<string, GhaJobStatus>;
87
+ durationByKey: Record<string, number | undefined>;
88
+ snapshotByKey: Record<string, GhaJobSnapshot | undefined>;
89
+ } {
90
+ const declaredKeys = new Set(declared.map((j) => j.key));
91
+ const statusByKey: Record<string, GhaJobStatus> = {};
92
+ const durationByKey: Record<string, number | undefined> = {};
93
+ const snapshotByKey: Record<string, GhaJobSnapshot | undefined> = {};
94
+ const extras: DagJob[] = [];
95
+
96
+ for (const job of declared) statusByKey[job.key] = "pending";
97
+
98
+ for (const snap of runtime) {
99
+ let key = snap.name;
100
+ if (!declaredKeys.has(key)) {
101
+ const base = declared.find((j) => snap.name.startsWith(`${j.key}-`));
102
+ if (base) key = base.key;
103
+ }
104
+ const prev = statusByKey[key];
105
+ statusByKey[key] = mergeStatus(prev, snap.status);
106
+ if (typeof snap.durationMs === "number") {
107
+ durationByKey[key] = Math.max(durationByKey[key] ?? 0, snap.durationMs);
108
+ }
109
+ // Keep the first snapshot seen for the key so step expansion has
110
+ // something to render. Matrix consolidation is best-effort.
111
+ if (!snapshotByKey[key]) snapshotByKey[key] = snap;
112
+ if (
113
+ !declaredKeys.has(snap.name) &&
114
+ !declared.some((j) => snap.name.startsWith(`${j.key}-`))
115
+ ) {
116
+ extras.push({
117
+ id: snap.name,
118
+ key: snap.name,
119
+ label: snap.name,
120
+ needs: [],
121
+ });
122
+ }
123
+ }
124
+
125
+ return {
126
+ jobs: [...declared, ...extras],
127
+ statusByKey,
128
+ durationByKey,
129
+ snapshotByKey,
130
+ };
131
+ }
132
+
133
+ function mergeStatus(
134
+ prev: GhaJobStatus | undefined,
135
+ next: GhaJobStatus,
136
+ ): GhaJobStatus {
137
+ const order: GhaJobStatus[] = [
138
+ "pending",
139
+ "skipped",
140
+ "success",
141
+ "running",
142
+ "failed",
143
+ ];
144
+ if (!prev) return next;
145
+ return order.indexOf(next) > order.indexOf(prev) ? next : prev;
146
+ }
147
+
148
+ // ─── Styling helpers ─────────────────────────────────────────────────────
149
+
150
+ const STATUS_STYLES: Record<
151
+ GhaJobStatus,
152
+ { box: string; dot: string; label: string; icon: string }
153
+ > = {
154
+ pending: {
155
+ box: "border-slate-700 bg-slate-900/40 text-slate-400",
156
+ dot: "bg-slate-500",
157
+ label: "Pending",
158
+ icon: "○",
159
+ },
160
+ running: {
161
+ box: "border-amber-400/60 bg-amber-500/10 text-amber-100",
162
+ dot: "bg-amber-400 animate-pulse",
163
+ label: "Running",
164
+ icon: "●",
165
+ },
166
+ success: {
167
+ box: "border-emerald-500/50 bg-emerald-500/10 text-emerald-100",
168
+ dot: "bg-emerald-400",
169
+ label: "Success",
170
+ icon: "✓",
171
+ },
172
+ failed: {
173
+ box: "border-red-500/60 bg-red-500/10 text-red-100",
174
+ dot: "bg-red-400",
175
+ label: "Failed",
176
+ icon: "✗",
177
+ },
178
+ skipped: {
179
+ box: "border-slate-600 bg-slate-800/30 text-slate-500",
180
+ dot: "bg-slate-500",
181
+ label: "Skipped",
182
+ icon: "↷",
183
+ },
184
+ };
185
+
186
+ function formatDuration(ms: number | undefined): string {
187
+ if (!ms || ms < 0) return "";
188
+ if (ms < 1000) return `${ms}ms`;
189
+ const s = ms / 1000;
190
+ if (s < 60) return `${s.toFixed(1)}s`;
191
+ const m = Math.floor(s / 60);
192
+ const r = Math.round(s % 60);
193
+ return `${m}m${r ? ` ${r}s` : ""}`;
194
+ }
195
+
196
+ // ─── Step row ────────────────────────────────────────────────────────────
197
+
198
+ function StepRow({ step }: { step: GhaStepSnapshot }) {
199
+ const [open, setOpen] = useState(false);
200
+ const style = STATUS_STYLES[step.status];
201
+ const hasLog = !!step.log && step.log.trim().length > 0;
202
+ return (
203
+ <div className="border-t border-slate-800/40 first:border-t-0">
204
+ <button
205
+ type="button"
206
+ onClick={() => hasLog && setOpen((v) => !v)}
207
+ className={`w-full flex items-center gap-2 px-2 py-1.5 text-[11px] text-left ${
208
+ hasLog ? "hover:bg-slate-800/40 cursor-pointer" : "cursor-default"
209
+ }`}
210
+ >
211
+ <span className="w-3 text-slate-500 inline-flex">
212
+ {hasLog ? (
213
+ open ? (
214
+ <ChevronDown className="w-3 h-3" />
215
+ ) : (
216
+ <ChevronRight className="w-3 h-3" />
217
+ )
218
+ ) : null}
219
+ </span>
220
+ <span
221
+ className={`inline-flex items-center justify-center w-4 h-4 rounded-full text-[10px] leading-none ${style.dot}`}
222
+ title={style.label}
223
+ >
224
+ <span className="text-slate-900 font-bold">{style.icon}</span>
225
+ </span>
226
+ <span className="flex-1 truncate text-slate-200">
227
+ {step.phase !== "Main" && (
228
+ <span className="text-slate-500 mr-1">[{step.phase}]</span>
229
+ )}
230
+ {step.name}
231
+ </span>
232
+ <span className="text-[10px] text-slate-500">
233
+ {formatDuration(step.durationMs)}
234
+ </span>
235
+ </button>
236
+ {open && hasLog && (
237
+ <pre className="mx-2 mb-2 max-h-60 overflow-auto rounded bg-black/40 p-2 text-[10px] text-slate-300 whitespace-pre-wrap">
238
+ {step.log}
239
+ </pre>
240
+ )}
241
+ </div>
242
+ );
243
+ }
244
+
245
+ // ─── Job card (expandable to step list) ──────────────────────────────────
246
+
247
+ function JobCard({
248
+ label,
249
+ status,
250
+ durationMs,
251
+ needs,
252
+ snapshot,
253
+ }: {
254
+ label: string;
255
+ status: GhaJobStatus;
256
+ durationMs?: number;
257
+ needs?: string[];
258
+ snapshot?: GhaJobSnapshot;
259
+ }) {
260
+ const [open, setOpen] = useState(false);
261
+ const style = STATUS_STYLES[status];
262
+ const steps = snapshot?.steps ?? [];
263
+ const hasSteps = steps.length > 0;
264
+ const dur = formatDuration(durationMs);
265
+ return (
266
+ <div className={`rounded-md border text-xs shadow-sm ${style.box}`}>
267
+ <button
268
+ type="button"
269
+ onClick={() => hasSteps && setOpen((v) => !v)}
270
+ className={`w-full flex items-center gap-2 px-3 py-2 ${
271
+ hasSteps ? "cursor-pointer" : "cursor-default"
272
+ }`}
273
+ >
274
+ <span className="w-3 opacity-70 inline-flex">
275
+ {hasSteps ? (
276
+ open ? (
277
+ <ChevronDown className="w-3 h-3" />
278
+ ) : (
279
+ <ChevronRight className="w-3 h-3" />
280
+ )
281
+ ) : null}
282
+ </span>
283
+ <span className={`inline-block w-2 h-2 rounded-full ${style.dot}`} />
284
+ <span className="font-medium truncate flex-1 text-left">{label}</span>
285
+ <span className="text-[10px] opacity-80">{style.label}</span>
286
+ {dur && <span className="text-[10px] opacity-80">• {dur}</span>}
287
+ </button>
288
+ {needs && needs.length > 0 && !open && (
289
+ <div className="px-3 pb-2 -mt-1 text-[10px] text-slate-500 truncate">
290
+ needs: {needs.join(", ")}
291
+ </div>
292
+ )}
293
+ {open && hasSteps && (
294
+ <div className="border-t border-slate-800/60 bg-slate-950/40">
295
+ {steps.map((s, idx) => (
296
+ <StepRow key={`${s.phase}-${s.name}-${idx}`} step={s} />
297
+ ))}
298
+ </div>
299
+ )}
300
+ </div>
301
+ );
302
+ }
303
+
304
+ // ─── Panel ───────────────────────────────────────────────────────────────
305
+
306
+ export interface GhaJobsPanelProps {
307
+ // Source of `needs:` info for live DAG layout. Pass undefined for history
308
+ // views so the layout reflects what actually ran rather than the current
309
+ // (possibly edited) YAML.
310
+ workflowYaml?: string | undefined;
311
+ jobs: GhaJobSnapshot[];
312
+ caption?: string;
313
+ // "dag" lays jobs into dependency columns; "list" stacks them vertically
314
+ // (used by history rows where the DAG is misleading because the YAML may
315
+ // have changed since the run).
316
+ mode?: "dag" | "list";
317
+ }
318
+
319
+ export default function GhaJobsPanel({
320
+ workflowYaml,
321
+ jobs,
322
+ caption,
323
+ mode = "dag",
324
+ }: GhaJobsPanelProps) {
325
+ const declared = useMemo(
326
+ () => (mode === "dag" ? (parseWorkflowJobs(workflowYaml) ?? []) : []),
327
+ [workflowYaml, mode],
328
+ );
329
+
330
+ const {
331
+ jobs: allJobs,
332
+ statusByKey,
333
+ durationByKey,
334
+ snapshotByKey,
335
+ } = useMemo(() => applyRuntimeStatuses(declared, jobs), [declared, jobs]);
336
+
337
+ const layout = useMemo(() => computeLayout(allJobs), [allJobs]);
338
+
339
+ const columnsMap = useMemo(() => {
340
+ const map = new Map<number, DagJob[]>();
341
+ layout.jobs.forEach((job, idx) => {
342
+ const col = layout.columns[idx];
343
+ const bucket = map.get(col) ?? [];
344
+ bucket.push(job);
345
+ map.set(col, bucket);
346
+ });
347
+ return Array.from(map.entries())
348
+ .sort((a, b) => a[0] - b[0])
349
+ .map(([col, jobs]) => ({ col, jobs }));
350
+ }, [layout]);
351
+
352
+ if (allJobs.length === 0) {
353
+ return (
354
+ <div className="flex-1 min-h-0 flex items-center justify-center text-xs text-slate-500 px-4 text-center">
355
+ No jobs detected yet. Run a workflow to see the live DAG, or open a
356
+ workflow YAML so the planned jobs appear here.
357
+ </div>
358
+ );
359
+ }
360
+
361
+ // ── List mode (history): vertical stack of expandable jobs ──
362
+ if (mode === "list") {
363
+ return (
364
+ <div className="flex-1 min-h-0 overflow-auto p-3">
365
+ {caption && (
366
+ <div className="mb-2 text-[10px] uppercase tracking-wider text-slate-500">
367
+ {caption}
368
+ </div>
369
+ )}
370
+ <div className="flex flex-col gap-2">
371
+ {allJobs.map((job) => (
372
+ <JobCard
373
+ key={job.id}
374
+ label={job.label}
375
+ status={statusByKey[job.key] ?? "pending"}
376
+ durationMs={durationByKey[job.key]}
377
+ snapshot={snapshotByKey[job.key]}
378
+ />
379
+ ))}
380
+ </div>
381
+ </div>
382
+ );
383
+ }
384
+
385
+ // ── DAG mode (live): layered columns with connector bars ──
386
+ return (
387
+ <div className="flex-1 min-h-0 overflow-auto p-3">
388
+ {caption && (
389
+ <div className="mb-2 text-[10px] uppercase tracking-wider text-slate-500">
390
+ {caption}
391
+ </div>
392
+ )}
393
+ <div className="flex items-start gap-6">
394
+ {columnsMap.map(({ col, jobs }, colIdx) => (
395
+ <div key={col} className="flex flex-col gap-3 min-w-[200px]">
396
+ {jobs.map((job) => (
397
+ <div key={job.id} className="relative">
398
+ <JobCard
399
+ label={job.label}
400
+ status={statusByKey[job.key] ?? "pending"}
401
+ durationMs={durationByKey[job.key]}
402
+ needs={job.needs}
403
+ snapshot={snapshotByKey[job.key]}
404
+ />
405
+ {colIdx < columnsMap.length - 1 && (
406
+ <span className="absolute top-4 -right-6 w-6 h-px bg-slate-700" />
407
+ )}
408
+ </div>
409
+ ))}
410
+ </div>
411
+ ))}
412
+ </div>
413
+ <div className="mt-4 flex flex-wrap gap-3 text-[10px] text-slate-500">
414
+ {(["pending", "running", "success", "failed", "skipped"] as const).map(
415
+ (s) => (
416
+ <span key={s} className="inline-flex items-center gap-1">
417
+ <span
418
+ className={`inline-block w-2 h-2 rounded-full ${STATUS_STYLES[s].dot}`}
419
+ />
420
+ {STATUS_STYLES[s].label}
421
+ </span>
422
+ ),
423
+ )}
424
+ <span className="ml-auto">
425
+ Jobs in the same column run in parallel; arrows imply{" "}
426
+ <code className="text-slate-400">needs:</code>. Click a job to expand
427
+ its steps.
428
+ </span>
429
+ </div>
430
+ </div>
431
+ );
432
+ }