/** * CI Run Detail page — real-time job/step viewer. * * Behaviour matches GitHub Actions: * - The currently-running step is auto-expanded and streams live output. * - When a step completes successfully it auto-collapses (unless the user * manually toggled it). * - Failed steps stay expanded. * - Log output shows line numbers and renders ANSI colour sequences. * * Data flows: * /actions/{runId}/events — SSE run/job/step status updates * /api/repos/…/steps/{id}/logs/stream — SSE per-step log lines */ // ── Types ───────────────────────────────────────────────────────────────────── interface StepMeta { stepId: string; status: string; isLive: boolean; } interface JobMeta { jobId: string; name: string; status: string; steps: StepMeta[]; } interface RunData { runId: string; status: string; baseUrl: string; repoId: string; owner: string; slug: string; isLive: boolean; jobs: JobMeta[]; } interface SSEStepStatus { stepId: string; status: string; exitCode: number | null; startedAt: string | null; completedAt: string | null; } interface SSEJobStatus { jobId: string; status: string; startedAt: string | null; completedAt: string | null; steps: SSEStepStatus[]; } interface SSEStatusPayload { status: string; startedAt: string | null; completedAt: string | null; jobs: SSEJobStatus[]; } // ── ANSI → HTML ─────────────────────────────────────────────────────────────── const ANSI_FG: Record = { 30: '#6e7681', 31: '#f85149', 32: '#3fb950', 33: '#d29922', 34: '#58a6ff', 35: '#bc8cff', 36: '#39c5cf', 37: '#b1bac4', 90: '#484f58', 91: '#ff7b72', 92: '#56d364', 93: '#e3b341', 94: '#79c0ff', 95: '#d2a8ff', 96: '#56d4dd', 97: '#f0f6fc', }; function escHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>'); } function ansiToHtml(raw: string): string { if (!raw.includes('\x1b[')) return escHtml(raw); let out = '', openSpans = 0, last = 0; const re = /\x1b\[([0-9;]*)m/g; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { out += escHtml(raw.slice(last, m.index)); last = m.index + m[0].length; for (const code of m[1].split(';').map(Number)) { if (code === 0) { while (openSpans-- > 0) out += ''; openSpans = 0; } else if (ANSI_FG[code]) { out += ``; openSpans++; } else if (code >= 40 && code <= 47 && ANSI_FG[code - 10]) { out += ``; openSpans++; } else if (code === 1) { out += ''; openSpans++; } else if (code === 2) { out += ''; openSpans++; } } } out += escHtml(raw.slice(last)); while (openSpans-- > 0) out += ''; return out; } // ── Log line rendering ──────────────────────────────────────────────────────── /** * Convert a raw log text string into numbered child elements * appended to `pre`. Preserves ANSI colours. * * `startAt` is the 1-based line number of the first line in `text`. */ function appendLogLines(pre: HTMLPreElement, text: string, startAt: number): number { const lines = text.split('\n'); // Don't emit a trailing blank line from a chunk that ends with \n if (lines[lines.length - 1] === '') lines.pop(); let n = startAt; for (const line of lines) { const span = document.createElement('span'); span.className = 'cird-log-line'; span.dataset.ln = String(n++); span.innerHTML = ansiToHtml(line); pre.appendChild(span); } return n; // next line number } /** * Render existing textContent of a
 as numbered lines in place.
 * Called once on page load for steps that already have stored log output.
 */
function renderExistingLog(pre: HTMLPreElement): void {
  const raw = pre.textContent ?? '';
  if (!raw.trim()) return;
  pre.textContent = '';
  appendLogLines(pre, raw, 1);
}

// ── Status SVG snippets ───────────────────────────────────────────────────────

function runIconSvg(status: string): string {
  const cls = (s: string) => `cird-icon cird-icon--${s}`;
  switch (status) {
    case 'success':
      return ``;
    case 'failure':
      return ``;
    case 'cancelled':
      return ``;
    case 'running':
    case 'queued':
    case 'pending':
      return ``;
    default:
      return ``;
  }
}

function jobBtnIconSvg(status: string): string {
  switch (status) {
    case 'success':
      return ``;
    case 'failure':
      return ``;
    case 'running':
    case 'queued':
      return ``;
    case 'skipped':
      return ``;
    default:
      return ``;
  }
}

function stepIconSvg(status: string): string {
  switch (status) {
    case 'success':
      return ``;
    case 'failure':
      return ``;
    case 'running':
    case 'queued':
      return ``;
    case 'skipped':
      return ``;
    default:
      return ``;
  }
}

// ── Duration helpers ──────────────────────────────────────────────────────────

