SPB Git

spb/ka-guardian

Public
12.4 KB · 229 lines javascript
1/* KA Guardian — dashboard live (style Groupe KA) */2const $ = (s) => document.querySelector(s);3const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));4const fmtT = (ts) => new Date(ts * 1000).toLocaleTimeString("fr-CA", { hour12: false });5const fmtD = (ts) => new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" });6const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "réparation", watching: "surveillance",7  cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };8const STATUSES = ["ok", "degraded", "broken", "stale"];9const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel",10  effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement" };11const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",12  rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" };1314let STATE = null;1516async function load() {17  const r = await fetch("/api/state");18  STATE = await r.json();19  const id = STATE.identity, num = STATE.agent.replace("ka", "");20  // Thème par agent (bleu ka2 / orange ka4 / mauve ka6): l'accent de l'agent21  // remplace le lime du design system sur tout son site.22  const rs = document.documentElement.style;23  rs.setProperty("--agent", id.accent);24  rs.setProperty("--lime", id.accent);25  if (id.accent_soft) rs.setProperty("--lime-soft", id.accent_soft);26  if (id.accent2) rs.setProperty("--accent-deep", id.accent2);27  document.title = `${STATE.agent.toUpperCase()} Guardian — Groupe KA`;28  $("#wm-name").textContent = "ka·" + num;29  $("#foot-name").textContent = "ka·" + num;30  const apps = Object.values(STATE.services).map((s) => s.app);31  $("#hero-label").textContent = `${STATE.agent} — ${id.tagline} · veille sur ${apps.join(", ")}`;32  $("#screen-title").textContent = `${STATE.agent} — flux en temps réel`;33  $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) =>34    `<a class="gk-badge" href="https://${v.domain}"><span class="dot" style="background:${v.accent}"></span><b>${a}<span class="ka">·G</span></b></a>`).join(" ");35  $("#foot-sites").innerHTML =36    Object.values(STATE.services).map((s) => `<a href="${s.site}">${esc(s.app)}</a>`).join("") +37    Object.entries(STATE.siblings).map(([a, v]) => `<a href="https://${v.domain}">${a}·guardian</a>`).join("");38  renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm();39}4041/* ---- commander un effort ---- */42let effortInit = false;43function initEffortForm() {44  const svcSel = $("#ef-service");45  if (!effortInit) {46    svcSel.innerHTML = Object.entries(STATE.services)47      .map(([k, s]) => `<option value="${k}">${esc(s.app)} — ${esc(s.node_alias)}</option>`).join("");48    $("#ef-token").value = localStorage.getItem("ka_token") || "";49    $("#ef-kind").addEventListener("change", syncEffortKind);50    svcSel.addEventListener("change", fillSources);51    $("#effort-form").addEventListener("submit", submitEffort);52    effortInit = true;53  }54}55function syncEffortKind() {56  const enrich = $("#ef-kind").value === "effort_enrich";57  $("#ef-source-wrap").hidden = !enrich;58  if (enrich) fillSources();59}60async function fillSources() {61  if ($("#ef-kind").value !== "effort_enrich") return;62  const r = await fetch("/api/connectors/" + $("#ef-service").value);63  const d = await r.json();64  $("#ef-source").innerHTML = d.connectors65    .map((c) => `<option value="${esc(c.source)}">${esc(c.source)} (${STATUS_FR[c.status] || c.status})</option>`).join("")66    || `<option value="">— aucun connecteur connu —</option>`;67}68async function submitEffort(e) {69  e.preventDefault();70  const msg = $("#ef-msg"), btn = e.target.querySelector("button");71  const token = $("#ef-token").value.trim();72  if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; }73  localStorage.setItem("ka_token", token);74  btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…";75  try {76    const r = await fetch("/api/admin/effort", {77      method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token },78      body: JSON.stringify({79        kind: $("#ef-kind").value, service: $("#ef-service").value,80        source: $("#ef-source") ? $("#ef-source").value : "", note: $("#ef-note").value,81      }),82    });83    const d = await r.json();84    if (r.ok && d.ok) {85      msg.className = "cmd-msg ok";86      msg.textContent = `effort accepté (incident ${d.incident_id}) — la session démarre d'ici ~5 min, suis le flux ⚡`;87      $("#ef-note").value = "";88      refetch();89    } else {90      msg.className = "cmd-msg err";91      msg.textContent = "refusé: " + (d.detail || r.status);92    }93  } catch (err) {94    msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err;95  } finally { btn.disabled = false; }96}9798function renderTiles() {99  const s = STATE.stats, mine = STATE.latest.mine || {};100  let total = 0;101  for (const b of Object.values(mine)) for (const st of STATUSES) total += (b.summary || {})[st] || 0;102  const tiles = [103    [total || "—", "connecteurs sous garde", true],104    [s.n_active ?? 0, "incidents actifs"],105    [s.n_missions ?? 0, "missions lancées"],106    [s.n_resolved ?? 0, "réparations confirmées"],107    [s.n_rollbacks ?? 0, "rollbacks assumés"],108    [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"],109    [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"],110  ];111  $("#tiles").innerHTML = tiles.map(([v, l, hot]) =>112    `<div class="tile${hot ? " hot" : ""}"><div class="v">${esc(v)}</div><div class="l">${l}</div></div>`).join("");113}114115function renderIncidents() {116  const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12);117  const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5);118  const row = (i) => `<div class="inc">119    <div><div class="src">${esc(i.source)}</div><div class="svc">${esc(i.service)} · ${esc(STATUS_FR[i.status_detected] || i.status_detected)} · ${i.attempts} tentative${i.attempts > 1 ? "s" : ""}</div></div>120    <span class="badge b-${esc(i.state)}">${STATE_FR[i.state] || esc(i.state)}</span></div>`;121  $("#incidents").innerHTML = (active.length || recent.length)122    ? active.map(row).join("") + recent.map(row).join("")123    : `<div class="empty">Aucun incident — tous les connecteurs sous garde sont sains. Le gardien veille.</div>`;124}125126function renderCoverage() {127  const mine = STATE.latest.mine || {};128  $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => {129    const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {};130    const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0);131    const strip = STATUSES.filter((st) => sum[st] > 0)132      .map((st) => `<span class="s-${st}" style="flex:${sum[st]}" title="${STATUS_FR[st]}: ${sum[st]}"></span>`).join("");133    const counts = STATUSES.map((st) => `<span><i class="s-${st}"></i>${sum[st] || 0} ${STATUS_FR[st]}</span>`).join("");134    return `<div class="cov"><div class="head"><span class="app">${esc(meta.app)}</span>135      <span class="node">${esc(meta.node_alias)} · ${total || "?"} connecteurs</span></div>136      <div class="strip">${strip || "<span style='flex:1;background:var(--line)'></span>"}</div>137      <div class="counts">${counts}</div></div>`;138  }).join("");139}140141function renderMissions() {142  const vd = (m) => {143    if (m.state === "running") return `<span class="vbadge v-running">en cours…</span>`;144    if (m.state === "error") return `<span class="vbadge v-erreur">erreur</span>`;145    const v = (JSON.parse(m.verdict || "{}").verdict) || "inconnu";146    return `<span class="vbadge v-${esc(v)}">${VERDICT_FR[v] || esc(v)}</span>`;147  };148  $("#missions tbody").innerHTML = STATE.missions.map((m) => {149    const commits = JSON.parse(m.commits || "[]").length;150    const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "—";151    return `<tr data-id="${esc(m.id)}"><td class="mono">${fmtD(m.started)}</td>152      <td class="mono"><b>${esc(m.source)}</b></td><td>${esc((STATE.services[m.service] || {}).app || m.service)}</td>153      <td class="mono">${esc(m.node)}</td><td>${vd(m)}</td><td class="mono">${commits || "—"}</td>154      <td class="mono">${m.num_turns ? m.num_turns + " tours · " : ""}${dur}</td>155      <td class="mono">${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}</td></tr>`;156  }).join("") || `<tr><td colspan="8" class="empty" style="padding:18px">Aucune mission encore — la première panne détectée lancera le gardien.</td></tr>`;157}158159/* ---- flux en direct ---- */160const feed = $("#feed");161function feedLine(cls, tag, msg, ts) {162  if (feed.querySelector(".feed-empty")) feed.innerHTML = "";163  const el = document.createElement("div");164  el.className = "fe " + cls;165  el.innerHTML = `<span class="t">${fmtT(ts || Date.now() / 1000)}</span><span class="tag">${esc(tag)}</span><span class="m">${esc(msg)}</span>`;166  feed.appendChild(el);167  while (feed.children.length > 400) feed.removeChild(feed.firstChild);168  feed.scrollTop = feed.scrollHeight;169}170171let refetchTimer = null;172const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); };173174function connectSSE() {175  const es = new EventSource("/events");176  es.onopen = () => $("#live-dot").classList.add("on");177  es.onerror = () => $("#live-dot").classList.remove("on");178  es.onmessage = (e) => {179    const m = JSON.parse(e.data);180    if (m.kind === "mission_event") {181      const d = m.data || {};182      if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts);183      else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts);184      else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts);185      else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts);186      else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); }187      else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); }188      else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts);189    } else if (m.kind === "incident") {190      feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts);191      refetch();192    } else if (m.kind === "rollback") {193      feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts);194      refetch();195    } else if (m.kind === "mission") {196      feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts);197      refetch();198    } else if (m.kind === "snapshot") {199      if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); renderTiles(); }200    } else if (m.kind === "log") {201      feedLine("incident", m.level || "log", m.msg, m.ts);202    }203  };204}205206/* ---- modal mission ---- */207document.addEventListener("click", async (e) => {208  const tr = e.target.closest("#missions tbody tr[data-id]");209  if (tr) {210    const r = await fetch("/api/missions/" + tr.dataset.id);211    const { mission, events } = await r.json();212    const v = JSON.parse(mission.verdict || "{}");213    $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`;214    $("#modal-body").innerHTML =215      `<div class="meta"><b>verdict:</b> ${esc(VERDICT_FR[v.verdict] || v.verdict || mission.state)}\n<b>diagnostic:</b> ${esc(v.diagnostic || "")}\n<b>actions:</b> ${esc(v.actions || "")}\n<b>test:</b> ${esc(v.test || "")}\n<b>commit de base:</b> ${esc(mission.base_commit || "")}\n<b>commits:</b> ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}</div>` +216      events.map((ev) => {217        const d = JSON.parse(ev.data || "{}");218        const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text219          : ev.type === "result" ? d.result : JSON.stringify(d);220        return `<div class="meta"><b>${fmtT(ev.ts)} · ${esc(ev.type)}</b>\n${esc((txt || "").slice(0, 2500))}</div>`;221      }).join("");222    $("#modal").hidden = false;223  }224  if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true;225});226227load().then(connectSSE);228setInterval(load, 60000);229
Files · 39f4a080e3f1e0ef46de9023d45eb6076ef20d40
Menu

Theme