faucet-cli 1.2.0

Config-driven CLI runner for faucet-stream pipelines (YAML / JSON, Meltano-style)
Documentation
import { api, toast } from "../api.js";
import { streamLogs } from "../sse.js";
import { navigate } from "../router.js";
import { fmtTime } from "./runs.js";
import { escapeHtml } from "../utils.js";

const TERMINAL = ["completed", "failed", "cancelled"];

export async function renderDetail(container, { id }) {
  let pollTimer = null;
  let logCtrl = null;

  container.innerHTML = `
    <div class="page">
      <div class="page-head">
        <button class="btn-ghost" id="back"> Runs</button>
        <div class="detail-actions">
          <button class="btn-ghost" id="cancel" hidden>Cancel</button>
          <button class="btn-danger" id="delete" hidden>Delete</button>
        </div>
      </div>
      <div id="detail-head"></div>
      <h2>Invocations</h2>
      <div id="invocations"></div>
      <h2>Logs</h2>
      <pre id="logs" class="logs"></pre>
    </div>`;

  container.querySelector("#back").onclick = () => navigate("#/runs");

  const logsEl = container.querySelector("#logs");
  function appendLog(text, cls = "") {
    const atBottom = logsEl.scrollHeight - logsEl.scrollTop - logsEl.clientHeight < 40;
    const span = document.createElement("span");
    if (cls) span.className = cls;
    span.textContent = text + "\n";
    logsEl.appendChild(span);
    if (atBottom) logsEl.scrollTop = logsEl.scrollHeight;
  }

  async function load() {
    let rec;
    try {
      rec = await api(`/v1/runs/${encodeURIComponent(id)}`);
    } catch (e) {
      container.querySelector("#detail-head").innerHTML = `<div class="empty">${escapeHtml(e.message)}</div>`;
      // Keep polling through a transient (network / 5xx) failure; stop on 4xx (e.g. a deleted run).
      if (e.status === undefined || e.status >= 500) {
        clearTimeout(pollTimer);
        pollTimer = setTimeout(load, 3000);
      }
      return;
    }
    renderHead(rec);
    const live = !TERMINAL.includes(rec.status);
    container.querySelector("#cancel").hidden = !(rec.status === "running" || rec.status === "queued");
    container.querySelector("#delete").hidden = !TERMINAL.includes(rec.status);
    if (live) {
      clearTimeout(pollTimer);
      pollTimer = setTimeout(load, 3000);
    }
  }

  function renderHead(rec) {
    const errors = rec.error ? `<div class="error-box">${escapeHtml(rec.error)}</div>` : "";
    container.querySelector("#detail-head").innerHTML = `
      <div class="detail-grid">
        <div><span class="pill pill-${rec.status}">${rec.status}</span></div>
        <div><b>${escapeHtml(rec.name || rec.run_id)}</b></div>
        <div>submitted ${fmtTime(rec.submitted_at)}</div>
        <div>started ${fmtTime(rec.started_at)}</div>
        <div>finished ${fmtTime(rec.finished_at)}</div>
        <div>${rec.records_written ?? 0} rows</div>
        ${rec.idempotency_key ? `<div>idem: ${escapeHtml(rec.idempotency_key)}</div>` : ""}
      </div>${errors}`;
    const inv = container.querySelector("#invocations");
    inv.innerHTML =
      `<table class="tbl"><thead><tr><th>row</th><th>parent key</th><th>rows</th><th>error</th></tr></thead><tbody>` +
      (rec.invocations || [])
        .map(
          (i) =>
            `<tr><td>${escapeHtml(i.row_id)}</td><td>${escapeHtml(i.parent_record_key || "")}</td><td>${i.records_written ?? 0}</td><td>${escapeHtml(i.error || "")}</td></tr>`,
        )
        .join("") +
      `</tbody></table>`;
  }

  container.querySelector("#cancel").onclick = async () => {
    try { await api(`/v1/runs/${encodeURIComponent(id)}/cancel`, { method: "POST" }); toast("cancel requested"); load(); }
    catch (e) { toast(e.message, "error"); }
  };
  container.querySelector("#delete").onclick = async () => {
    try { await api(`/v1/runs/${encodeURIComponent(id)}`, { method: "DELETE" }); navigate("#/runs"); }
    catch (e) { toast(e.message, "error"); }
  };

  logCtrl = streamLogs(id, {
    onLog: (l) => appendLog(l),
    onTruncated: (m) => appendLog(` ${m} `, "log-truncated"),
    onEnd: () => appendLog("— end of logs —", "log-end"),
    onExpired: () => appendLog("— logs expired —", "log-end"),
    onError: (e) => appendLog(` log stream error: ${e.message} `, "log-truncated"),
  });

  await load();
  return () => {
    clearTimeout(pollTimer);
    logCtrl?.abort();
  };
}