const TERMINAL = new Set(['success', 'failure', 'cancelled', 'skipped']);

function fmt(seconds: number): string {
  if (seconds < 60) return `${seconds}s`;
  const m = Math.floor(seconds / 60), s = seconds % 60;
  return s > 0 ? `${m}m ${s}s` : `${m}m`;
}

const liveTimers = new Map>();

function startLiveTimer(el: HTMLElement, startedAt: string): ReturnType {
  const t0 = new Date(startedAt).getTime();
  const tick = () => { el.textContent = fmt(Math.round((Date.now() - t0) / 1000)); };
  tick();
  return setInterval(tick, 1000);
}

function setStaticDuration(el: HTMLElement, startedAt: string, completedAt: string): void {
  const elapsed = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
  el.textContent = fmt(elapsed);
  el.classList.remove('cird-live');
  el.dataset.completed = completedAt;
}

function ensureLiveTimer(el: HTMLElement, startedAt: string): void {
  if (el.id && liveTimers.has(el.id)) return;
  if (!el.dataset.started) el.dataset.started = startedAt;
  const h = startLiveTimer(el, startedAt);
  if (el.id) liveTimers.set(el.id, h);
}

function stopLiveTimer(el: HTMLElement): void {
  if (el.id && liveTimers.has(el.id)) {
    clearInterval(liveTimers.get(el.id)!);
    liveTimers.delete(el.id);
  }
}

function initDurationTimers(): void {
  document.querySelectorAll('[data-started]').forEach(el => {
    const started   = el.dataset.started   ?? '';
    const completed = el.dataset.completed ?? '';
    if (!started) return;
    if (completed) { setStaticDuration(el, started, completed); return; }
    ensureLiveTimer(el, started);
  });
}

// ── Job selection ─────────────────────────────────────────────────────────────

let activeJobId = '';

function selectJob(jobId: string): void {
  activeJobId = jobId;
  document.querySelectorAll('.cird-job-btn').forEach(btn => {
    btn.classList.toggle('cird-job-btn--active', btn.dataset.jobId === jobId);
  });
  document.querySelectorAll('.cird-job-panel').forEach(panel => {
    panel.hidden = panel.id !== `cird-job-panel-${jobId}`;
  });
}

function initSidebar(jobs: JobMeta[]): void {
  document.querySelectorAll('.cird-job-btn').forEach(btn => {
    btn.addEventListener('click', () => { const id = btn.dataset.jobId ?? ''; if (id) selectJob(id); });
  });
  const def =
    jobs.find(j => j.status === 'failure') ??
    jobs.find(j => j.status === 'running') ??
    jobs.find(j => j.status === 'queued')  ??
    jobs[0];
  if (def) selectJob(def.jobId);
}

// ── Step expand / collapse ────────────────────────────────────────────────────

// Steps that were opened by the auto-behaviour (not by user click).
// Auto-opened steps will be auto-closed on success; manually toggled steps
// are left alone.
const autoOpenedSteps = new Set();

// Previous step statuses — used to detect transitions (pending→running, running→done).
const prevStepStatus = new Map();

function setStepOpen(stepId: string, open: boolean): void {
  const btn  = document.querySelector(`[data-step-toggle="${stepId}"]`);
  const body = document.getElementById(`cird-log-panel-${stepId}`);
  if (!btn || !body) return;
  body.hidden = !open;
  btn.setAttribute('aria-expanded', open ? 'true' : 'false');
  btn.closest('.cird-step')?.classList.toggle('cird-step--open', open);
}

function openStep(stepId: string, auto = false): void {
  const body = document.getElementById(`cird-log-panel-${stepId}`);
  if (!body || !body.hidden) return; // already open
  setStepOpen(stepId, true);
  if (auto) autoOpenedSteps.add(stepId);
}

function closeStep(stepId: string): void {
  const body = document.getElementById(`cird-log-panel-${stepId}`);
  if (!body || body.hidden) return; // already closed
  setStepOpen(stepId, false);
  autoOpenedSteps.delete(stepId);
}

function initStepToggles(jobs: JobMeta[]): void {
  document.querySelectorAll('[data-step-toggle]').forEach(btn => {
    const stepId = btn.dataset.stepToggle ?? '';
    const body   = document.getElementById(`cird-log-panel-${stepId}`);
    if (!body) return;
    btn.addEventListener('click', () => {
      const opening = body.hidden;
      // User is manually toggling — remove from auto-set so we stop controlling it
      autoOpenedSteps.delete(stepId);
      setStepOpen(stepId, opening);
    });
  });

  // Auto-open running / failed steps on initial load
  jobs.forEach(job => job.steps.forEach(step => {
    prevStepStatus.set(step.stepId, step.status);
    if (step.status === 'failure') openStep(step.stepId, false); // failure: keep open, not auto
    else if (step.isLive)         openStep(step.stepId, true);   // running: auto-open
  }));
}

// ── SSE: run-status stream ────────────────────────────────────────────────────

function applyStatusUpdate(data: SSEStatusPayload): void {
  // Run icon + status pill
  const icon = document.getElementById('cird-run-icon');
  if (icon) icon.innerHTML = runIconSvg(data.status);
  const pill = document.getElementById('cird-status-pill');
  if (pill) {
    pill.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
    pill.className   = `cird-status-pill cird-status-pill--${data.status}`;
  }

  // Run total duration
  const runDur = document.getElementById('cird-total-dur');
  if (runDur && data.startedAt) {
    if (data.completedAt && !runDur.dataset.completed) {
      stopLiveTimer(runDur); setStaticDuration(runDur, data.startedAt, data.completedAt);
    } else if (!data.completedAt) { ensureLiveTimer(runDur, data.startedAt); }
  }

  for (const job of data.jobs) {
    // Sidebar button
    const btn = document.getElementById(`cird-job-btn-${job.jobId}`);
    if (btn) {
      btn.className = `cird-job-btn cird-job-btn--${job.status}` + (job.jobId === activeJobId ? ' cird-job-btn--active' : '');
      const btnIcon = document.getElementById(`cird-job-btn-icon-${job.jobId}`);
      if (btnIcon) btnIcon.innerHTML = jobBtnIconSvg(job.status);
    }

    // Sidebar job duration
    const btnDur = document.getElementById(`cird-job-btn-dur-${job.jobId}`);
    if (btnDur && job.startedAt) {
      if (job.completedAt && !btnDur.dataset.completed) {
        stopLiveTimer(btnDur); setStaticDuration(btnDur, job.startedAt, job.completedAt);
      } else if (!job.completedAt) {
        btnDur.innerHTML = '●';
      }
    }

    // Job panel header duration
    const panelDur = document.getElementById(`cird-job-header-dur-${job.jobId}`);
    if (panelDur && job.startedAt) {
      if (job.completedAt && !panelDur.dataset.completed) {
        stopLiveTimer(panelDur); setStaticDuration(panelDur, job.startedAt, job.completedAt);
      } else if (!job.completedAt) { ensureLiveTimer(panelDur, job.startedAt); }
    }

    for (const step of job.steps) {
      const prev = prevStepStatus.get(step.stepId);

      // ── Step element class (drives left-border colour)
      const stepEl = document.getElementById(`cird-step-${step.stepId}`);
      if (stepEl) {
        stepEl.className = `cird-step cird-step--${step.status}`;
      }

      // ── Step icon
      const stepIcon = document.getElementById(`cird-step-icon-${step.stepId}`);
      if (stepIcon) stepIcon.innerHTML = stepIconSvg(step.status);

      // ── Step duration
      const stepDur = document.getElementById(`cird-step-dur-${step.stepId}`);
      if (stepDur && step.startedAt) {
        if (step.completedAt && !stepDur.dataset.completed) {
          stopLiveTimer(stepDur); setStaticDuration(stepDur, step.startedAt, step.completedAt);
        } else if (!step.completedAt) { ensureLiveTimer(stepDur, step.startedAt); }
      }

      // ── Step open/close transitions (the GitHub-style auto-behaviour)
      //
      // running   → currently executing  : auto-open
      // running   → success              : auto-close (if we auto-opened it)
      // running   → failure              : keep open (don't auto-close failures)
      // * → skipped                      : stay closed
      if (step.status === 'running' && prev !== 'running') {
        openStep(step.stepId, /* auto = */ true);
      } else if (prev === 'running' && TERMINAL.has(step.status)) {
        if (step.status === 'success' && autoOpenedSteps.has(step.stepId)) {
          closeStep(step.stepId);
        }
        // failure: intentionally NOT closed — leave expanded so the user sees the error
      }

      // ── Exit code badge
      if (step.exitCode !== null && step.exitCode !== 0) {
        if (!document.getElementById(`cird-step-exit-${step.stepId}`)) {
          const dur = document.getElementById(`cird-step-dur-${step.stepId}`);
          if (dur?.parentElement) {
            const badge = document.createElement('span');
            badge.id = `cird-step-exit-${step.stepId}`;
            badge.className = 'cird-step-exit';
            badge.textContent = `exit ${step.exitCode}`;
            dur.parentElement.insertBefore(badge, dur);
          }
        }
      }

      prevStepStatus.set(step.stepId, step.status);
    }
  }

  // Auto-select the failing job when it appears
  if (!activeJobId || document.querySelector('.cird-job-btn--active') === null) {
    const fail = data.jobs.find(j => j.status === 'failure');
    if (fail) selectJob(fail.jobId);
  }
}

