gabriel / musehub public
ci-run-detail.ts typescript
512 lines 23.3 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 /**
2 * CI Run Detail page — real-time job/step viewer.
3 *
4 * Behaviour matches GitHub Actions:
5 * - The currently-running step is auto-expanded and streams live output.
6 * - When a step completes successfully it auto-collapses (unless the user
7 * manually toggled it).
8 * - Failed steps stay expanded.
9 * - Log output shows line numbers and renders ANSI colour sequences.
10 *
11 * Data flows:
12 * /actions/{runId}/events — SSE run/job/step status updates
13 * /api/repos/…/steps/{id}/logs/stream — SSE per-step log lines
14 */
15
16 // ── Types ─────────────────────────────────────────────────────────────────────
17
18 interface StepMeta { stepId: string; status: string; isLive: boolean; }
19 interface JobMeta { jobId: string; name: string; status: string; steps: StepMeta[]; }
20 interface RunData {
21 runId: string; status: string; baseUrl: string; repoId: string;
22 owner: string; slug: string; isLive: boolean; jobs: JobMeta[];
23 }
24 interface SSEStepStatus {
25 stepId: string; status: string; exitCode: number | null;
26 startedAt: string | null; completedAt: string | null;
27 }
28 interface SSEJobStatus {
29 jobId: string; status: string;
30 startedAt: string | null; completedAt: string | null;
31 steps: SSEStepStatus[];
32 }
33 interface SSEStatusPayload {
34 status: string;
35 startedAt: string | null; completedAt: string | null;
36 jobs: SSEJobStatus[];
37 }
38
39 // ── ANSI → HTML ───────────────────────────────────────────────────────────────
40
41 const ANSI_FG: Record<number, string> = {
42 30: '#6e7681', 31: '#f85149', 32: '#3fb950', 33: '#d29922',
43 34: '#58a6ff', 35: '#bc8cff', 36: '#39c5cf', 37: '#b1bac4',
44 90: '#484f58', 91: '#ff7b72', 92: '#56d364', 93: '#e3b341',
45 94: '#79c0ff', 95: '#d2a8ff', 96: '#56d4dd', 97: '#f0f6fc',
46 };
47
48 function escHtml(s: string): string {
49 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
50 }
51
52 function ansiToHtml(raw: string): string {
53 if (!raw.includes('\x1b[')) return escHtml(raw);
54 let out = '', openSpans = 0, last = 0;
55 const re = /\x1b\[([0-9;]*)m/g;
56 let m: RegExpExecArray | null;
57 while ((m = re.exec(raw)) !== null) {
58 out += escHtml(raw.slice(last, m.index));
59 last = m.index + m[0].length;
60 for (const code of m[1].split(';').map(Number)) {
61 if (code === 0) { while (openSpans-- > 0) out += '</span>'; openSpans = 0; }
62 else if (ANSI_FG[code]) { out += `<span style="color:${ANSI_FG[code]}">`; openSpans++; }
63 else if (code >= 40 && code <= 47 && ANSI_FG[code - 10]) {
64 out += `<span style="background:${ANSI_FG[code - 10]}">`; openSpans++;
65 } else if (code === 1) { out += '<span style="font-weight:700">'; openSpans++; }
66 else if (code === 2) { out += '<span style="opacity:.55">'; openSpans++; }
67 }
68 }
69 out += escHtml(raw.slice(last));
70 while (openSpans-- > 0) out += '</span>';
71 return out;
72 }
73
74 // ── Log line rendering ────────────────────────────────────────────────────────
75
76 /**
77 * Convert a raw log text string into numbered <span> child elements
78 * appended to `pre`. Preserves ANSI colours.
79 *
80 * `startAt` is the 1-based line number of the first line in `text`.
81 */
82 function appendLogLines(pre: HTMLPreElement, text: string, startAt: number): number {
83 const lines = text.split('\n');
84 // Don't emit a trailing blank line from a chunk that ends with \n
85 if (lines[lines.length - 1] === '') lines.pop();
86 let n = startAt;
87 for (const line of lines) {
88 const span = document.createElement('span');
89 span.className = 'cird-log-line';
90 span.dataset.ln = String(n++);
91 span.innerHTML = ansiToHtml(line);
92 pre.appendChild(span);
93 }
94 return n; // next line number
95 }
96
97 /**
98 * Render existing textContent of a <pre> as numbered lines in place.
99 * Called once on page load for steps that already have stored log output.
100 */
101 function renderExistingLog(pre: HTMLPreElement): void {
102 const raw = pre.textContent ?? '';
103 if (!raw.trim()) return;
104 pre.textContent = '';
105 appendLogLines(pre, raw, 1);
106 }
107
108 // ── Status SVG snippets ───────────────────────────────────────────────────────
109
110 function runIconSvg(status: string): string {
111 const cls = (s: string) => `cird-icon cird-icon--${s}`;
112 switch (status) {
113 case 'success':
114 return `<svg class="${cls('success')}" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 9"/></svg>`;
115 case 'failure':
116 return `<svg class="${cls('failure')}" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
117 case 'cancelled':
118 return `<svg class="${cls('cancelled')}" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
119 case 'running':
120 case 'queued':
121 case 'pending':
122 return `<svg class="${cls('running')} ci-spin" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;
123 default:
124 return `<svg class="${cls('pending')}" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>`;
125 }
126 }
127
128 function jobBtnIconSvg(status: string): string {
129 switch (status) {
130 case 'success':
131 return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 9"/></svg>`;
132 case 'failure':
133 return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
134 case 'running':
135 case 'queued':
136 return `<svg class="ci-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;
137 case 'skipped':
138 return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
139 default:
140 return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/></svg>`;
141 }
142 }
143
144 function stepIconSvg(status: string): string {
145 switch (status) {
146 case 'success':
147 return `<svg class="si-success" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 9"/></svg>`;
148 case 'failure':
149 return `<svg class="si-failure" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
150 case 'running':
151 case 'queued':
152 return `<svg class="si-running ci-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;
153 case 'skipped':
154 return `<svg class="si-skipped" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
155 default:
156 return `<svg class="si-pending" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>`;
157 }
158 }
159
160 // ── Duration helpers ──────────────────────────────────────────────────────────
161
162 const TERMINAL = new Set(['success', 'failure', 'cancelled', 'skipped']);
163
164 function fmt(seconds: number): string {
165 if (seconds < 60) return `${seconds}s`;
166 const m = Math.floor(seconds / 60), s = seconds % 60;
167 return s > 0 ? `${m}m ${s}s` : `${m}m`;
168 }
169
170 const liveTimers = new Map<string, ReturnType<typeof setInterval>>();
171
172 function startLiveTimer(el: HTMLElement, startedAt: string): ReturnType<typeof setInterval> {
173 const t0 = new Date(startedAt).getTime();
174 const tick = () => { el.textContent = fmt(Math.round((Date.now() - t0) / 1000)); };
175 tick();
176 return setInterval(tick, 1000);
177 }
178
179 function setStaticDuration(el: HTMLElement, startedAt: string, completedAt: string): void {
180 const elapsed = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
181 el.textContent = fmt(elapsed);
182 el.classList.remove('cird-live');
183 el.dataset.completed = completedAt;
184 }
185
186 function ensureLiveTimer(el: HTMLElement, startedAt: string): void {
187 if (el.id && liveTimers.has(el.id)) return;
188 if (!el.dataset.started) el.dataset.started = startedAt;
189 const h = startLiveTimer(el, startedAt);
190 if (el.id) liveTimers.set(el.id, h);
191 }
192
193 function stopLiveTimer(el: HTMLElement): void {
194 if (el.id && liveTimers.has(el.id)) {
195 clearInterval(liveTimers.get(el.id)!);
196 liveTimers.delete(el.id);
197 }
198 }
199
200 function initDurationTimers(): void {
201 document.querySelectorAll<HTMLElement>('[data-started]').forEach(el => {
202 const started = el.dataset.started ?? '';
203 const completed = el.dataset.completed ?? '';
204 if (!started) return;
205 if (completed) { setStaticDuration(el, started, completed); return; }
206 ensureLiveTimer(el, started);
207 });
208 }
209
210 // ── Job selection ─────────────────────────────────────────────────────────────
211
212 let activeJobId = '';
213
214 function selectJob(jobId: string): void {
215 activeJobId = jobId;
216 document.querySelectorAll<HTMLElement>('.cird-job-btn').forEach(btn => {
217 btn.classList.toggle('cird-job-btn--active', btn.dataset.jobId === jobId);
218 });
219 document.querySelectorAll<HTMLElement>('.cird-job-panel').forEach(panel => {
220 panel.hidden = panel.id !== `cird-job-panel-${jobId}`;
221 });
222 }
223
224 function initSidebar(jobs: JobMeta[]): void {
225 document.querySelectorAll<HTMLElement>('.cird-job-btn').forEach(btn => {
226 btn.addEventListener('click', () => { const id = btn.dataset.jobId ?? ''; if (id) selectJob(id); });
227 });
228 const def =
229 jobs.find(j => j.status === 'failure') ??
230 jobs.find(j => j.status === 'running') ??
231 jobs.find(j => j.status === 'queued') ??
232 jobs[0];
233 if (def) selectJob(def.jobId);
234 }
235
236 // ── Step expand / collapse ────────────────────────────────────────────────────
237
238 // Steps that were opened by the auto-behaviour (not by user click).
239 // Auto-opened steps will be auto-closed on success; manually toggled steps
240 // are left alone.
241 const autoOpenedSteps = new Set<string>();
242
243 // Previous step statuses — used to detect transitions (pending→running, running→done).
244 const prevStepStatus = new Map<string, string>();
245
246 function setStepOpen(stepId: string, open: boolean): void {
247 const btn = document.querySelector<HTMLButtonElement>(`[data-step-toggle="${stepId}"]`);
248 const body = document.getElementById(`cird-log-panel-${stepId}`);
249 if (!btn || !body) return;
250 body.hidden = !open;
251 btn.setAttribute('aria-expanded', open ? 'true' : 'false');
252 btn.closest('.cird-step')?.classList.toggle('cird-step--open', open);
253 }
254
255 function openStep(stepId: string, auto = false): void {
256 const body = document.getElementById(`cird-log-panel-${stepId}`);
257 if (!body || !body.hidden) return; // already open
258 setStepOpen(stepId, true);
259 if (auto) autoOpenedSteps.add(stepId);
260 }
261
262 function closeStep(stepId: string): void {
263 const body = document.getElementById(`cird-log-panel-${stepId}`);
264 if (!body || body.hidden) return; // already closed
265 setStepOpen(stepId, false);
266 autoOpenedSteps.delete(stepId);
267 }
268
269 function initStepToggles(jobs: JobMeta[]): void {
270 document.querySelectorAll<HTMLButtonElement>('[data-step-toggle]').forEach(btn => {
271 const stepId = btn.dataset.stepToggle ?? '';
272 const body = document.getElementById(`cird-log-panel-${stepId}`);
273 if (!body) return;
274 btn.addEventListener('click', () => {
275 const opening = body.hidden;
276 // User is manually toggling — remove from auto-set so we stop controlling it
277 autoOpenedSteps.delete(stepId);
278 setStepOpen(stepId, opening);
279 });
280 });
281
282 // Auto-open running / failed steps on initial load
283 jobs.forEach(job => job.steps.forEach(step => {
284 prevStepStatus.set(step.stepId, step.status);
285 if (step.status === 'failure') openStep(step.stepId, false); // failure: keep open, not auto
286 else if (step.isLive) openStep(step.stepId, true); // running: auto-open
287 }));
288 }
289
290 // ── SSE: run-status stream ────────────────────────────────────────────────────
291
292 function applyStatusUpdate(data: SSEStatusPayload): void {
293 // Run icon + status pill
294 const icon = document.getElementById('cird-run-icon');
295 if (icon) icon.innerHTML = runIconSvg(data.status);
296 const pill = document.getElementById('cird-status-pill');
297 if (pill) {
298 pill.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
299 pill.className = `cird-status-pill cird-status-pill--${data.status}`;
300 }
301
302 // Run total duration
303 const runDur = document.getElementById('cird-total-dur');
304 if (runDur && data.startedAt) {
305 if (data.completedAt && !runDur.dataset.completed) {
306 stopLiveTimer(runDur); setStaticDuration(runDur, data.startedAt, data.completedAt);
307 } else if (!data.completedAt) { ensureLiveTimer(runDur, data.startedAt); }
308 }
309
310 for (const job of data.jobs) {
311 // Sidebar button
312 const btn = document.getElementById(`cird-job-btn-${job.jobId}`);
313 if (btn) {
314 btn.className = `cird-job-btn cird-job-btn--${job.status}` + (job.jobId === activeJobId ? ' cird-job-btn--active' : '');
315 const btnIcon = document.getElementById(`cird-job-btn-icon-${job.jobId}`);
316 if (btnIcon) btnIcon.innerHTML = jobBtnIconSvg(job.status);
317 }
318
319 // Sidebar job duration
320 const btnDur = document.getElementById(`cird-job-btn-dur-${job.jobId}`);
321 if (btnDur && job.startedAt) {
322 if (job.completedAt && !btnDur.dataset.completed) {
323 stopLiveTimer(btnDur); setStaticDuration(btnDur, job.startedAt, job.completedAt);
324 } else if (!job.completedAt) {
325 btnDur.innerHTML = '<span class="cird-live-pulse">●</span>';
326 }
327 }
328
329 // Job panel header duration
330 const panelDur = document.getElementById(`cird-job-header-dur-${job.jobId}`);
331 if (panelDur && job.startedAt) {
332 if (job.completedAt && !panelDur.dataset.completed) {
333 stopLiveTimer(panelDur); setStaticDuration(panelDur, job.startedAt, job.completedAt);
334 } else if (!job.completedAt) { ensureLiveTimer(panelDur, job.startedAt); }
335 }
336
337 for (const step of job.steps) {
338 const prev = prevStepStatus.get(step.stepId);
339
340 // ── Step element class (drives left-border colour)
341 const stepEl = document.getElementById(`cird-step-${step.stepId}`);
342 if (stepEl) {
343 stepEl.className = `cird-step cird-step--${step.status}`;
344 }
345
346 // ── Step icon
347 const stepIcon = document.getElementById(`cird-step-icon-${step.stepId}`);
348 if (stepIcon) stepIcon.innerHTML = stepIconSvg(step.status);
349
350 // ── Step duration
351 const stepDur = document.getElementById(`cird-step-dur-${step.stepId}`);
352 if (stepDur && step.startedAt) {
353 if (step.completedAt && !stepDur.dataset.completed) {
354 stopLiveTimer(stepDur); setStaticDuration(stepDur, step.startedAt, step.completedAt);
355 } else if (!step.completedAt) { ensureLiveTimer(stepDur, step.startedAt); }
356 }
357
358 // ── Step open/close transitions (the GitHub-style auto-behaviour)
359 //
360 // running → currently executing : auto-open
361 // running → success : auto-close (if we auto-opened it)
362 // running → failure : keep open (don't auto-close failures)
363 // * → skipped : stay closed
364 if (step.status === 'running' && prev !== 'running') {
365 openStep(step.stepId, /* auto = */ true);
366 } else if (prev === 'running' && TERMINAL.has(step.status)) {
367 if (step.status === 'success' && autoOpenedSteps.has(step.stepId)) {
368 closeStep(step.stepId);
369 }
370 // failure: intentionally NOT closed — leave expanded so the user sees the error
371 }
372
373 // ── Exit code badge
374 if (step.exitCode !== null && step.exitCode !== 0) {
375 if (!document.getElementById(`cird-step-exit-${step.stepId}`)) {
376 const dur = document.getElementById(`cird-step-dur-${step.stepId}`);
377 if (dur?.parentElement) {
378 const badge = document.createElement('span');
379 badge.id = `cird-step-exit-${step.stepId}`;
380 badge.className = 'cird-step-exit';
381 badge.textContent = `exit ${step.exitCode}`;
382 dur.parentElement.insertBefore(badge, dur);
383 }
384 }
385 }
386
387 prevStepStatus.set(step.stepId, step.status);
388 }
389 }
390
391 // Auto-select the failing job when it appears
392 if (!activeJobId || document.querySelector('.cird-job-btn--active') === null) {
393 const fail = data.jobs.find(j => j.status === 'failure');
394 if (fail) selectJob(fail.jobId);
395 }
396 }
397
398 function initRunStatusSSE(runData: RunData): void {
399 if (!runData.isLive) return;
400 const es = new EventSource(`${runData.baseUrl}/actions/${runData.runId}/events`);
401 es.addEventListener('status', (e: Event) => {
402 try { applyStatusUpdate(JSON.parse((e as MessageEvent<string>).data) as SSEStatusPayload); }
403 catch { /* malformed — ignore */ }
404 });
405 es.addEventListener('done', (e: Event) => {
406 try {
407 const d = JSON.parse((e as MessageEvent<string>).data) as { status: string };
408 const pill = document.getElementById('cird-status-pill');
409 if (pill) { pill.textContent = d.status.charAt(0).toUpperCase() + d.status.slice(1); pill.className = `cird-status-pill cird-status-pill--${d.status}`; }
410 const icon = document.getElementById('cird-run-icon');
411 if (icon) icon.innerHTML = runIconSvg(d.status);
412 } catch { /* ignore */ }
413 es.close();
414 });
415 es.onerror = () => es.close();
416 }
417
418 // ── SSE: per-step log streaming ───────────────────────────────────────────────
419
420 function initLogStreaming(): void {
421 document.querySelectorAll<HTMLPreElement>('[data-step-live="true"]').forEach(pre => {
422 const url = pre.dataset.streamUrl;
423 if (!url) return;
424
425 // Count lines already rendered (from server-side log_output, if any)
426 let nextLine = pre.querySelectorAll('.cird-log-line').length + 1;
427
428 // Buffer for incomplete lines — chunks from the SSE may not be line-aligned
429 let lineBuffer = '';
430
431 function flush(text: string, finalFlush = false): void {
432 lineBuffer += text;
433 // Split on newlines; keep the incomplete tail in the buffer
434 const lines = lineBuffer.split('\n');
435 lineBuffer = finalFlush ? '' : (lines.pop() ?? '');
436 for (const line of lines) {
437 const span = document.createElement('span');
438 span.className = 'cird-log-line';
439 span.dataset.ln = String(nextLine++);
440 span.innerHTML = ansiToHtml(line);
441 pre.appendChild(span);
442 }
443 // Auto-scroll to bottom while streaming
444 pre.scrollTop = pre.scrollHeight;
445 }
446
447 const es = new EventSource(url);
448 es.addEventListener('log', (e: Event) => {
449 flush((e as MessageEvent<string>).data + '\n');
450 });
451 es.addEventListener('done', () => { flush('', /* finalFlush = */ true); es.close(); });
452 es.onerror = () => { flush('', true); es.close(); };
453 });
454 }
455
456 // ── Copy buttons ──────────────────────────────────────────────────────────────
457
458 const COPY_SVG = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy`;
459
460 function initCopyButtons(): void {
461 document.querySelectorAll<HTMLButtonElement>('.cird-log-copy').forEach(btn => {
462 btn.addEventListener('click', () => {
463 const pre = document.getElementById(btn.dataset.copyTarget ?? '');
464 if (!pre) return;
465 // Collect text from line spans to preserve line breaks
466 const text = Array.from(pre.querySelectorAll<HTMLElement>('.cird-log-line'))
467 .map(s => s.textContent ?? '')
468 .join('\n');
469 navigator.clipboard.writeText(text).then(() => {
470 btn.textContent = 'Copied!';
471 setTimeout(() => { btn.innerHTML = COPY_SVG; }, 2000);
472 });
473 });
474 });
475 }
476
477 // ── Keyboard shortcuts ────────────────────────────────────────────────────────
478
479 function initKeyboard(jobs: JobMeta[]): void {
480 document.addEventListener('keydown', (e: KeyboardEvent) => {
481 if ((e.target as HTMLElement).tagName === 'INPUT') return;
482 if (e.key === 'f' || e.key === 'F') {
483 const fail = jobs.find(j => j.status === 'failure');
484 if (fail) selectJob(fail.jobId);
485 }
486 });
487 }
488
489 // ── Entry point ───────────────────────────────────────────────────────────────
490
491 export function initCIRunDetail(): void {
492 const dataEl = document.getElementById('ci-run-data');
493 if (!dataEl) return;
494 let runData: RunData;
495 try { runData = JSON.parse(dataEl.textContent ?? '{}') as RunData; }
496 catch { return; }
497
498 const { jobs } = runData;
499
500 // Render existing log output as numbered lines (ANSI-aware)
501 document.querySelectorAll<HTMLPreElement>('.cird-log-pre').forEach(pre => {
502 renderExistingLog(pre);
503 });
504
505 initSidebar(jobs);
506 initStepToggles(jobs);
507 initDurationTimers();
508 initLogStreaming();
509 initCopyButtons();
510 initKeyboard(jobs);
511 initRunStatusSSE(runData);
512 }
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago