/* 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",
site_down: "site hors ligne",
effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement",
effort_degrade: "effort · inspection dégradés" };
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", "");
// Thème par agent (bleu ka2 / orange ka4 / mauve ka6): l'accent de l'agent
// remplace le lime du design system sur tout son site.
const rs = document.documentElement.style;
rs.setProperty("--agent", id.accent);
rs.setProperty("--lime", id.accent);
if (id.accent_soft) rs.setProperty("--lime-soft", id.accent_soft);
if (id.accent2) rs.setProperty("--accent-deep", id.accent2);
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();
}
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]) =>
`
`).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("");
// Nœud = registre mld (vivant). Runner = sonde registry-sync : sans runner sur le
// nœud, aucune mission ne peut y être dépêchée — on le dit plutôt que de le cacher.
const rn = meta.runner;
const runner = rn === null || rn === undefined ? ""
: rn.ok ? ` · runner ${rn.busy ? "occupé" : "prêt"}`
: ` · ⚠ sans runner`;
const src = meta.registry === "ok" ? "" : ` · repli`;
return `${esc(meta.app)}
${esc(meta.node_alias)}${runner}${src} · ${total || "?"} connecteurs
${strip || ""}
${counts}
`;
}).join("") + renderRegistryNote();
}
function renderRegistryNote() {
const r = STATE.registry;
if (!r) return "";
const when = r.updated ? r.updated.replace("T", " ") : "jamais reçu";
const age = r.file_age_s == null ? "" : ` · copie locale vieille de ${Math.round(r.file_age_s / 60)} min`;
const state = r.error ? `⚠ ${esc(r.error)}`
: r.stale ? `⚠ registre périmé (${esc(when)}${age})`
: `registre mld ${esc(when)} · ${r.n_apps} apps${age}`;
const last = (r.changes || []).slice(-3).reverse().map((c) => `${esc(c.msg)}
`).join("");
return `
emplacements : ${state}${last ? `
${last}
` : ""}
`;
}
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);