function initRunStatusSSE(runData: RunData): void {
  if (!runData.isLive) return;
  const es = new EventSource(`${runData.baseUrl}/actions/${runData.runId}/events`);
  es.addEventListener('status', (e: Event) => {
    try { applyStatusUpdate(JSON.parse((e as MessageEvent).data) as SSEStatusPayload); }
    catch { /* malformed — ignore */ }
  });
  es.addEventListener('done', (e: Event) => {
    try {
      const d = JSON.parse((e as MessageEvent).data) as { status: string };
      const pill = document.getElementById('cird-status-pill');
      if (pill) { pill.textContent = d.status.charAt(0).toUpperCase() + d.status.slice(1); pill.className = `cird-status-pill cird-status-pill--${d.status}`; }
      const icon = document.getElementById('cird-run-icon');
      if (icon) icon.innerHTML = runIconSvg(d.status);
    } catch { /* ignore */ }
    es.close();
  });
  es.onerror = () => es.close();
}

// ── SSE: per-step log streaming ───────────────────────────────────────────────

function initLogStreaming(): void {
  document.querySelectorAll('[data-step-live="true"]').forEach(pre => {
    const url = pre.dataset.streamUrl;
    if (!url) return;

    // Count lines already rendered (from server-side log_output, if any)
    let nextLine = pre.querySelectorAll('.cird-log-line').length + 1;

    // Buffer for incomplete lines — chunks from the SSE may not be line-aligned
    let lineBuffer = '';

    function flush(text: string, finalFlush = false): void {
      lineBuffer += text;
      // Split on newlines; keep the incomplete tail in the buffer
      const lines = lineBuffer.split('\n');
      lineBuffer = finalFlush ? '' : (lines.pop() ?? '');
      for (const line of lines) {
        const span = document.createElement('span');
        span.className = 'cird-log-line';
        span.dataset.ln = String(nextLine++);
        span.innerHTML = ansiToHtml(line);
        pre.appendChild(span);
      }
      // Auto-scroll to bottom while streaming
      pre.scrollTop = pre.scrollHeight;
    }

    const es = new EventSource(url);
    es.addEventListener('log', (e: Event) => {
      flush((e as MessageEvent).data + '\n');
    });
    es.addEventListener('done', () => { flush('', /* finalFlush = */ true); es.close(); });
    es.onerror = () => { flush('', true); es.close(); };
  });
}

// ── Copy buttons ──────────────────────────────────────────────────────────────

const COPY_SVG = ` Copy`;

function initCopyButtons(): void {
  document.querySelectorAll('.cird-log-copy').forEach(btn => {
    btn.addEventListener('click', () => {
      const pre = document.getElementById(btn.dataset.copyTarget ?? '');
      if (!pre) return;
      // Collect text from line spans to preserve line breaks
      const text = Array.from(pre.querySelectorAll('.cird-log-line'))
        .map(s => s.textContent ?? '')
        .join('\n');
      navigator.clipboard.writeText(text).then(() => {
        btn.textContent = 'Copied!';
        setTimeout(() => { btn.innerHTML = COPY_SVG; }, 2000);
      });
    });
  });
}

// ── Keyboard shortcuts ────────────────────────────────────────────────────────

function initKeyboard(jobs: JobMeta[]): void {
  document.addEventListener('keydown', (e: KeyboardEvent) => {
    if ((e.target as HTMLElement).tagName === 'INPUT') return;
    if (e.key === 'f' || e.key === 'F') {
      const fail = jobs.find(j => j.status === 'failure');
      if (fail) selectJob(fail.jobId);
    }
  });
}

// ── Entry point ───────────────────────────────────────────────────────────────

export function initCIRunDetail(): void {
  const dataEl = document.getElementById('ci-run-data');
  if (!dataEl) return;
  let runData: RunData;
  try { runData = JSON.parse(dataEl.textContent ?? '{}') as RunData; }
  catch { return; }

  const { jobs } = runData;

  // Render existing log output as numbered lines (ANSI-aware)
  document.querySelectorAll('.cird-log-pre').forEach(pre => {
    renderExistingLog(pre);
  });

  initSidebar(jobs);
  initStepToggles(jobs);
  initDurationTimers();
  initLogStreaming();
  initCopyButtons();
  initKeyboard(jobs);
  initRunStatusSSE(runData);
}