/* KA Guardian — dashboard live (style Groupe KA) */ const $ = (s) => document.querySelector(s); const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmtT = (ts) => new Date(ts * 1000).toLocaleTimeString("fr-CA", { hour12: false }); const fmtD = (ts) => new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" }); const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "réparation", watching: "surveillance", cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" }; const STATUSES = ["ok", "degraded", "broken", "stale"]; const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel", effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement" }; const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte", rien_a_faire: "rien à faire", inconnu: "inconnu", erreur: "erreur" }; let STATE = null; async function load() { const r = await fetch("/api/state"); STATE = await r.json(); const id = STATE.identity, num = STATE.agent.replace("ka", ""); document.documentElement.style.setProperty("--agent", id.accent); document.title = `${STATE.agent.toUpperCase()} Guardian — Groupe KA`; $("#wm-name").textContent = "ka·" + num; $("#foot-name").textContent = "ka·" + num; const apps = Object.values(STATE.services).map((s) => s.app); $("#hero-label").textContent = `${STATE.agent} — ${id.tagline} · veille sur ${apps.join(", ")}`; $("#screen-title").textContent = `${STATE.agent} — flux en temps réel`; $("#siblings").innerHTML = Object.entries(STATE.siblings).map(([a, v]) => `${a}·G`).join(" "); $("#foot-sites").innerHTML = Object.values(STATE.services).map((s) => `${esc(s.app)}`).join("") + Object.entries(STATE.siblings).map(([a, v]) => `${a}·guardian`).join(""); renderTiles(); renderIncidents(); renderCoverage(); renderMissions(); initEffortForm(); } /* ---- commander un effort ---- */ let effortInit = false; function initEffortForm() { const svcSel = $("#ef-service"); if (!effortInit) { svcSel.innerHTML = Object.entries(STATE.services) .map(([k, s]) => ``).join(""); $("#ef-token").value = localStorage.getItem("ka_token") || ""; $("#ef-kind").addEventListener("change", syncEffortKind); svcSel.addEventListener("change", fillSources); $("#effort-form").addEventListener("submit", submitEffort); effortInit = true; } } function syncEffortKind() { const enrich = $("#ef-kind").value === "effort_enrich"; $("#ef-source-wrap").hidden = !enrich; if (enrich) fillSources(); } async function fillSources() { if ($("#ef-kind").value !== "effort_enrich") return; const r = await fetch("/api/connectors/" + $("#ef-service").value); const d = await r.json(); $("#ef-source").innerHTML = d.connectors .map((c) => ``).join("") || ``; } async function submitEffort(e) { e.preventDefault(); const msg = $("#ef-msg"), btn = e.target.querySelector("button"); const token = $("#ef-token").value.trim(); if (!token) { msg.className = "cmd-msg err"; msg.textContent = "jeton d'opérateur requis"; return; } localStorage.setItem("ka_token", token); btn.disabled = true; msg.className = "cmd-msg"; msg.textContent = "lancement…"; try { const r = await fetch("/api/admin/effort", { method: "POST", headers: { "Content-Type": "application/json", "X-KA-Token": token }, body: JSON.stringify({ kind: $("#ef-kind").value, service: $("#ef-service").value, source: $("#ef-source") ? $("#ef-source").value : "", note: $("#ef-note").value, }), }); const d = await r.json(); if (r.ok && d.ok) { msg.className = "cmd-msg ok"; msg.textContent = `effort accepté (incident ${d.incident_id}) — la session démarre d'ici ~5 min, suis le flux ⚡`; $("#ef-note").value = ""; refetch(); } else { msg.className = "cmd-msg err"; msg.textContent = "refusé: " + (d.detail || r.status); } } catch (err) { msg.className = "cmd-msg err"; msg.textContent = "erreur: " + err; } finally { btn.disabled = false; } } function renderTiles() { const s = STATE.stats, mine = STATE.latest.mine || {}; let total = 0; for (const b of Object.values(mine)) for (const st of STATUSES) total += (b.summary || {})[st] || 0; const tiles = [ [total || "—", "connecteurs sous garde", true], [s.n_active ?? 0, "incidents actifs"], [s.n_missions ?? 0, "missions lancées"], [s.n_resolved ?? 0, "réparations confirmées"], [s.n_rollbacks ?? 0, "rollbacks assumés"], [s.mttr_h != null ? s.mttr_h + " h" : "—", "temps moyen de guérison"], [s.cost_total != null ? s.cost_total + " $" : "0 $", "coût API total"], ]; $("#tiles").innerHTML = tiles.map(([v, l, hot]) => `
${esc(v)}
${l}
`).join(""); } function renderIncidents() { const active = STATE.incidents.filter((i) => !["resolved", "self_healed"].includes(i.state)).slice(0, 12); const recent = STATE.incidents.filter((i) => ["resolved", "self_healed"].includes(i.state)).slice(0, 5); const row = (i) => `
${esc(i.source)}
${esc(i.service)} · ${esc(STATUS_FR[i.status_detected] || i.status_detected)} · ${i.attempts} tentative${i.attempts > 1 ? "s" : ""}
${STATE_FR[i.state] || esc(i.state)}
`; $("#incidents").innerHTML = (active.length || recent.length) ? active.map(row).join("") + recent.map(row).join("") : `
Aucun incident — tous les connecteurs sous garde sont sains. Le gardien veille.
`; } function renderCoverage() { const mine = STATE.latest.mine || {}; $("#coverage").innerHTML = Object.keys(STATE.services).map((svc) => { const meta = STATE.services[svc], sum = (mine[svc] || {}).summary || {}; const total = STATUSES.reduce((a, st) => a + (sum[st] || 0), 0); const strip = STATUSES.filter((st) => sum[st] > 0) .map((st) => ``).join(""); const counts = STATUSES.map((st) => `${sum[st] || 0} ${STATUS_FR[st]}`).join(""); return `
${esc(meta.app)} ${esc(meta.node_alias)} · ${total || "?"} connecteurs
${strip || ""}
${counts}
`; }).join(""); } function renderMissions() { const vd = (m) => { if (m.state === "running") return `en cours…`; if (m.state === "error") return `erreur`; const v = (JSON.parse(m.verdict || "{}").verdict) || "inconnu"; return `${VERDICT_FR[v] || esc(v)}`; }; $("#missions tbody").innerHTML = STATE.missions.map((m) => { const commits = JSON.parse(m.commits || "[]").length; const dur = m.ended ? Math.round((m.ended - m.started) / 60) + " min" : "—"; return `${fmtD(m.started)} ${esc(m.source)}${esc((STATE.services[m.service] || {}).app || m.service)} ${esc(m.node)}${vd(m)}${commits || "—"} ${m.num_turns ? m.num_turns + " tours · " : ""}${dur} ${m.cost_usd != null ? m.cost_usd.toFixed(2) + " $" : ""}`; }).join("") || `Aucune mission encore — la première panne détectée lancera le gardien.`; } /* ---- flux en direct ---- */ const feed = $("#feed"); function feedLine(cls, tag, msg, ts) { if (feed.querySelector(".feed-empty")) feed.innerHTML = ""; const el = document.createElement("div"); el.className = "fe " + cls; el.innerHTML = `${fmtT(ts || Date.now() / 1000)}${esc(tag)}${esc(msg)}`; feed.appendChild(el); while (feed.children.length > 400) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; } let refetchTimer = null; const refetch = () => { clearTimeout(refetchTimer); refetchTimer = setTimeout(load, 800); }; function connectSSE() { const es = new EventSource("/events"); es.onopen = () => $("#live-dot").classList.add("on"); es.onerror = () => $("#live-dot").classList.remove("on"); es.onmessage = (e) => { const m = JSON.parse(e.data); if (m.kind === "mission_event") { const d = m.data || {}; if (m.type === "start") feedLine("tool", "mission", `démarrage — commit de base ${(d.base_commit || "").slice(0, 8)} sur ${d.node || ""}`, m.ts); else if (m.type === "tool") feedLine("tool", d.name || "outil", d.input || "", m.ts); else if (m.type === "text") feedLine("text", "agent", d.text || "", m.ts); else if (m.type === "result") feedLine("text", "résultat", (d.result || "").slice(0, 600), m.ts); else if (m.type === "final") { feedLine("text", "verdict", JSON.stringify(d.verdict || {}), m.ts); refetch(); } else if (m.type === "error") { feedLine("rollback", "erreur", d.error || "", m.ts); refetch(); } else if (m.type === "rollback") feedLine("rollback", "rollback", JSON.stringify(d), m.ts); } else if (m.kind === "incident") { feedLine("incident", "incident", `${m.service}/${m.source} → ${STATE_FR[m.state] || m.state}${m.note ? " — " + m.note : ""}`, m.ts); refetch(); } else if (m.kind === "rollback") { feedLine("rollback", "rollback", `${m.service}/${m.source} — ${m.reason} (${(m.result || {}).mode || "?"})`, m.ts); refetch(); } else if (m.kind === "mission") { feedLine("tool", "mission", `nouvelle mission sur ${m.service}/${m.source}`, m.ts); refetch(); } else if (m.kind === "snapshot") { if (STATE) { STATE.latest.mine = Object.fromEntries(Object.entries(m.mine).map(([s, sum]) => [s, { summary: sum }])); renderCoverage(); renderTiles(); } } else if (m.kind === "log") { feedLine("incident", m.level || "log", m.msg, m.ts); } }; } /* ---- modal mission ---- */ document.addEventListener("click", async (e) => { const tr = e.target.closest("#missions tbody tr[data-id]"); if (tr) { const r = await fetch("/api/missions/" + tr.dataset.id); const { mission, events } = await r.json(); const v = JSON.parse(mission.verdict || "{}"); $("#modal-title").textContent = `Mission ${mission.id} — ${mission.source} (${mission.service})`; $("#modal-body").innerHTML = `
verdict: ${esc(VERDICT_FR[v.verdict] || v.verdict || mission.state)}\ndiagnostic: ${esc(v.diagnostic || "")}\nactions: ${esc(v.actions || "")}\ntest: ${esc(v.test || "")}\ncommit de base: ${esc(mission.base_commit || "")}\ncommits: ${esc((JSON.parse(mission.commits || "[]")).join(" | ") || "aucun")}
` + events.map((ev) => { const d = JSON.parse(ev.data || "{}"); const txt = ev.type === "tool" ? `${d.name}: ${d.input}` : ev.type === "text" ? d.text : ev.type === "result" ? d.result : JSON.stringify(d); return `
${fmtT(ev.ts)} · ${esc(ev.type)}\n${esc((txt || "").slice(0, 2500))}
`; }).join(""); $("#modal").hidden = false; } if (e.target.id === "modal-close" || e.target.id === "modal") $("#modal").hidden = true; }); load().then(connectSSE); setInterval(load, 60000);