SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%
114.4 KB · 2,057 lines javascript
Raw Blame History
1/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */2(() => {3  const $ = (id) => document.getElementById(id);4  const messagesEl = $('messages');5  const inputEl = $('input');6  const sendBtn = $('sendBtn');7  const stopBtn = $('stopBtn');8  const projectSel = $('projectSel');910  marked.setOptions({ breaks: true, mangle: false, headerIds: false });1112  // ---------- état ----------13  const store = JSON.parse(localStorage.getItem('adminka') || '{}');14  const settings = {15    model: store.model || 'fable',16    permMode: store.permMode || 'default',17    projectId: store.projectId || null,18  };19  function saveStore() { localStorage.setItem('adminka', JSON.stringify(settings)); }2021  let ws = null, wsRetry = 1000, wsWanted = true;22  let currentChat = null;      // publicChat du serveur23  let isRunning = false;24  let live = null, liveThink = null;25  let curView = 'chat';26  let ecoData = null;27  const toolCards = new Map();28  const permCards = new Map();2930  const MODEL_LABEL = { fable: 'Fable 5', opus: 'Opus 5', sonnet: 'Sonnet 5', haiku: 'Haiku' };31  const PERM_LABEL = { default: 'normal', acceptEdits: 'acceptEdits', plan: 'plan', bypass: '⚠ bypass' };3233  // ---------- utilitaires ----------34  function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }35  function el(tag, cls, html) {36    const e = document.createElement(tag);37    if (cls) e.className = cls;38    if (html !== undefined) e.innerHTML = html;39    return e;40  }41  function fmt$(v) { return (v || 0) < 0.005 ? '<0,01 $' : (v).toFixed(2).replace('.', ',') + ' $'; }42  function fmtTok(v) { return v > 1000 ? Math.round(v / 1000) + 'k' : String(v || 0); }43  function timeAgo(ts) {44    if (!ts) return '—';45    const s = (Date.now() - ts) / 1000;46    if (s < 90) return 'à l’instant';47    if (s < 3600) return Math.round(s / 60) + ' min';48    if (s < 86400) return Math.round(s / 3600) + ' h';49    return Math.round(s / 86400) + ' j';50  }51  function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; }5253  // ---------- navigation (sidebar desktop + bottom nav mobile) ----------54  const I = {55    home: '<svg viewBox="0 0 24 24"><path d="M4 11l8-7 8 7v9a1.5 1.5 0 01-1.5 1.5H15v-6h-6v6H5.5A1.5 1.5 0 014 20z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',56    chat: '<svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',57    sessions: '<svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',58    prompts: '<svg viewBox="0 0 24 24"><path d="M7 3h10a2 2 0 012 2v16l-7-4-7 4V5a2 2 0 012-2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg>',59    eco: '<svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>',60    visitors: '<svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M3.5 19a5.5 5.5 0 0111 0" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/><path d="M16 5.2a3 3 0 010 5.6M18 19a5.5 5.5 0 00-3-4.9" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>',61    social: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H4z M8 20h8" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 8l5 2.5-5 2.5z" fill="currentColor"/></svg>',62    settings: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>',63    more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.8" fill="currentColor"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/><circle cx="19" cy="12" r="1.8" fill="currentColor"/></svg>',64  };65  const NAV = [66    { id: 'overview', label: 'Vue d’ensemble', icon: I.home },67    { id: 'chat', label: 'Claude Code', icon: I.chat },68    { id: 'sessions', label: 'Sessions', icon: I.sessions },69    { id: 'prompts', label: 'Prompts', icon: I.prompts },70    { id: 'eco', label: 'Écosystème', icon: I.eco },71    { id: 'visitors', label: 'Visiteurs', icon: I.visitors },72    { id: 'social', label: 'Studio', icon: I.social },73    { id: 'settings', label: 'Réglages', icon: I.settings },74  ];75  const views = NAV.map(n => n.id);76  const TITLES = Object.fromEntries(NAV.map(n => [n.id, n.label]));77  function navBtnHTML(n) {78    return `<button data-view="${n.id}" class="nav-btn">${n.icon}<span>${n.label}</span><i class="badge hidden"></i></button>`;79  }80  $('sideNav').innerHTML = NAV.map(navBtnHTML).join('');81  const MOBILE_NAV = ['overview', 'chat', 'eco', 'visitors'];82  $('bottomNav').innerHTML = NAV.filter(n => MOBILE_NAV.includes(n.id)).map(navBtnHTML).join('')83    + `<button class="nav-btn" id="navMore">${I.more}<span>Plus</span><i class="badge hidden"></i></button>`;84  $('navMore').onclick = () => {85    openSheet(`<div class="sheet-h"><h3>Navigation</h3></div><div class="more-nav">${86      NAV.filter(n => !MOBILE_NAV.includes(n.id)).map(n => `<button class="more-btn" data-view="${n.id}">${n.icon}<span>${n.label}</span></button>`).join('')87    }</div>`);88    document.querySelectorAll('.more-btn').forEach(b => { b.onclick = () => { closeSheet(); switchView(b.dataset.view); }; });89  };9091  function switchView(name) {92    curView = name;93    for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);94    const pt = $('pageTitle'); if (pt) pt.textContent = TITLES[name] || name;95    document.querySelectorAll('.nav-btn').forEach((b) => {96      b.classList.toggle('active', b.dataset.view === name);97      if (b.dataset.view === name) { const bd = b.querySelector('.badge'); if (bd) bd.classList.add('hidden'); }98    });99    if (name === 'overview' && window.startOverview) window.startOverview();100    if (name === 'sessions') loadSessions();101    if (name === 'prompts') loadPrompts();102    if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }103    if (name === 'social' && window.__loadGallery) window.__loadGallery();104    if (name === 'visitors' && window.startVisitors) window.startVisitors();105    if (name === 'settings') { refreshSettingsView(); if (window.loadAnCfg) window.loadAnCfg(); }106  }107  document.addEventListener('click', (e) => {108    const nb = e.target.closest('.nav-btn[data-view]');109    if (nb) { switchView(nb.dataset.view); return; }110    const gl = e.target.closest('[data-goto]');111    if (gl) switchView(gl.dataset.goto);112  });113114  // ---------- thème (auto / clair / sombre) ----------115  function applyTheme() {116    const t = localStorage.getItem('ka_theme') || 'auto';117    const dark = t === 'dark' || (t === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches);118    document.documentElement.dataset.theme = dark ? 'dark' : 'light';119    const meta = document.querySelector('meta[name="theme-color"]');120    if (meta) meta.content = dark ? '#16130e' : '#F2F1EC';121    document.querySelectorAll('#segTheme button').forEach(b => b.classList.toggle('on', b.dataset.v === t));122  }123  document.querySelectorAll('#segTheme button').forEach(b => { b.onclick = () => { localStorage.setItem('ka_theme', b.dataset.v); applyTheme(); }; });124  $('themeBtn').onclick = () => {125    const cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';126    localStorage.setItem('ka_theme', cur === 'dark' ? 'light' : 'dark');127    applyTheme();128  };129  matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);130  applyTheme();131132// ===== VISITEURS v2 — trafic humain crédible =====133(function () {134  const $ = (id) => document.getElementById(id);135  let timer = null, mode = 'human', lastSummary = null;136  const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');137  const DEV_LABEL = { m: '📱 mobile', t: '💻 tablette', d: '🖥️ desktop', '?': '—' };138  const CLS_LABEL = { human: 'Humains', crawler: 'Crawlers', datacenter: 'Datacenter', automation: 'Automation', internal: 'Interne' };139  const CLS_COLOR = { human: 'var(--good)', crawler: '#6a9fd8', datacenter: 'var(--warn)', automation: '#a08cc9', internal: 'var(--muted)' };140141  function delta(cur, prev) {142    if (!prev) return '';143    const d = Math.round((cur - prev) / prev * 100);144    if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';145    return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;146  }147  function kpi(v, l, extra, accent) {148    return `<div class="kpi2${accent ? ' accent' : ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;149  }150  function renderGlobal(d) {151    const g = d.global || {};152    $('visLive').innerHTML = '<span class="vis-dot on"></span> ' + nf(g.online) + ' humain' + (g.online > 1 ? 's' : '') + ' en ligne';153    const all = mode === 'all';154    const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);155    $('visGlobal').innerHTML =156      kpi(nf(g.online), 'en ligne (actifs < 2 min)', '', true)157      + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))158      + kpi(nf(g.sessions), 'sessions')159      + kpi(nf(g.pv), 'pages vues')160      + kpi(nf(g.d7), '7 jours')161      + kpi(nf(g.d30), '30 jours')162      + (all ? kpi(nf(g.total_vids), 'visiteurs (toutes classes)') + kpi(nf(autoHits), 'hits automatisés') : '');163    // barre de répartition des classes (événements du jour)164    const seg = [['human', g.humanHits], ['crawler', g.crawler], ['datacenter', g.datacenter], ['automation', g.automation], ['internal', g.internal]]165      .map(([k, v]) => [k, v || 0]);166    const tot = seg.reduce((a, [, v]) => a + v, 0) || 1;167    $('visBreak').innerHTML = `168      <div class="break-bar">${seg.map(([k, v]) => v ? `<i style="width:${(v / tot * 100).toFixed(2)}%;background:${CLS_COLOR[k]}" title="${CLS_LABEL[k]} : ${nf(v)}"></i>` : '').join('')}</div>169      <div class="legend">${seg.map(([k, v]) => `<span><i class="sw" style="background:${CLS_COLOR[k]}"></i>${CLS_LABEL[k]} ${nf(v)}</span>`).join('')}170      <span class="panel-note">événements du jour — les métriques principales n’affichent que les humains</span></div>`;171  }172173  function siteCard(s) {174    const all = mode === 'all';175    const autoHits = (s.crawler || 0) + (s.datacenter || 0) + (s.automation || 0) + (s.internal || 0);176    return `<div class="vis-card" data-site="${s.site}" data-label="${s.label || s.site}">177      <div class="vis-h"><img src="/icons/${s.site}" onerror="this.style.display='none'"><div class="vis-name">${s.label || s.site}</div>178        <span class="vis-on-pill${s.online > 0 ? ' on' : ''}">${nf(s.online)} en ligne</span></div>179      <div class="vis-row">180        <div class="vis-m"><div class="vm-n">${nf(s.today)}</div><div class="vm-l">humains</div></div>181        <div class="vis-m"><div class="vm-n">${nf(s.sessions)}</div><div class="vm-l">sessions</div></div>182        <div class="vis-m"><div class="vm-n">${nf(s.pv)}</div><div class="vm-l">pages</div></div>183        <div class="vis-m"><div class="vm-n">${nf(s.d7)}</div><div class="vm-l">7 j</div></div>184      </div>185      ${all ? `<div class="vis-pv">🤖 ${nf(autoHits)} hits automatisés (crawler ${nf(s.crawler)} · dc ${nf(s.datacenter)} · autom. ${nf(s.automation)} · interne ${nf(s.internal)})</div>`186            : `<div class="vis-pv">${delta(s.today, s.yesterdaySame) || '&nbsp;'}<span class="vis-more">détail →</span></div>`}187    </div>`;188  }189190  async function loadVisitors() {191    try {192      const d = await (await fetch('/api/analytics/summary')).json();193      lastSummary = d;194      renderGlobal(d);195      $('visGrid').innerHTML = (d.sites || []).map(siteCard).join('');196      $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); });197    } catch {}198    loadRealtime(); loadAnoms();199  }200201  async function loadRealtime() {202    try {203      const d = await (await fetch('/api/analytics/realtime')).json();204      $('rtCount').textContent = d.active.length + ' actif' + (d.active.length > 1 ? 's' : '') + ' · fenêtre ' + d.activeSec + ' s';205      if (!d.active.length) { $('visRealtime').innerHTML = '<div class="soc-sub">Personne en ce moment — le compteur expire tout seul (~2 min).</div>'; return; }206      $('visRealtime').innerHTML = d.active.slice(0, 30).map(a => {207        const dur = Math.max(0, Math.round((Date.now() - (a.since || a.lastTs)) / 60000));208        return `<div class="rt-row">209          <img src="/icons/${a.site}" onerror="this.style.display='none'">210          <span class="rt-site">${a.label}</span>211          <span class="rt-path mono" title="${esc(a.path || '/')}">${esc((a.path || '/').slice(0, 42))}</span>212          <span class="rt-loc">${esc(a.loc)}${a.cc && a.cc !== 'CA' && a.cc !== 'XX' ? ' · ' + a.cc : ''}</span>213          <span class="rt-dev">${DEV_LABEL[a.dev] || ''}</span>214          <span class="rt-dur mono">${dur < 1 ? '<1 min' : dur + ' min'}</span>215        </div>`;216      }).join('');217    } catch {}218  }219220  async function loadAnoms() {221    try {222      const d = await (await fetch('/api/analytics/anomalies')).json();223      const bits = [];224      if (d.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(d.datacenter.lastHour)} hits/h (moy. ${nf(d.datacenter.hourlyAvg)})</div>`);225      for (const s of d.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(s.site)}</b> : ${nf(s.lastHour)} evt/h (×${s.factor})</div>`);226      for (const ip of (d.topIps || []).slice(0, 5)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} · classe <b>${esc(ip.cls)}</b> <button class="linklike" data-ip="${esc(ip.ip)}">inspecter</button></div>`);227      $('visAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler — trafic dans les normes.</div>';228      $('visAnom').querySelectorAll('[data-ip]').forEach(b => { b.onclick = () => inspect(b.dataset.ip); });229    } catch {}230  }231232  async function inspect(q) {233    const val = q || prompt('IP ou visitor-id à inspecter :');234    if (!val) return;235    const isIp = /^[\d.:a-f]+$/i.test(val) && val.includes('.') || val.includes(':');236    const r = await fetch('/api/analytics/inspect?' + (isIp ? 'ip=' : 'vid=') + encodeURIComponent(val));237    const d = await r.json();238    const ipi = d.ipinfo;239    openSheet(`<div class="sheet-h"><h3>🔬 Inspecteur de trafic</h3></div>240      <div class="sheet-sec">241        <div class="kv"><span>Classe stockée</span><b>${esc(d.storedClass || '—')}</b></div>242        <div class="kv"><span>IP</span><b>${esc(d.ip || '—')}</b></div>243        ${ipi ? `<div class="kv"><span>Réseau</span><b>AS${ipi.asn} · ${esc(ipi.org || ipi.isp || '?')}</b></div>244        <div class="kv"><span>Lieu</span><b>${esc([ipi.city, ipi.region, ipi.country].filter(Boolean).join(', ') || '—')}</b></div>245        <div class="kv"><span>Drapeaux</span><b>${ipi.hosting ? 'hosting ' : ''}${ipi.proxy ? 'proxy ' : ''}${ipi.mobile ? 'mobile ' : ''}${!ipi.hosting && !ipi.proxy ? 'résidentiel' : ''}</b></div>` : '<div class="soc-sub">IP pas encore résolue (file ip-api)</div>'}246      </div>247      <div class="sheet-sec"><h4>Raisons de la classification</h4>248        ${(d.reasons || []).map(x => `<div class="inc-item"><span>·</span><span>${esc(x)}</span></div>`).join('')}249      </div>250      <div class="sheet-sec"><h4>Événements récents (${(d.events || []).length})</h4>251        ${(d.events || []).slice(0, 20).map(e2 => `<div class="inc-item mono" style="font-size:11px"><span>${e2.ev}</span><span>${new Date(e2.ts).toLocaleTimeString('fr-CA')} · ${esc(e2.site)} · ${esc(e2.path)} · ${esc(e2.cls)}</span></div>`).join('') || '<div class="soc-sub">aucun</div>'}252      </div>`);253  }254  $('visDebugBtn').onclick = () => inspect(null);255256  document.querySelectorAll('#visMode button').forEach(b => {257    b.onclick = () => {258      mode = b.dataset.v;259      document.querySelectorAll('#visMode button').forEach(x => x.classList.toggle('on', x === b));260      if (lastSummary) { renderGlobal(lastSummary); $('visGrid').innerHTML = (lastSummary.sites || []).map(siteCard).join(''); $('visGrid').querySelectorAll('.vis-card').forEach(c => { c.onclick = () => openDetail(c.dataset.site, c.dataset.label); }); }261    };262  });263264  // graphique série 30 j : humains (trait plein) + héritage v1 (pointillé gris)265  function chart(series, mig) {266    const w = 680, h = 220, pl = 42, pb = 26, pt = 10;267    const n = series.length; if (n < 2) return '<div class="soc-sub">Pas encore assez de données.</div>';268    const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));269    const X = i => pl + (w - pl - 10) * (i / (n - 1));270    const Y = v => h - pb - (h - pb - pt) * (v / max);271    let dH = '', dL = '', pen = false;272    series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });273    series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });274    const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;275    let lab = '';276    for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 8}" font-size="11" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;277    const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');278    const migDay = mig ? new Date(mig).toISOString().slice(0, 10) : null;279    const migIdx = migDay ? series.findIndex(x => x.day >= migDay) : -1;280    const migLine = migIdx > 0 ? `<line x1="${X(migIdx)}" y1="${pt}" x2="${X(migIdx)}" y2="${h - pb}" stroke="var(--muted)" stroke-dasharray="3 4"/><text x="${X(migIdx) + 4}" y="${pt + 10}" font-size="9" fill="var(--muted)">migration v2</text>` : '';281    return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">282      ${grid}${migLine}283      <path d="${area}" fill="var(--orange-deep)" opacity="0.12"/>284      <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.6" stroke-dasharray="4 4"/>285      <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>286      ${lab}287    </svg>288    <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage (non filtré)</span></div>`;289  }290291  async function openDetail(site, label) {292    openSheet(`<div class="sheet-h"><img class="site-logo big" src="/icons/${site}" onerror="this.style.display='none'"><h3>${esc(label)}</h3></div><div id="vdBody"><div class="soc-sub">Chargement…</div></div>`);293    try {294      const [sm, se, det] = await Promise.all([295        lastSummary ? Promise.resolve(lastSummary) : (await fetch('/api/analytics/summary')).json(),296        (await fetch('/api/analytics/series/' + site)).json(),297        (await fetch('/api/analytics/site/' + site + '?days=7')).json(),298      ]);299      const s = (sm.sites || []).find(x => x.site === site) || {};300      const bar = (rows, kL, kV, unit) => {301        const mx = Math.max(1, ...rows.map(r => r[kV]));302        return rows.map(r => `<div class="vd-geo"><span class="vd-geoL" title="${esc(String(r[kL]))}">${esc(String(r[kL]).slice(0, 42))}</span><div class="vd-bar"><div style="width:${Math.round(100 * r[kV] / mx)}%"></div></div><span class="vd-geoN">${nf(r[kV])}${unit || ''}</span></div>`).join('') || '<div class="soc-sub">rien encore</div>';303      };304      const devTot = det.devs.reduce((a, x) => a + x.uv, 0) || 1;305      $('vdBody').innerHTML = `306        <div class="sheet-sec">307          <div class="vd-kpis">308            <div class="vd-k"><div class="vd-kn">${nf(s.online)}</div><div class="vd-kl">en ligne</div></div>309            <div class="vd-k"><div class="vd-kn">${nf(s.today)}</div><div class="vd-kl">humains auj.</div></div>310            <div class="vd-k"><div class="vd-kn">${nf(s.sessions)}</div><div class="vd-kl">sessions</div></div>311            <div class="vd-k"><div class="vd-kn">${nf(s.pv)}</div><div class="vd-kl">pages vues</div></div>312          </div>313          <div class="vd-kpis" style="margin-top:8px">314            <div class="vd-k"><div class="vd-kn">${nf(det.newToday)}</div><div class="vd-kl">nouveaux</div></div>315            <div class="vd-k"><div class="vd-kn">${nf(det.returningToday)}</div><div class="vd-kl">récurrents</div></div>316            <div class="vd-k"><div class="vd-kn">${det.pagesPerSession || 0}</div><div class="vd-kl">pages/session</div></div>317            <div class="vd-k"><div class="vd-kn">${det.avgSessionSec >= 60 ? Math.round(det.avgSessionSec / 60) + ' min' : (det.avgSessionSec || 0) + ' s'}</div><div class="vd-kl">durée moy.</div></div>318          </div>319        </div>320        <div class="sheet-sec"><h4>Visiteurs humains — 30 jours</h4>${chart(se.series || [], se.migratedAt)}</div>321        <div class="sheet-sec"><h4>Pages populaires — 7 j (humains)</h4>${bar(det.pages, 'path', 'pv')}</div>322        <div class="sheet-sec"><h4>Sources / referrers — 7 j</h4>${det.refs.length ? bar(det.refs.map(r => ({ ...r, ref: r.ref.replace(/^https?:\/\//, '') })), 'ref', 'uv') : '<div class="soc-sub">rien encore (referrer fourni par le beacon v2)</div>'}</div>323        <div class="sheet-sec"><h4>Appareils</h4>${bar(det.devs.map(x => ({ l: (DEV_LABEL[x.dev] || 'inconnu (beacon v1)') + ' · ' + Math.round(x.uv / devTot * 100) + ' %', uv: x.uv })), 'l', 'uv')}</div>324        <div class="sheet-sec"><h4>D'où viennent les visiteurs — 7 j</h4>325          ${bar(det.geo.map(x => ({ l: x.city + (x.region ? ', ' + (x.cc === 'CA' ? x.region : x.country) : ''), uv: x.uv })), 'l', 'uv')}326          ${det.countries.length > 1 ? '<h4 style="margin-top:12px">Pays</h4>' + bar(det.countries.map(x => ({ l: x.country, uv: x.uv })), 'l', 'uv') : ''}327        </div>328        <div class="sheet-sec"><h4>Trafic filtré — 7 j</h4>329          ${bar((det.classes || []).filter(c => c.cls !== 'human').map(c => ({ l: (CLS_LABEL[c.cls] || c.cls) + ' (' + nf(c.uv) + ' ids)', n: c.n })), 'l', 'n', ' hits')}330        </div>`;331    } catch (e) { $('vdBody').innerHTML = '<div class="soc-sub">Erreur : ' + esc(e.message) + '</div>'; }332  }333334  window.startVisitors = function () {335    loadVisitors();336    clearInterval(timer);337    timer = setInterval(() => { if (curView === 'visitors' && !document.hidden) { loadVisitors(); } }, 12000);338  };339})();340341// ===== VUE D'ENSEMBLE =====342(function () {343  const $ = (id) => document.getElementById(id);344  let timer = null;345  const nf = (n) => (n || 0).toLocaleString('fr-CA').replace(/,/g, ' ');346  const fmt$$ = (v) => (v || 0).toFixed(2).replace('.', ',') + ' $';347  function kpi(v, l, extra, cls) {348    return `<div class="kpi2 ${cls || ''}"><div class="kv2">${v}</div><div class="kl2">${l}</div>${extra || ''}</div>`;349  }350  function delta(cur, prev) {351    if (!prev) return '';352    const d = Math.round((cur - prev) / prev * 100);353    if (!isFinite(d) || Math.abs(d) < 1) return '<span class="kdelta flat">≈ hier</span>';354    return `<span class="kdelta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '+' : ''}${d}&nbsp;% vs hier</span>`;355  }356  async function load() {357    try {358      const [an, eco, chats, anom, series] = await Promise.all([359        (await fetch('/api/analytics/summary')).json(),360        (await fetch('/api/eco/summary')).json(),361        (await fetch('/api/chats')).json(),362        (await fetch('/api/analytics/anomalies')).json(),363        (await fetch('/api/analytics/series/_all')).json(),364      ]);365      const g = an.global || {}, eg = eco.global || {};366      const running = (chats.chats || []).filter(c => c.state !== 'idle');367      const cost = (chats.chats || []).reduce((a, c) => a + (c.totalCost || 0), 0);368      $('ovStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });369      const ts = $('topStamp'); if (ts) ts.textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });370      $('ovKpis').innerHTML =371        kpi(nf(g.online), 'humains en ligne', '', 'accent')372        + kpi(nf(g.today), 'humains aujourd’hui', delta(g.today, g.yesterdaySame))373        + kpi(nf(g.sessions), 'sessions')374        + kpi(nf(g.pv), 'pages vues')375        + kpi(`${eg.up}<small>/${eg.total}</small>`, 'sites en ligne', '', eg.down ? 'bad' : 'good')376        + kpi(eg.openIncidents || 0, 'incidents ouverts', '', eg.openIncidents ? 'bad' : '')377        + kpi((eg.uptime24 ?? '—') + '<small>%</small>', 'uptime 24 h')378        + kpi(eg.avgLatency + '<small> ms</small>', 'latence moy.')379        + kpi(running.length, 'sessions Claude actives', '', running.length ? 'accent' : '')380        + kpi(fmt$$(cost), 'coût Claude cumulé');381      // graphique 30 j global382      const s = series.series || [];383      $('ovChart').innerHTML = ovChart(s, series.migratedAt);384      const autoHits = (g.crawler || 0) + (g.datacenter || 0) + (g.automation || 0) + (g.internal || 0);385      $('ovBreak').innerHTML = `<div class="panel-note" style="margin-top:8px">Aujourd’hui : <b>${nf(g.today)}</b> humains · trafic filtré : ${nf(g.crawler)} crawler + ${nf(g.datacenter)} datacenter + ${nf(g.automation)} automation + ${nf(g.internal)} interne (${nf(autoHits)} hits exclus des métriques)</div>`;386      // écosystème compact387      const order = { down: 0, slow: 1, unknown: 2, up: 3 };388      const stt = (x) => (x.incident || (x.last && !x.last.ok)) ? 'down' : (x.last && x.last.ms > 4000) ? 'slow' : x.last ? 'up' : 'unknown';389      $('ovEco').innerHTML = '<div class="ov-eco-chips">' + [...eco.sites].sort((a, b) => order[stt(a)] - order[stt(b)]).map(x => {390        const st = stt(x);391        return `<span class="eco-chip ${st}" data-goto="eco" title="${x.label} — ${x.last?.ok ? x.last.ms + ' ms' : 'hors ligne'}">${st === 'up' ? '●' : st === 'slow' ? '▲' : '■'} ${x.label}<b>${x.last?.ok ? x.last.ms + 'ms' : 'OFF'}</b></span>`;392      }).join('') + '</div>';393      // incidents récents394      const inc = await (await fetch('/api/eco/incidents')).json();395      $('ovInc').innerHTML = (inc.incidents || []).slice(0, 5).map(i => `396        <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>397        <span><b>${esc(i.site)}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'}</span></div>`).join('')398        || '<div class="soc-sub">Aucun incident récent 🎉</div>';399      // Claude Code400      $('ovClaude').innerHTML = (chats.chats || []).slice(0, 5).map(c => `401        <div class="ov-sess" data-chat="${c.id}">402          <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>403          <span class="ov-sess-t">${esc(c.title)}</span>404          <span class="ov-sess-m mono">${esc(c.projectName || '')} · ${fmt$$(c.totalCost)}</span>405        </div>`).join('') || '<div class="soc-sub">Aucune session encore.</div>';406      $('ovClaude').querySelectorAll('[data-chat]').forEach(x => { x.onclick = () => openChat(x.dataset.chat); });407      // anomalies408      const bits = [];409      if (anom.datacenter?.surge) bits.push(`<div class="anom warn">▲ Poussée datacenter : ${nf(anom.datacenter.lastHour)} hits/h</div>`);410      for (const sp of anom.spikes || []) bits.push(`<div class="anom warn">▲ Pic sur <b>${esc(sp.site)}</b> : ×${sp.factor}</div>`);411      for (const ip of (anom.topIps || []).slice(0, 3)) bits.push(`<div class="anom"><span class="mono">${esc(ip.ip)}</span> · ${nf(ip.n)} evt/h · ${esc(ip.org || '?')} → <b>${esc(ip.cls)}</b></div>`);412      $('ovAnom').innerHTML = bits.length ? bits.join('') : '<div class="soc-sub">● Rien à signaler.</div>';413    } catch {}414  }415  function ovChart(series, mig) {416    const w = 760, h = 200, pl = 44, pb = 24, pt = 8;417    const n = series.length; if (n < 2) return '<div class="soc-sub">Les données humaines s’accumulent — revenez dans quelques heures.</div>';418    const max = Math.max(1, ...series.flatMap(x => [x.humans || 0, x.legacy || 0]));419    const X = i => pl + (w - pl - 10) * (i / (n - 1));420    const Y = v => h - pb - (h - pb - pt) * (v / max);421    let dH = '', dL = '', pen = false;422    series.forEach((x, i) => { dH += (i ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.humans || 0).toFixed(1); });423    series.forEach((x, i) => { if (x.legacy == null) { pen = false; return; } dL += (pen ? 'L' : 'M') + X(i).toFixed(1) + ' ' + Y(x.legacy).toFixed(1); pen = true; });424    const area = `M${X(0)} ${h - pb} ${dH.replace(/^M/, 'L')} L${X(n - 1)} ${h - pb} Z`;425    let lab = '';426    for (let i = 0; i < n; i += Math.ceil(n / 6)) lab += `<text x="${X(i)}" y="${h - 6}" font-size="10" fill="var(--muted)" text-anchor="middle" font-family="var(--mono)">${series[i].day.slice(5)}</text>`;427    const grid = [0.5, 1].map(f => `<line x1="${pl}" y1="${Y(max * f)}" x2="${w - 10}" y2="${Y(max * f)}" stroke="var(--grid)"/><text x="${pl - 6}" y="${Y(max * f) + 4}" font-size="10" fill="var(--muted)" text-anchor="end" font-family="var(--mono)">${nf(Math.round(max * f))}</text>`).join('');428    return `<svg viewBox="0 0 ${w} ${h}" class="vis-chart">${grid}429      <path d="${area}" fill="var(--orange-deep)" opacity="0.10"/>430      <path d="${dL}" fill="none" stroke="var(--muted)" stroke-width="1.5" stroke-dasharray="4 4"/>431      <path d="${dH}" fill="none" stroke="var(--orange-deep)" stroke-width="2.4" stroke-linejoin="round"/>${lab}</svg>432      <div class="legend"><span><i class="sw" style="background:var(--orange-deep)"></i>humains (v2, filtré)</span><span><i class="sw" style="background:var(--muted)"></i>ancien comptage v1 (non filtré)</span></div>`;433  }434  window.startOverview = function () {435    load();436    clearInterval(timer);437    timer = setInterval(() => { if (curView === 'overview' && !document.hidden) load(); }, 30000);438  };439})();440441442443444  // ---------- Écran du nœud (noVNC embarqué) ----------445  let _rfb = null;446  (function bindVNC(){447    const connect = document.getElementById('vncConnect');448    const disc = document.getElementById('vncDisconnect');449    const statusEl = document.getElementById('vncStatus');450    const panel = document.getElementById('vncPanel');451    if (!connect) return;452    connect.onclick = async () => {453      if (_rfb) { statusEl.textContent = 'déjà connecté'; return; }454      statusEl.textContent = 'connexion…';455      try {456        const pw = await (await fetch('/api/social/vncpw')).json();457        const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; }458        const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws';459        panel.style.display = 'block';460        _rfb = new RFB(panel, url, { credentials: { password: pw.password } });461        _rfb.scaleViewport = true;462        _rfb.resizeSession = false;463        _rfb.background = '#0d0d0d';464        _rfb.addEventListener('connect', () => { statusEl.textContent = '✅ connecté — tu peux cliquer dans l\'écran'; });465        _rfb.addEventListener('disconnect', (e) => { statusEl.textContent = 'déconnecté'; _rfb = null; panel.style.display='none'; });466        _rfb.addEventListener('credentialsrequired', () => { _rfb.sendCredentials({ password: pw.password }); });467      } catch (e) { statusEl.textContent = 'échec : ' + e.message; }468    };469    disc.onclick = () => { if (_rfb) { try { _rfb.disconnect(); } catch {} _rfb = null; } panel.style.display='none'; statusEl.textContent='déconnecté'; };470  })();471472  // ---------- Social ----------473  let socDraft = null;474  async function loadSocial() {475    try {476      const r = await fetch('/api/social/state'); const d = await r.json();477      renderSocialState(d.state); renderSocialLog(d.log || []);478    } catch {}479  }480  function renderSocialState(st) {481    if (!st) return;482    const tog = $('socAutoToggle'); if (tog) tog.checked = !!st.auto;483    const nx = $('socNext');484    if (nx) {485      if (st.auto && st.nextAt) nx.textContent = 'Prochaine publication ~ ' + new Date(st.nextAt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) + ' (toutes les ' + st.intervalMin + ' min)';486      else nx.textContent = st.auto ? 'Automatique activé' : 'Automatique désactivé';487    }488  }489  function renderSocialLog(log) {490    const el = $('socLog'); if (!el) return;491    if (!log.length) { el.innerHTML = '<div class="soc-sub">Aucun événement.</div>'; return; }492    el.innerHTML = log.map(function (e) {493      const t = new Date(e.ts).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });494      let msg = e.kind;495      if (e.kind === 'published') msg = '📣 Publié — ' + (e.site || e.trigger || '') + (e.headline ? ' · ' + e.headline : '');496      else if (e.kind === 'cycle_error') msg = '⚠️ Erreur — ' + (e.error || '');497      else if (e.kind === 'cycle_skip') msg = '⏸️ Ignoré — ' + (e.reason || '');498      else if (e.kind === 'auto_on') msg = '▶️ Automatique activé';499      else if (e.kind === 'auto_off') msg = '⏹️ Automatique désactivé';500      else if (e.kind === 'cycle_start') msg = '… cycle démarré (' + (e.trigger || '') + ')';501      return '<div class="soc-logrow"><span class="soc-logt">' + t + '</span> ' + esc(msg) + '</div>';502    }).join('');503  }504  function bindSocial() {505    const tog = $('socAutoToggle');506    if (tog) tog.onchange = async () => {507      const r = await fetch('/api/social/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on: tog.checked }) });508      const d = await r.json(); renderSocialState(d.state);509      banner(tog.checked ? 'good' : 'muted', tog.checked ? 'Publication automatique activée' : 'Publication automatique désactivée');510    };511    const gen = $('socGenerate');512    if (gen) gen.onclick = doGenerate;513    const rg = $('socRegen'); if (rg) rg.onclick = doGenerate;514    const pub = $('socPublish');515    if (pub) pub.onclick = async () => {516      if (!socDraft) return;517      pub.disabled = true; pub.textContent = 'Publication…';518      try {519        const r = await fetch('/api/social/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: $('socCaption').value, image: socDraft.image.split('/').pop() }) });520        const d = await r.json();521        if (d.ok) { banner('good', '📣 Post publié sur la Page Groupe-KA'); $('socDraft').classList.add('hidden'); socDraft = null; loadSocial(); }522        else banner('bad', 'Échec : ' + (d.error || '?'));523      } catch (e) { banner('bad', 'Échec : ' + e.message); }524      pub.disabled = false; pub.textContent = '📣 Publier ce post';525    };526    const run = $('socRunNow');527    if (run) run.onclick = async () => {528      run.disabled = true; run.textContent = 'Publication…';529      try {530        const r = await fetch('/api/social/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });531        const d = await r.json();532        banner(d.ok ? 'good' : 'bad', d.ok ? ('📣 Publié : ' + (d.insight ? d.insight.site : '')) : ('Échec : ' + (d.error || d.reason || '?')));533        loadSocial();534      } catch (e) { banner('bad', 'Échec : ' + e.message); }535      run.disabled = false; run.textContent = '⚡ Publier maintenant';536    };537    const chk = $('socCheckLogin');538    if (chk) chk.onclick = async () => {539      const el = $('socLogin'); el.textContent = 'Vérification…';540      try { const r = await fetch('/api/social/login'); const d = await r.json();541        el.textContent = d.login === 'LOGGED_IN' ? '✅ Facebook connecté (Simon-Pierre)' : (d.login === 'LOGIN_PAGE' ? '⚠️ Facebook déconnecté — reconnecter Safari sur le nœud' : ('État : ' + (d.login || d.error)));542      } catch (e) { el.textContent = 'Erreur : ' + e.message; }543    };544  }545  let socReelDraft = null;546  (function bindReel(){547    const gen = document.getElementById('socReelGen');548    if (gen) gen.onclick = async () => {549      const prev = gen.textContent; gen.disabled = true; gen.textContent = 'Génération vidéo…';550      try {551        const r = await fetch('/api/social/reel/generate', { method:'POST', headers:{'Content-Type':'application/json'},552          body: JSON.stringify({ site: document.getElementById('socReelSite').value, prompt: document.getElementById('socReelPrompt').value }) });553        const d = await r.json();554        if (d.draft) { socReelDraft = d.draft;555          document.getElementById('socReelVid').src = d.draft.videoUrl + '?t=' + Date.now();556          document.getElementById('socReelCaption').value = d.draft.caption;557          document.getElementById('socReelDl').href = d.draft.videoUrl;558          document.getElementById('socReelDraft').classList.remove('hidden');559        } else banner('bad', 'Échec : ' + (d.error||'?'));560      } catch(e){ banner('bad','Échec : '+e.message); }561      gen.disabled = false; gen.textContent = prev;562    };563    const pub = document.getElementById('socReelPublish');564    if (pub) pub.onclick = async () => {565      if (!socReelDraft) return;566      pub.disabled = true; pub.textContent = 'Publication…';567      banner('muted', '🎬 Publication du reel — termine le « Suivant » sur l\'écran distant du nœud si demandé', null, 9000);568      try {569        const r = await fetch('/api/social/reel/publish', { method:'POST', headers:{'Content-Type':'application/json'},570          body: JSON.stringify({ caption: document.getElementById('socReelCaption').value, video: socReelDraft.video.split('/').pop() }) });571        const d = await r.json();572        banner(d.ok?'good':'bad', d.ok?'📣 Reel publié':'Échec : '+(d.error||'?'));573        if (d.ok) { document.getElementById('socReelDraft').classList.add('hidden'); socReelDraft=null; loadSocial(); }574      } catch(e){ banner('bad','Échec : '+e.message); }575      pub.disabled = false; pub.textContent = '📣 Publier (assisté)';576    };577  })();578579  async function doGenerate() {580    const gen = $('socGenerate'); const prev = gen.textContent;581    gen.disabled = true; gen.textContent = 'Génération…';582    try {583      const r = await fetch('/api/social/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ site: $('socSite').value, prompt: $('socPrompt').value }) });584      const d = await r.json();585      if (d.draft) {586        socDraft = d.draft;587        $('socImg').src = d.draft.imageUrl + '?t=' + Date.now();588        $('socCaption').value = d.draft.caption;589        $('socDraft').classList.remove('hidden');590      } else banner('bad', 'Échec : ' + (d.error || '?'));591    } catch (e) { banner('bad', 'Échec : ' + e.message); }592    gen.disabled = false; gen.textContent = prev;593  }594595  bindSocial();596  function navBadge(view) {597    if (curView === view) return;598    document.querySelectorAll(`.nav-btn[data-view="${view}"] .badge`).forEach(b => b.classList.remove('hidden'));599  }600601  // ---------- bannières ----------602  function banner(kind, html, onclick, ttl = 6000) {603    const b = el('div', 'banner ' + kind, html);604    if (onclick) b.onclick = () => { onclick(); b.remove(); };605    $('banners').appendChild(b);606    if (navigator.vibrate) navigator.vibrate(60);607    setTimeout(() => b.remove(), ttl);608  }609610  // ---------- scroll / messages ----------611  function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }612  function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }613  function addMsg(node) {614    $('welcome').classList.add('hidden');615    const wasNear = nearBottom();616    const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);617    scrollBottom(wasNear);618  }619  function renderMd(container, text) {620    container.innerHTML = marked.parse(text || '');621    container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });622  }623  // bouton copier sur tous les blocs de code (délégué, marche pour tout <pre>)624  messagesEl.addEventListener('click', (e) => {625    const pre = e.target.closest('pre');626    if (!pre || e.target.closest('button') || pre.dataset.copying) return;627    if (window.getSelection().toString()) return;628    navigator.clipboard.writeText(pre.textContent).then(() => {629      pre.dataset.copying = '1';630      const tag = el('span', 'copied-tag', 'copié ✓');631      pre.appendChild(tag);632      setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);633    }).catch(() => {});634  });635  function truncPre(text, limit = 3000) {636    const pre = el('pre');637    const full = String(text ?? '');638    if (full.length <= limit) { pre.textContent = full; return pre; }639    pre.textContent = full.slice(0, limit);640    const wrap = el('div');641    const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');642    btn.onclick = () => { pre.textContent = full; btn.remove(); };643    wrap.append(pre, btn);644    return wrap;645  }646647  function workingEl() { return messagesEl.querySelector('.working'); }648  let workTimer = null, workStart = 0;649  function setRunning(r) {650    isRunning = r;651    sendBtn.classList.toggle('hidden', r);652    stopBtn.classList.toggle('hidden', !r);653    let w = workingEl();654    if (r && !w) {655      $('welcome').classList.add('hidden');656      w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span><b class="work-time"></b>');657      messagesEl.appendChild(w);658      scrollBottom(true);659      workStart = Date.now();660      clearInterval(workTimer);661      workTimer = setInterval(() => {662        const t = w.querySelector('.work-time');663        if (!t) { clearInterval(workTimer); return; }664        const s = Math.floor((Date.now() - workStart) / 1000);665        t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s';666      }, 1000);667    } else if (!r && w) { w.remove(); clearInterval(workTimer); }668    if (!r) {669      finalizeLive(); finalizeThink();670      flushQueued();671    }672  }673  const WORK_VERB = { Bash: 'exécute', Read: 'lit', Edit: 'modifie', MultiEdit: 'modifie', Write: 'écrit', Glob: 'cherche', Grep: 'cherche dans', LS: 'liste', Task: 'délègue', WebFetch: 'récupère', WebSearch: 'recherche', TodoWrite: 'planifie', ExitPlanMode: 'propose un plan' };674  function setWorkingLabel(name, sub) {675    const w = workingEl(); if (!w) return;676    const span = w.querySelector('span'); if (!span) return;677    const verb = WORK_VERB[name] || 'utilise ' + name;678    const detail = sub ? ' ' + sub.slice(0, 46) : '';679    span.textContent = `Claude ${verb}${detail}…`;680  }681  // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini682  let queuedPrompt = null;683  function flushQueued() {684    if (!queuedPrompt) return;685    const q = queuedPrompt; queuedPrompt = null;686    const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove();687    doSend(q);688  }689690  // ---------- streaming live ----------691  function ensureLive() {692    if (live) return live;693    const w = workingEl(); if (w) { const s = w.querySelector('span'); if (s) s.textContent = 'Claude rédige…'; }694    const wrap = el('div', 'msg-claude');695    const md = el('div', 'md');696    wrap.appendChild(md);697    addMsg(wrap);698    live = { el: wrap, textEl: md, text: '' };699    return live;700  }701  function finalizeLive() { if (live) { live.el.remove(); live = null; } }702  function ensureThink() {703    if (liveThink) return liveThink;704    const box = el('div', 'thinking');705    addMsg(box);706    liveThink = { el: box, text: '' };707    return liveThink;708  }709  function finalizeThink() {710    if (!liveThink) return;711    const d = el('details', 'thinking-box', '<summary>réflexion</summary>');712    const inner = el('div', 'thinking'); inner.textContent = liveThink.text;713    d.appendChild(inner);714    liveThink.el.replaceWith(d);715    liveThink = null;716  }717718  // ---------- cartes outils ----------719  const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };720  const TOOL_TINT = { Bash: 'bash', Edit: 'edit', MultiEdit: 'edit', Write: 'edit', NotebookEdit: 'edit', Read: 'read', Glob: 'read', Grep: 'read', LS: 'read', NotebookRead: 'read', WebFetch: 'web', WebSearch: 'web' };721  function toolSubtitle(name, input) {722    input = input || {};723    if (name === 'Bash') return input.command || '';724    if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');725    if (input.pattern) return input.pattern;726    if (input.url) return input.url;727    if (name === 'Task') return input.description || '';728    if (name === 'TodoWrite') return 'liste de tâches';729    return '';730  }731  function diffBlock(oldStr, newStr) {732    const d = el('div', 'diff');733    for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));734    for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));735    return d;736  }737  function toolBody(name, input) {738    const body = el('div', 'tool-body');739    input = input || {};740    if (name === 'Bash') {741      body.appendChild(el('div', 'label', 'commande'));742      body.appendChild(truncPre(input.command));743      if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));744    } else if (name === 'Edit') {745      body.appendChild(diffBlock(input.old_string, input.new_string));746    } else if (name === 'MultiEdit') {747      for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));748    } else if (name === 'Write') {749      body.appendChild(el('div', 'label', 'contenu'));750      const d = el('div', 'diff');751      const lines = String(input.content || '').split('\n');752      for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));753      if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));754      body.appendChild(d);755    } else if (name === 'TodoWrite') {756      const ul = el('ul', 'todos');757      for (const t of input.todos || []) {758        const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';759        ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));760      }761      body.appendChild(ul);762    } else if (name === 'Task') {763      body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));764    } else if (name === 'ExitPlanMode') {765      const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);766    } else {767      body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));768    }769    return body;770  }771  // outils dont on ouvre la carte par défaut (on veut VOIR ce qui est fait)772  const OPEN_BY_DEFAULT = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'ExitPlanMode']);773  function addToolCard(block) {774    finalizeLive(); finalizeThink();775    const name = block.name || '?';776    const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;777    const card = el('details', 'tool');778    if (OPEN_BY_DEFAULT.has(name)) card.open = true;779    const sum = el('summary');780    sum.innerHTML = `<span class="t-icon ${TOOL_TINT[name] || ''}">${TOOL_ICONS[name] || '⚙️'}</span><span class="t-name">${esc(shortName)}</span><span class="t-sub">${esc(toolSubtitle(name, block.input))}</span><span class="t-state pending">●&nbsp;en cours</span>`;781    card.appendChild(sum);782    card.appendChild(toolBody(name, block.input));783    addMsg(card);784    toolCards.set(block.id, { card, name, stateEl: sum.querySelector('.t-state'), subEl: sum.querySelector('.t-sub'), bodyEl: card.querySelector('.tool-body') });785    // met à jour l'indicateur « en cours » avec l'action réelle786    setWorkingLabel(name, toolSubtitle(name, block.input));787  }788  function attachToolResult(block) {789    const t = toolCards.get(block.tool_use_id);790    if (!t) return;791    t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');792    t.stateEl.textContent = block.is_error ? '✗ erreur' : '✓';793    let text = '';794    if (typeof block.content === 'string') text = block.content;795    else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');796    text = text.trim();797    if (text) {798      // les cartes repliées (Read/Grep/…) montrent un aperçu du résultat dans l'en-tête799      if (!t.card.open && t.subEl) {800        const firstLine = text.split('\n').find(l => l.trim()) || '';801        t.subEl.textContent = firstLine.slice(0, 80);802      }803      t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));804      t.bodyEl.appendChild(truncPre(text));805    } else {806      t.bodyEl.appendChild(el('div', 'meta-line', block.is_error ? 'erreur (vide)' : 'terminé sans sortie'));807    }808  }809810  // ---------- permissions ----------811  function addPermCard(ev) {812    finalizeLive(); finalizeThink();813    const card = el('div', 'perm');814    const isPlan = ev.tool === 'ExitPlanMode';815    card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;816    if (isPlan && ev.input?.plan) {817      const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);818    } else {819      const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);820    }821    const actions = el('div', 'perm-actions');822    const deny = el('button', 'perm-deny', 'Refuser');823    const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');824    actions.append(deny, allow);825    if (!isPlan) {826      const always = el('button', 'perm-always', 'Toujours<br><small>cet outil</small>');827      always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });828      actions.insertBefore(always, allow);829      // « Tout autoriser » : le serveur approuve TOUTES les permissions de la830      // session — la tâche va au bout même téléphone rangé831      const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander');832      all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true });833      card.appendChild(actions);834      card.appendChild(all);835    } else {836      card.appendChild(actions);837    }838    deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });839    allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });840    addMsg(card);841    permCards.set(ev.requestId, card);842    scrollBottom(true);843    if (navigator.vibrate) navigator.vibrate([60, 60, 60]);844  }845  function resolvePermCard(ev) {846    const card = permCards.get(ev.requestId);847    if (!card) return;848    const actions = card.querySelector('.perm-actions');849    if (actions) actions.remove();850    const allBtn = card.querySelector('.perm-allall');851    if (allBtn) allBtn.remove();852    card.appendChild(el('div', 'perm-resolved',853      ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));854  }855856  // ---------- rendu des événements ----------857  function renderEvent(ev) {858    if (ev.type === 'user_prompt') {859      finalizeLive(); finalizeThink();860      const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);861      return;862    }863    if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }864    if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }865    if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }866    if (ev.type === 'permission_request') { addPermCard(ev); return; }867    if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }868    if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }869    if (ev.type === 'claude') { renderClaude(ev.event); return; }870  }871872  function renderClaude(ev) {873    if (ev.type === 'system') {874      if (ev.subtype === 'init') {875        addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));876      } else if (ev.subtype === 'compact_boundary') {877        addMsg(el('div', 'meta-line', '⇣ contexte compacté'));878      } else if (ev.subtype && ev.subtype !== 'hook') {879        addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype)));880      }881      return;882    }883    if (ev.type === 'stream_event' && ev.event) {884      const se = ev.event;885      if (se.type === 'content_block_delta' && se.delta) {886        if (se.delta.type === 'text_delta') {887          const l = ensureLive();888          l.text += se.delta.text;889          renderMd(l.textEl, l.text);890          scrollBottom();891        } else if (se.delta.type === 'thinking_delta') {892          const t = ensureThink();893          t.text += se.delta.thinking;894          t.el.textContent = t.text.slice(-600);895          scrollBottom();896        }897      }898      return;899    }900    if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {901      for (const block of ev.message.content) {902        if (block.type === 'text' && block.text && block.text.trim()) {903          finalizeLive(); finalizeThink();904          const wrap = el('div', 'msg-claude');905          const md = el('div', 'md');906          renderMd(md, block.text);907          wrap.appendChild(md);908          addMsg(wrap);909        } else if (block.type === 'tool_use') {910          addToolCard(block);911        } else if (block.type === 'thinking' && block.thinking) {912          if (liveThink) { liveThink.el.remove(); liveThink = null; }913          const d = el('details', 'thinking-box', '<summary>réflexion</summary>');914          const inner = el('div', 'thinking'); inner.textContent = block.thinking;915          d.appendChild(inner);916          addMsg(d);917        }918      }919      return;920    }921    if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {922      for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);923      return;924    }925    if (ev.type === 'result') {926      finalizeLive(); finalizeThink();927      const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';928      const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';929      const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';930      addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));931      if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));932      return;933    }934  }935936  // ---------- méta de session (chips + bandeau) ----------937  function refreshChips() {938    const model = currentChat ? currentChat.model : settings.model;939    const perm = currentChat ? currentChat.permMode : settings.permMode;940    $('chipModel').textContent = MODEL_LABEL[model] || model;941    $('chipPerm').textContent = PERM_LABEL[perm] || perm;942    $('chipPerm').classList.toggle('warn', perm === 'bypass');943    $('chipModel').classList.add('orange');944    projectSel.classList.toggle('all-mode', !currentChat && projectSel.value === 'ALL');945    const meta = $('sessMeta');946    if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {947      meta.classList.remove('hidden');948      $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]);949      $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';950      $('metaCost').textContent = fmt$(currentChat.totalCost);951      $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : '';952    } else meta.classList.add('hidden');953  }954955  // ---------- WebSocket ----------956  function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }957  function setConn(state) {958    const txt = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';959    for (const id of ['connPill', 'connPillSide']) {960      const pill = $(id);961      if (!pill) continue;962      pill.className = 'conn-pill ' + state;963      const t = pill.querySelector('.conn-txt'); if (t) t.textContent = txt;964    }965  }966  function connect() {967    if (!wsWanted) return;968    setConn('mid');969    const proto = location.protocol === 'https:' ? 'wss' : 'ws';970    ws = new WebSocket(`${proto}://${location.host}/ws`);971    ws.onopen = () => {972      setConn('on');973      wsRetry = 1000;974      if (currentChat) send({ type: 'open', chatId: currentChat.id });975      if (curView === 'eco') send({ type: 'eco_sub' });976    };977    ws.onclose = () => {978      setConn('off');979      setTimeout(connect, wsRetry);980      wsRetry = Math.min(wsRetry * 1.6, 20000);981    };982    ws.onmessage = (e) => {983      let msg;984      try { msg = JSON.parse(e.data); } catch { return; }985      handleServer(msg);986    };987  }988  function rememberChat(id) { settings.lastChatId = id; saveStore(); }989  function handleServer(msg) {990    if (msg.type === 'batch_created') {991      banner('good', `🌐 Lancé sur ${msg.count}/${msg.total} apps — suis-les dans Sessions`, () => switchView('sessions'), 6000);992      switchView('sessions');993      return;994    }995    if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; }996    if (msg.type === 'chat_meta') {997      if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }998      return;999    }1000    if (msg.type === 'history') {1001      currentChat = msg.chat;1002      rememberChat(msg.chat.id);1003      clearConversation(true);1004      for (const ev of msg.events) { try { renderEvent(ev); } catch {} }1005      setRunning(msg.running);1006      for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;1007      refreshChips();1008      scrollBottom(true);1009      return;1010    }1011    if (msg.type === 'notify') {1012      if (msg.kind === 'done') {1013        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {1014          banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));1015          navBadge('chat'); navBadge('sessions');1016        }1017      } else if (msg.kind === 'perm') {1018        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {1019          banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);1020          navBadge('chat');1021        }1022      }1023      return;1024    }1025    if (msg.type === 'projects') {   // le registre mld a bougé : la liste des apps/nœuds suit1026      fillProjects(msg.projects || []);1027      loadRegistryInfo();1028      return;1029    }1030    if (msg.type === 'eco_alert') {1031      if (msg.kind === 'registry') {1032        banner('warn', `📦 ${esc(msg.reason || '')} — registre mld`, () => switchView('settings'), 12000);1033        loadRegistryInfo();1034        if (curView === 'eco') loadEco();1035        return;1036      }1037      if (msg.kind === 'down') { banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000); const tb = $('topIncBadge'); if (tb) tb.classList.remove('hidden'); }1038      else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));1039      navBadge('eco');1040      if (curView === 'eco') loadEco();1041      return;1042    }1043    if (msg.type === 'eco') {1044      if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);1045      return;1046    }1047    renderEvent(msg);1048  }10491050  function clearConversation(keepChat) {1051    messagesEl.innerHTML = '';1052    toolCards.clear(); permCards.clear();1053    live = null; liveThink = null;1054    if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');1055  }1056  function openChat(chatId) {1057    switchView('chat');1058    currentChat = { id: chatId };1059    clearConversation(true);1060    send({ type: 'open', chatId });1061  }10621063  // ---------- composer ----------1064  function doSend(text) {1065    send({1066      type: 'start',1067      chatId: currentChat ? currentChat.id : null,1068      project: projectSel.value,1069      model: currentChat ? currentChat.model : settings.model,1070      permMode: currentChat ? currentChat.permMode : settings.permMode,1071      prompt: text,1072    });1073  }1074  function sendPrompt() {1075    const text = inputEl.value.trim();1076    if (!text) return;1077    // commandes slash locales1078    if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }1079    if (text === '/cost') {1080      if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));1081      inputEl.value = ''; autoGrow(); return;1082    }1083    if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }1084    inputEl.value = '';1085    autoGrow();1086    hideSlash();1087    // diffusion à toutes les apps KA1088    if (!currentChat && projectSel.value === 'ALL') { broadcastToAll(text); return; }1089    if (isRunning) {1090      // Claude travaille : le message part automatiquement dès la fin du tour1091      queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text;1092      let n = messagesEl.querySelector('.queued-notice');1093      if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); }1094      n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »';1095      scrollBottom(true);1096      return;1097    }1098    doSend(text);1099  }1100  function broadcastToAll(text) {1101    if (!confirm('Lancer ce prompt sur TOUTES les apps du Groupe KA en parallèle (une session par app) ?')) return;1102    send({ type: 'start_all', prompt: text, model: settings.model, permMode: settings.permMode });1103  }1104  sendBtn.onclick = sendPrompt;1105  stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };1106  inputEl.addEventListener('keydown', (e) => {1107    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }1108  });1109  function autoGrow() {1110    inputEl.style.height = 'auto';1111    inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';1112  }1113  inputEl.addEventListener('input', () => {1114    autoGrow();1115    if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();1116  });1117  inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));11181119  function showSlash() {1120    const h = $('slashHint');1121    h.classList.remove('hidden');1122    if (!h.childElementCount) {1123      for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {1124        const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);1125        b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };1126        h.appendChild(b);1127      }1128    }1129  }1130  function hideSlash() { $('slashHint').classList.add('hidden'); }11311132  document.querySelectorAll('.hint').forEach((h) => {1133    h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };1134  });11351136  // ---------- upgradeur de prompt (Opus 4.8 + savoir Groupe KA) ----------1137  const upgradeBtn = $('upgradeBtn');1138  let upgrading = false;1139  upgradeBtn.onclick = async () => {1140    const raw = inputEl.value.trim();1141    if (!raw || upgrading) return;1142    upgrading = true;1143    upgradeBtn.classList.add('busy');1144    const original = inputEl.value;1145    inputEl.disabled = true;1146    inputEl.value = '✨ amélioration du prompt avec Opus 4.8…';1147    try {1148      const r = await fetch('/api/upgrade-prompt', {1149        method: 'POST',1150        headers: { 'Content-Type': 'application/json' },1151        body: JSON.stringify({ prompt: raw, project: projectSel.value }),1152      });1153      const d = await r.json();1154      if (r.ok && d.upgraded) {1155        inputEl.value = d.upgraded;1156        if (navigator.vibrate) navigator.vibrate(40);1157      } else {1158        inputEl.value = original;1159        banner('bad', '✗ Upgrade impossible : ' + esc(d.error || 'erreur'), null, 5000);1160      }1161    } catch (e) {1162      inputEl.value = original;1163      banner('bad', '✗ Upgrade impossible (réseau)', null, 5000);1164    } finally {1165      inputEl.disabled = false;1166      upgrading = false;1167      upgradeBtn.classList.remove('busy');1168      autoGrow();1169      inputEl.focus();1170    }1171  };11721173  // ---------- chips ----------1174  $('chipModel').onclick = () => {1175    const order = ['fable', 'opus', 'sonnet', 'haiku'];1176    const cur = currentChat ? currentChat.model : settings.model;1177    const next = order[(order.indexOf(cur) + 1) % order.length];1178    applyOpts({ model: next });1179  };1180  $('chipPerm').onclick = () => {1181    const order = ['default', 'acceptEdits', 'plan', 'bypass'];1182    const cur = currentChat ? currentChat.permMode : settings.permMode;1183    const next = order[(order.indexOf(cur) + 1) % 4];1184    if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;1185    applyOpts({ permMode: next });1186  };1187  function applyOpts(opts) {1188    if (currentChat) {1189      Object.assign(currentChat, opts);1190      send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });1191    }1192    if (opts.model) settings.model = opts.model;1193    if (opts.permMode) settings.permMode = opts.permMode;1194    saveStore();1195    refreshChips();1196    refreshSettingsView();1197  }1198  $('chipMore').onclick = () => {1199    const c = currentChat;1200    openSheet(`1201      <div class="sheet-h"><h3>Session</h3></div>1202      <div class="sheet-sec">1203        <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>1204        <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>1205        <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>1206        <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>1207        <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>1208      </div>1209      <div class="sheet-sec">1210        <div class="set-actions">1211          <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>1212          <button class="btn-ghost" id="shCompact">/compact</button>1213          <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>1214        </div>1215      </div>`);1216    $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };1217    $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };1218    $('shNew').onclick = () => { closeSheet(); newChat(); };1219  };1220  function newChat() {1221    currentChat = null;1222    queuedPrompt = null;1223    settings.lastChatId = null; saveStore();1224    clearConversation();1225    setRunning(false);1226    refreshChips();1227    switchView('chat');1228  }12291230  // ---------- sessions ----------1231  let sessCache = [];1232  function sessFilters() {1233    return { q: ($('sessSearch')?.value || '').toLowerCase(), app: $('sessApp')?.value || '', st: $('sessState')?.value || '' };1234  }1235  function renderSessions() {1236    const { q, app, st } = sessFilters();1237    const list = $('sessList');1238    list.innerHTML = '';1239    const rows = sessCache.filter(c =>1240      (!q || (c.title + ' ' + (c.projectName || '')).toLowerCase().includes(q))1241      && (!app || c.projectId === app)1242      && (!st || c.state === st));1243    if (!rows.length) { list.appendChild(el('div', 'sess-empty', sessCache.length ? 'Aucune session ne correspond aux filtres.' : 'Aucune session. Lance une conversation dans l’onglet Claude Code.')); return; }1244    const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };1245    for (const c of rows) {1246      const card = el('div', 'sess-card row');1247      card.innerHTML = `1248        <span class="sess-badge ${c.state}" title="${stLabel[c.state] || c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>1249        <div class="sess-main">1250          <span class="sess-title">${c.batch ? '<span class="batch-tag">🌐</span> ' : ''}${esc(c.title)}</span>1251          <span class="sess-info"><span>${esc(c.projectName || '')}</span><span>${MODEL_LABEL[c.model] || c.model}</span><span>${c.contextTokens ? 'ctx ' + fmtTok(c.contextTokens) : ''}</span></span>1252        </div>1253        <span class="sess-cost mono">${fmt$(c.totalCost)}</span>1254        <span class="sess-when mono">${timeAgo(c.updated)}</span>1255        <span class="sess-btns"></span>`;1256      const btns = card.querySelector('.sess-btns');1257      const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');1258      open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };1259      btns.appendChild(open);1260      if (c.state === 'idle') {1261        const del = el('button', 'del', '✕');1262        del.title = 'Supprimer';1263        del.onclick = async (e) => {1264          e.stopPropagation();1265          if (!confirm('Supprimer cette session ?')) return;1266          await fetch('/api/chats/' + c.id, { method: 'DELETE' });1267          sessCache = sessCache.filter(x => x.id !== c.id);1268          renderSessions();1269          if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }1270        };1271        btns.appendChild(del);1272      }1273      card.onclick = () => openChat(c.id);1274      list.appendChild(card);1275    }1276  }1277  async function loadSessions() {1278    const r = await fetch('/api/chats');1279    if (!r.ok) return;1280    sessCache = (await r.json()).chats || [];1281    // remplit le filtre app à partir des sessions existantes1282    const sel = $('sessApp');1283    if (sel && sel.options.length <= 1) {1284      const seen = new Map();1285      for (const c of sessCache) if (c.projectId && !seen.has(c.projectId)) seen.set(c.projectId, c.projectName || c.projectId);1286      for (const [id2, name] of seen) { const o = document.createElement('option'); o.value = id2; o.textContent = name; sel.appendChild(o); }1287    }1288    renderSessions();1289  }1290  $('newSessBtn').onclick = newChat;1291  for (const id2 of ['sessSearch', 'sessApp', 'sessState']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderSessions; }12921293  // sessions récentes sur l'écran d'accueil du chat1294  async function loadWelcomeRecent() {1295    const box = $('welcomeRecent');1296    if (!box) return;1297    try {1298      const { chats } = await (await fetch('/api/chats')).json();1299      if (!chats.length) { box.innerHTML = ''; return; }1300      box.innerHTML = '<div class="wr-title">Reprendre une session</div>' + chats.slice(0, 4).map(c => `1301        <button class="wr-item" data-chat="${c.id}">1302          <span class="sess-badge ${c.state}">${c.state === 'running' ? '●' : c.state === 'waiting_perm' ? '🔐' : '✓'}</span>1303          <span class="wr-t">${esc(c.title)}</span>1304          <span class="wr-m mono">${esc(c.projectName || '')}</span>1305        </button>`).join('');1306      box.querySelectorAll('[data-chat]').forEach(b => { b.onclick = () => openChat(b.dataset.chat); });1307    } catch {}1308  }13091310  // ---------- Prompt Bank ----------1311  function projectOptionsHTML(selected) {1312    return [...projectSel.options].map(o =>1313      `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');1314  }1315  let promptCache = [];1316  function renderPrompts() {1317    const q = ($('promptSearch')?.value || '').toLowerCase();1318    const app = $('promptApp')?.value || '';1319    const list = $('promptList');1320    list.innerHTML = '';1321    const rows = promptCache1322      .filter(pr => (!q || (pr.title + ' ' + pr.text).toLowerCase().includes(q)) && (!app || pr.project === app))1323      .sort((a, b) => (b.fav ? 1 : 0) - (a.fav ? 1 : 0) || (b.updated || 0) - (a.updated || 0));1324    if (!rows.length) { list.appendChild(el('div', 'sess-empty', promptCache.length ? 'Aucun prompt ne correspond.' : 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }1325    for (const pr of rows) {1326      const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';1327      const card = el('div', 'sess-card prow');1328      card.innerHTML = `1329        <div class="sess-top">1330          <button class="fav-btn${pr.fav ? ' on' : ''}" title="Favori">${pr.fav ? '★' : '☆'}</button>1331          <span class="sess-title">${esc(pr.title)}</span>1332          <span class="sess-info"><span>🎯 ${esc(projName)}</span>${pr.uses ? `<span>${pr.uses}× utilisé</span>` : ''}<span>${timeAgo(pr.updated)}</span></span>1333        </div>1334        <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>`;1335      const actions = el('div', 'sess-actions');1336      const sendB = el('button', 'p-send', 'Lancer →');1337      const editB = el('button', '', '✎ Éditer');1338      const upB = el('button', '', '✨ Améliorer');1339      const dupB = el('button', '', '⧉ Dupliquer');1340      const delB = el('button', 'del', 'Suppr.');1341      actions.append(sendB, upB, editB, dupB, delB);1342      card.appendChild(actions);1343      card.querySelector('.fav-btn').onclick = async (e) => {1344        e.stopPropagation();1345        pr.fav = !pr.fav;1346        await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fav: pr.fav }) });1347        renderPrompts();1348      };1349      sendB.onclick = () => {1350        fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bumpUse: true }) }).catch(() => {});1351        openSendSheet(pr);1352      };1353      editB.onclick = () => openPromptEditor(pr);1354      dupB.onclick = async () => {1355        await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: pr.title + ' (copie)', text: pr.text, project: pr.project }) });1356        loadPrompts();1357      };1358      delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); promptCache = promptCache.filter(x => x.id !== pr.id); renderPrompts(); };1359      upB.onclick = async () => {1360        upB.textContent = '✨ …'; upB.disabled = true;1361        try {1362          const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) });1363          const d = await rr.json();1364          if (rr.ok && d.upgraded) {1365            await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) });1366            banner('good', '✨ Prompt amélioré et enregistré', null, 3000);1367            loadPrompts();1368          } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);1369        } catch { banner('bad', '✗ réseau', null, 4000); }1370        upB.textContent = '✨ Améliorer'; upB.disabled = false;1371      };1372      list.appendChild(card);1373    }1374  }1375  async function loadPrompts() {1376    const r = await fetch('/api/prompts');1377    if (!r.ok) return;1378    promptCache = (await r.json()).prompts || [];1379    const sel = $('promptApp');1380    if (sel && sel.options.length <= 1) {1381      const seen = new Set(promptCache.map(p2 => p2.project).filter(Boolean));1382      for (const id2 of seen) {1383        const o = document.createElement('option');1384        o.value = id2; o.textContent = [...projectSel.options].find(x => x.value === id2)?.textContent || id2;1385        sel.appendChild(o);1386      }1387    }1388    renderPrompts();1389  }1390  $('newPromptBtn').onclick = () => openPromptEditor(null);1391  for (const id2 of ['promptSearch', 'promptApp']) { const e2 = $(id2); if (e2) e2.oninput = e2.onchange = renderPrompts; }13921393  function openPromptEditor(pr) {1394    const isNew = !pr;1395    openSheet(`1396      <div class="sheet-h"><h3>${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}</h3></div>1397      <div class="sheet-sec">1398        <input id="peTitle" class="pe-input" placeholder="Titre (optionnel)" value="${esc(pr?.title || '')}">1399        <textarea id="peText" class="pe-textarea" placeholder="Écris ton prompt ici…">${esc(pr?.text || '')}</textarea>1400        <div class="set-label" style="margin-top:10px">App par défaut</div>1401        <select id="peProject" class="pill-select" style="max-width:100%;width:100%">1402          <option value="">— app au choix (demandé à l’envoi) —</option>1403          ${projectOptionsHTML(pr?.project || '')}1404        </select>1405        <div class="set-actions" style="margin-top:12px">1406          <button class="btn-ghost" id="peUpgrade">✨ Améliorer (Opus 4.8)</button>1407          <button class="btn-cta small" id="peSave">Enregistrer</button>1408        </div>1409      </div>`);1410    $('peUpgrade').onclick = async () => {1411      const t = $('peText').value.trim(); if (!t) return;1412      const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true;1413      try {1414        const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) });1415        const d = await rr.json();1416        if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); }1417        else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);1418      } catch { banner('bad', '✗ réseau', null, 4000); }1419      b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false;1420    };1421    $('peSave').onclick = async () => {1422      const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; }1423      const payload = { title: $('peTitle').value, text, project: $('peProject').value || null };1424      if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });1425      else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });1426      closeSheet(); loadPrompts();1427    };1428  }14291430  function openSendSheet(pr) {1431    openSheet(`1432      <div class="sheet-h"><h3>Envoyer à Claude Code</h3></div>1433      <div class="sheet-sec">1434        <div class="prompt-preview" style="max-height:180px;overflow:auto">${esc(pr.text)}</div>1435      </div>1436      <div class="sheet-sec">1437        <div class="set-label">App cible (nœud)</div>1438        <select id="sendProject" class="pill-select" style="max-width:100%;width:100%">${projectOptionsHTML(pr.project || projectSel.value)}</select>1439        <div class="set-label" style="margin-top:10px">Modèle · permissions</div>1440        <div class="kv"><span>${MODEL_LABEL[settings.model] || settings.model} · ${PERM_LABEL[settings.permMode] || settings.permMode}</span><span class="mono">(réglages par défaut)</span></div>1441        <button class="btn-cta" style="width:100%;margin-top:12px" id="sendGo">🚀 Lancer sur Claude Code</button>1442      </div>`);1443    $('sendGo').onclick = () => {1444      const proj = $('sendProject').value;1445      closeSheet();1446      sendPromptToApp(pr.text, proj);1447    };1448  }14491450  function sendPromptToApp(text, projectId) {1451    if (projectId === 'ALL') { broadcastToAll(text); return; }1452    currentChat = null;1453    queuedPrompt = null;1454    clearConversation();1455    setRunning(false);1456    for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value;1457    switchView('chat');1458    refreshChips();1459    send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text });1460    banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);1461  }14621463  // incidents globaux (bouton Écosystème + cloche topbar)1464  $('topInc').onclick = () => openIncidents();1465  $('incBtn').onclick = () => openIncidents();1466  async function openIncidents() {1467    const tb = $('topIncBadge'); if (tb) tb.classList.add('hidden');1468    const r = await fetch('/api/eco/incidents');1469    if (!r.ok) return;1470    const { incidents } = await r.json();1471    const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app;1472    openSheet(`1473      <div class="sheet-h"><h3>Incidents</h3></div>1474      <div class="sheet-sec">1475        <h4>Historique — 50 derniers</h4>1476        ${incidents.length ? incidents.map(i => `1477          <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1478          <span><b>${esc(label(i.site))}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1479          ${i.ended ? ' → rétabli en ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — <b style="color:var(--bad)">EN COURS</b>'} · ${esc(i.reason || '')}</span></div>`).join('')1480          : '<div style="color:var(--muted)">Aucun incident enregistré 🎉</div>'}1481      </div>`);1482  };14831484  // ---------- écosystème ----------1485  function siteStatus(s) {1486    if (s.incident || (s.last && !s.last.ok)) return 'down';1487    if (s.last && s.last.ms > 4000) return 'slow';1488    return s.last ? 'up' : 'unknown';1489  }1490  const ST = {1491    up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },1492    slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },1493    down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },1494    unknown: { cls: 'slow', ico: '▲', lbl: '…' },1495  };1496  function sparkline(spark, w = 120, h = 30) {1497    if (!spark || spark.length < 2) return '<svg class="spark"></svg>';1498    const ms = spark.map(p => p.ok ? p.ms : null);1499    const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;1500    const step = w / (spark.length - 1);1501    let d = '', pen = false;1502    spark.forEach((p, i) => {1503      if (!p.ok) { pen = false; return; }1504      const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);1505      d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);1506      pen = true;1507    });1508    const fails = spark.map((p, i) => !p.ok ? `<rect x="${(i * step - 1.5).toFixed(1)}" y="${h - 7}" width="3" height="5" fill="var(--bad)"/>` : '').join('');1509    return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>${fails}</svg>`;1510  }1511  async function loadEco() {1512    const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]);1513    if (!r.ok) return;1514    ecoData = await r.json();1515    renderEco();1516    if (rn.ok) renderNodes(await rn.json());1517  }1518  function nbar(label, val, pct) {1519    const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)';1520    return `<div class="nbar"><div class="nbar-l"><span>${label}</span><b>${val}</b></div>1521      <div class="nbar-track"><div class="nbar-fill" style="width:${Math.max(2, Math.min(100, pct))}%;background:${c}"></div></div></div>`;1522  }1523  function renderNodes(data) {1524    const grid = $('ecoNodes');1525    if (!grid) return;1526    grid.innerHTML = '';1527    for (const n of data.nodes) {1528      const L = n.last;1529      const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null;1530      const upApps = n.apps.filter(a => a.status === 'online').length;1531      const badApp = n.apps.some(a => a.status && a.status !== 'online');1532      const card = el('div', 'node-card' + (badApp ? ' warn' : ''));1533      card.innerHTML = `1534        <div class="node-top"><span class="node-name">${esc(n.node)}</span>1535          <span class="node-apps${badApp ? ' bad' : ''}">${upApps}/${n.apps.length} apps</span></div>1536        ${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)}1537        ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)}1538        ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`;1539      grid.appendChild(card);1540    }1541  }1542  function applyChecks(items) {1543    if (!ecoData) return;1544    for (const it of items) {1545      const s = ecoData.sites.find(x => x.app === it.site);1546      if (!s) continue;1547      s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };1548      s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });1549      if (s.spark.length > 40) s.spark.shift();1550    }1551    renderEco();1552  }1553  function trendBadge(t) {1554    if (t == null) return '';1555    if (t <= -8) return `<span class="trend down" title="latence en baisse">▼ ${Math.abs(t)}%</span>`;1556    if (t >= 8) return `<span class="trend up" title="latence en hausse">▲ ${t}%</span>`;1557    return `<span class="trend flat">≈</span>`;1558  }1559  function statusBar(sd) {1560    const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1;1561    const seg = (n, cls) => n ? `<i class="${cls}" style="width:${(n / total * 100).toFixed(1)}%" title="${cls} : ${n}"></i>` : '';1562    return `<div class="statusbar">${seg(sd['2xx'], 's2')}${seg(sd['3xx'], 's3')}${seg(sd['4xx'], 's4')}${seg(sd['5xx'], 's5')}${seg(sd.err, 'se')}</div>`;1563  }1564  function renderEco() {1565    const sites = ecoData.sites;1566    const g = ecoData.global || {};1567    $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });1568    // bandeau KPI du command center1569    $('ecoOverview').innerHTML = `1570      <div class="kpi ${g.down ? 'bad' : 'ok'}"><div class="v">${g.up}<span class="tot">/${g.total}</span></div><div class="l">en ligne</div></div>1571      <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</div></div>1572      <div class="kpi"><div class="v">${g.avgLatency}<small>ms</small></div><div class="l">latence moy.</div></div>1573      <div class="kpi"><div class="v">${(g.checks24 / 1000).toFixed(1)}<small>k</small></div><div class="l">checks 24h</div></div>1574      <div class="kpi ${g.fails24 ? 'bad' : ''}"><div class="v">${g.fails24}</div><div class="l">échecs 24h</div></div>1575      <div class="kpi ${g.openIncidents ? 'bad' : ''}"><div class="v">${g.openIncidents}</div><div class="l">incidents</div></div>`;1576    const grid = $('ecoGrid');1577    grid.innerHTML = '';1578    const order = { down: 0, slow: 1, unknown: 2, up: 3 };1579    for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) {1580      const st = ST[siteStatus(s)];1581      const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));1582      card.innerHTML = `1583        <div class="site-top">1584          <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">1585          <span class="site-name">${esc(s.label)}</span>1586          <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1587        </div>1588        <div class="site-mid">1589          <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small> ${trendBadge(s.trend)}</div>1590          ${sparkline(s.spark)}1591        </div>1592        ${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}1593        <div class="site-metrics">1594          <div class="m"><b>${s.uptime24 ?? '—'}%</b><span>uptime</span></div>1595          <div class="m"><b>${s.p95_24 ?? '—'}</b><span>p95 ms</span></div>1596          <div class="m"><b>${s.p99_24 ?? '—'}</b><span>p99 ms</span></div>1597          <div class="m"><b>${((s.checks24 || 0) / 1000).toFixed(1)}k</b><span>checks</span></div>1598        </div>1599        <div class="site-foot">1600          <span>${esc(s.node)}</span>1601          ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">proc <b>${esc(s.proc.status)}</b></span>` : ''}1602          ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}1603          ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}1604        </div>`;1605      card.onclick = () => openSiteSheet(s.app);1606      grid.appendChild(card);1607    }1608  }16091610  async function openSiteSheet(app) {1611    const r = await fetch('/api/eco/site/' + app);1612    if (!r.ok) return;1613    const d = await r.json();1614    const s = ecoData.sites.find(x => x.app === app) || {};1615    const st = ST[siteStatus(s)];1616    const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;1617    const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {};1618    openSheet(`1619      <div class="sheet-h">1620        <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">1621        <h3>${esc(d.label)}</h3>1622        <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>1623      </div>1624      <div class="sheet-sec">1625        <h4>Disponibilité</h4>1626        <div class="tile-row">1627          <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>1628          <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>1629          <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>1630        </div>1631        <div style="margin-top:10px">${uptimeDays(d.days)}</div>1632      </div>1633      <div class="sheet-sec">1634        <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>1635        ${latencyChart(d.series)}1636        <div class="pgrid">1637          <div class="pc"><b>${L.min ?? '—'}</b><span>min</span></div>1638          <div class="pc"><b>${L.p50 ?? '—'}</b><span>p50</span></div>1639          <div class="pc"><b>${L.p90 ?? '—'}</b><span>p90</span></div>1640          <div class="pc hot"><b>${L.p95 ?? '—'}</b><span>p95</span></div>1641          <div class="pc hot"><b>${L.p99 ?? '—'}</b><span>p99</span></div>1642          <div class="pc"><b>${L.max ?? '—'}</b><span>max</span></div>1643        </div>1644      </div>1645      <div class="sheet-sec">1646        <h4>Codes HTTP & fiabilité — 24 h</h4>1647        ${statusBar(SD)}1648        <div class="legend">1649          <span><i class="sw s2"></i>2xx ${SD['2xx'] || 0}</span>1650          <span><i class="sw s3"></i>3xx ${SD['3xx'] || 0}</span>1651          <span><i class="sw s4"></i>4xx ${SD['4xx'] || 0}</span>1652          <span><i class="sw s5"></i>5xx ${SD['5xx'] || 0}</span>1653          <span><i class="sw se"></i>err ${SD.err || 0}</span>1654        </div>1655        <div class="tile-row" style="margin-top:10px">1656          <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>1657          <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</div></div>1658          <div class="tile"><div class="v">${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}</div><div class="l">taille moy</div></div>1659        </div>1660      </div>1661      <div class="sheet-sec">1662        <h4>Incidents — 30 j</h4>1663        <div class="tile-row">1664          <div class="tile"><div class="v ${IS.count30 ? 'red' : ''}">${IS.count30 ?? 0}</div><div class="l">incidents</div></div>1665          <div class="tile"><div class="v">${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}</div><div class="l">MTTR</div></div>1666          <div class="tile"><div class="v">${IS.longestMin ? IS.longestMin + ' min' : '—'}</div><div class="l">pire arrêt</div></div>1667        </div>1668      </div>1669      <div class="sheet-sec">1670        <h4>Service</h4>1671        <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>1672        <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>1673        <div class="kv"><span>Processus</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>1674        <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>1675        <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div>1676        <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>1677        <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>1678      </div>1679      ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `1680        <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1681        <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1682        ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}1683      ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `1684        <div class="inc-item"><span class="inc-dot">✗</span><span>${new Date(e.ts).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} · ${e.code ? 'HTTP ' + e.code : esc(e.err || '')}</span></div>`).join('')}</div>` : ''}1685      <div class="sheet-sec">1686        <h4>Actions d'administration</h4>1687        <div class="admin-actions">1688          <button class="btn-ghost" id="aRestart">↻ Redémarrer</button>1689          <button class="btn-ghost" id="aLogs">📜 Logs récents</button>1690          <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>1691          <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>1692        </div>1693        <div id="aOut" class="admin-out hidden"></div>1694      </div>1695      <button class="btn-cta" style="width:100%" id="shChat">💬 Demander à Claude Code →</button>1696    `);1697    $('shChat').onclick = () => {1698      closeSheet();1699      newChat();1700      for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;1701    };1702    const out = () => $('aOut');1703    const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };1704    $('aRestart').onclick = async () => {1705      if (!confirm(`Redémarrer ${d.label} sur ${d.node} ?`)) return;1706      showOut('<div class="admin-load">redémarrage…</div>');1707      const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });1708      const j = await r.json();1709      showOut(`<div class="${j.ok ? 'admin-ok' : 'admin-err'}">${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}</div>`);1710      if (navigator.vibrate) navigator.vibrate(60);1711    };1712    $('aLogs').onclick = async () => {1713      showOut('<div class="admin-load">chargement des logs…</div>');1714      const r = await fetch('/api/admin/logs/' + d.app);1715      const j = await r.json();1716      showOut(`<pre>${esc((j.logs || '').slice(-4000))}</pre>`);1717    };1718    $('aCommits').onclick = async () => {1719      showOut('<div class="admin-load">…</div>');1720      const r = await fetch('/api/admin/commits/' + d.app);1721      const j = await r.json();1722      showOut(`<pre>${esc(j.text || '')}</pre>`);1723    };1724    attachChartTips();1725  }17261727  function uptimeDays(days) {1728    if (!days) return '';1729    const cells = days.map(dd => {1730      const u = dd.uptime;1731      const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';1732      return `<div title="${new Date(dd.day).toLocaleDateString('fr-CA')} — ${u === null ? 'aucune donnée' : u + '%'}" style="flex:1;height:22px;border-radius:5px;background:${c};border:1.5px solid var(--line)"></div>`;1733    }).join('');1734    return `<div style="display:flex;gap:4px">${cells}</div><div style="display:flex;justify-content:space-between;font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:3px"><span>il y a 14 j</span><span>aujourd'hui</span></div>`;1735  }17361737  function latencyChart(series) {1738    const W = 340, H = 130, PL = 38, PB = 18, PT = 8;1739    if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';1740    const ok = series.filter(p => p.ok);1741    const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;1742    const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);1743    const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);1744    let d = '', pen = false;1745    const pts = [];1746    for (const p of series) {1747      if (!p.ok) { pen = false; continue; }1748      const px = x(p.t), py = y(p.ms);1749      d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);1750      pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });1751      pen = true;1752    }1753    const failMarks = series.filter(p => !p.ok).map(p =>1754      `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');1755    const gridLines = [0.25, 0.5, 0.75, 1].map(f => {1756      const gy = y(max * f / 1.15);1757      return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>1758        <text x="${PL - 5}" y="${gy + 3.5}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${Math.round(max * f / 1.15)}</text>`;1759    }).join('');1760    const lastPt = pts[pts.length - 1];1761    const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>1762      <text x="${Math.min(lastPt.x, W - 40)}" y="${Math.max(lastPt.y - 8, 10)}" font-size="10" font-weight="800" fill="var(--ink)" font-family="var(--mono)">${lastPt.ms} ms</text>` : '';1763    const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);1764    const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';1765    return `<div style="position:relative">1766      <svg class="chart lat-chart" viewBox="0 0 ${W} ${H}" data-pts='${JSON.stringify(pts.map(p => ({ x: +p.x.toFixed(1), y: +p.y.toFixed(1), ms: p.ms, t: p.t })))}'>1767        ${gridLines}1768        <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>1769        ${failMarks}${lastLbl}1770        <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>1771        <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>1772      </svg></div>`;1773  }1774  function attachChartTips() {1775    document.querySelectorAll('.lat-chart').forEach((svg) => {1776      const pts = JSON.parse(svg.dataset.pts || '[]');1777      if (!pts.length) return;1778      const wrap = svg.parentElement;1779      let tip = null;1780      const show = (clientX) => {1781        const rect = svg.getBoundingClientRect();1782        const vx = (clientX - rect.left) / rect.width * 340;1783        let best = pts[0];1784        for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;1785        if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }1786        tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;1787        tip.style.left = (best.x / 340 * rect.width) + 'px';1788        tip.style.top = (best.y / 130 * rect.height) + 'px';1789      };1790      svg.addEventListener('pointerdown', (e) => show(e.clientX));1791      svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });1792      svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });1793    });1794  }17951796  // rafraîchissement périodique de l'onglet éco (en plus du push)1797  setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);1798  // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) :1799  // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours1800  document.addEventListener('visibilitychange', () => {1801    if (document.hidden) return;1802    if (!ws || ws.readyState !== 1) {1803      wsRetry = 500;1804      try { ws && ws.close(); } catch {}1805      connect();1806    } else if (currentChat) {1807      send({ type: 'open', chatId: currentChat.id });1808    }1809    if (curView === 'eco') loadEco();1810  });1811  window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); });18121813  // ---------- feuille ----------1814  function openSheet(html) {1815    $('sheetBody').innerHTML = html;1816    $('sheet').classList.remove('hidden');1817  }1818  function closeSheet() { $('sheet').classList.add('hidden'); }1819  $('sheetBack').onclick = closeSheet;18201821  // ---------- réglages ----------1822  function refreshSettingsView() {1823    document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));1824    document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));1825    $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';1826    $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';1827    $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';1828  }1829  document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });1830  document.querySelectorAll('#segPerm button').forEach(b => {1831    b.onclick = () => {1832      if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;1833      applyOpts({ permMode: b.dataset.v });1834    };1835  });1836  $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };1837  $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };1838  $('logoutBtn').onclick = async () => {1839    wsWanted = false;1840    await fetch('/api/logout', { method: 'POST' });1841    location.reload();1842  };18431844  // ---------- réglages analytics ----------1845  window.loadAnCfg = async function () {1846    const box = $('anCfg');1847    if (!box) return;1848    try {1849      const c = await (await fetch('/api/analytics/config')).json();1850      box.innerHTML = `1851        <div class="set-row"><span>Fenêtre de session</span><span><input id="acSess" class="ac-num" type="number" min="5" max="240" value="${c.sessionMin}"> min</span></div>1852        <div class="set-row"><span>Fenêtre « en ligne »</span><span><input id="acAct" class="ac-num" type="number" min="30" max="900" value="${c.activeSec}"> s</span></div>1853        <div class="set-row"><span>Rétention des événements</span><span><input id="acRet" class="ac-num" type="number" min="7" max="365" value="${c.retentionDays}"> jours</span></div>1854        <div class="set-row"><span>Seuil anti-flood par IP</span><span><input id="acFlood" class="ac-num" type="number" min="100" max="100000" value="${c.maxIpPerHour}"> evt/h</span></div>1855        <div class="set-label" style="margin-top:10px">IPs internes exclues (préfixes, une par ligne)</div>1856        <textarea id="acIps" class="pe-textarea" style="min-height:56px">${esc((c.internalIps || []).join('\n'))}</textarea>1857        <div class="set-label" style="margin-top:10px">User-Agents internes / bots additionnels (fragments)</div>1858        <textarea id="acUas" class="pe-textarea" style="min-height:56px" placeholder="UA internes…">${esc((c.internalUAs || []).join('\n'))}</textarea>1859        <textarea id="acBots" class="pe-textarea" style="min-height:56px;margin-top:6px" placeholder="UA bots custom…">${esc((c.extraBotUAs || []).join('\n'))}</textarea>1860        <div class="set-label" style="margin-top:10px">ASNs exclus (numéros, un par ligne)</div>1861        <textarea id="acAsn" class="pe-textarea" style="min-height:44px">${esc((c.blockedASNs || []).join('\n'))}</textarea>1862        <div class="set-actions"><button id="acSave" class="btn-cta small">Enregistrer</button><span id="acMsg" class="soc-sub"></span></div>`;1863      $('acSave').onclick = async () => {1864        const lines = (id2) => $(id2).value.split('\n').map(s => s.trim()).filter(Boolean);1865        const body = {1866          sessionMin: +$('acSess').value, activeSec: +$('acAct').value,1867          retentionDays: +$('acRet').value, maxIpPerHour: +$('acFlood').value,1868          internalIps: lines('acIps'), internalUAs: lines('acUas'),1869          extraBotUAs: lines('acBots'), blockedASNs: lines('acAsn').map(Number).filter(Boolean),1870        };1871        const r2 = await fetch('/api/analytics/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });1872        $('acMsg').textContent = r2.ok ? '✓ enregistré et appliqué' : '✗ échec';1873        setTimeout(() => { $('acMsg').textContent = ''; }, 3000);1874      };1875    } catch { box.innerHTML = '<div class="soc-sub">Config indisponible.</div>'; }1876  };18771878  // ---------- login / init ----------1879  // Sélecteur d'app : rempli depuis /api/projects (dérivé du registre mld) et RE-rempli1880  // quand le serveur annonce un changement de topologie (message WS `projects`).1881  function fillProjects(projects) {1882    const keep = projectSel.value || settings.projectId;1883    projectSel.innerHTML = '';1884    const allOpt = document.createElement('option');1885    allOpt.value = 'ALL'; allOpt.textContent = '🌐 Tous les sites KA (' + Math.max(0, projects.length - 1) + ')';1886    projectSel.appendChild(allOpt);1887    for (const p of projects) {1888      const o = document.createElement('option');1889      o.value = p.id; o.textContent = p.name + ' · ' + p.node;1890      projectSel.appendChild(o);1891    }1892    // un ancien id « app@nœud » reste valide côté serveur (résolu par app) ; côté UI on1893    // retrouve l'option de la même app si elle a changé de nœud1894    if (keep) {1895      const app = String(keep).split('@')[0];1896      const match = [...projectSel.options].find(o => o.value === keep) || [...projectSel.options].find(o => o.value.split('@')[0] === app);1897      if (match) { projectSel.value = match.value; if (settings.projectId !== match.value) { settings.projectId = match.value; saveStore(); } }1898    }1899    if (typeof refreshChips === 'function') refreshChips();1900  }1901  async function loadRegistryInfo() {1902    const el = $('setRegistry'), en = $('setRegistryNodes');1903    if (!el) return;1904    try {1905      const r = await (await fetch('/api/registry')).json();1906      const when = r.updated ? r.updated.replace('T', ' ') : 'jamais reçu';1907      const age = r.ageMs != null ? Math.round(r.ageMs / 60000) : null;1908      el.textContent = r.error ? `⚠ ${r.error}` : `${r.sites.length} apps Ka / ${r.nApps} au registre · mld ${when}${age != null ? ` · lu il y a ${age} min` : ''}${r.stale ? ' · ⚠ PÉRIMÉ' : ''}`;1909      el.title = `source : ${r.source || '?'} · cache ${r.cache || ''}${r.missing?.length ? ` · absentes du registre : ${r.missing.join(', ')}` : ''}`;1910      if (en) en.textContent = (r.nodes || []).map(n => n === r.self ? n + ' (console)' : n).join(' · ');1911    } catch (e) { el.textContent = 'indisponible'; }1912  }1913  async function init() {1914    const me = await fetch('/api/me');1915    if (!me.ok) {1916      $('login').classList.remove('hidden');1917      $('app').classList.add('hidden');1918      return;1919    }1920    const info = await me.json();1921    $('setHost').textContent = (info.node || '?') + ':3300';1922    $('login').classList.add('hidden');1923    $('app').classList.remove('hidden');1924    const pr = await fetch('/api/projects');1925    const { projects } = await pr.json();1926    fillProjects(projects);1927    loadRegistryInfo();1928    const rb = $('btnRegRefresh');1929    if (rb) rb.onclick = async () => {1930      rb.disabled = true; rb.textContent = '↻ lecture…';1931      try {1932        const r = await fetch('/api/registry/refresh', { method: 'POST' });1933        const d = await r.json();1934        banner(d.ok ? 'good' : 'bad', d.ok ? `Registre relu (${esc(d.source || '')})${d.events?.length ? ' — ' + d.events.length + ' changement(s)' : ''}` : 'Registre injoignable : ' + esc(d.error || '?'));1935        const pr2 = await fetch('/api/projects'); fillProjects((await pr2.json()).projects || []);1936      } catch (e) { banner('bad', 'Échec : ' + e.message); }1937      rb.disabled = false; rb.textContent = '↻ Relire le registre (M1M32)'; loadRegistryInfo();1938    };1939    const tb = $('btnRegTooling');1940    if (tb) tb.onclick = async () => {1941      tb.disabled = true; tb.textContent = '🧰 vérification…';1942      try {1943        const d = await (await fetch('/api/registry/tooling', { method: 'POST' })).json();1944        const lines = (d.nodes || []).map(n => `${n.node} : ${n.local ? 'console (local)' : n.error ? '✗ ' + n.error : (n.actions && n.actions.length ? n.actions.join(' ; ') : 'ok')}`);1945        banner('good', lines.map(esc).join('<br>'), null, 15000);1946      } catch (e) { banner('bad', 'Échec : ' + e.message); }1947      tb.disabled = false; tb.textContent = '🧰 Vérifier l\'outillage des nœuds';1948    };1949    projectSel.onchange = () => {1950      settings.projectId = projectSel.value; saveStore(); refreshChips();1951      inputEl.placeholder = projectSel.value === 'ALL' ? 'Prompt diffusé à TOUTES les apps KA…'1952        : projectSel.value === 'ORCH' ? 'Tâche multi-sites — 1 session qui coordonne tout…'1953        : 'Demander à Claude Code…';1954    };1955    refreshChips();1956    refreshSettingsView();1957    loadWelcomeRecent();1958    // reprise automatique : on rouvre la dernière conversation (historique +1959    // live si une tâche tourne encore) même après fermeture complète de l'app1960    if (settings.lastChatId) currentChat = { id: settings.lastChatId };1961    connect();1962    // page d'accueil = Vue d'ensemble (le chat garde sa reprise en arrière-plan)1963    switchView('overview');1964  }1965  $('loginBtn').onclick = doLogin;1966  $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });1967  async function doLogin() {1968    const r = await fetch('/api/login', {1969      method: 'POST',1970      headers: { 'Content-Type': 'application/json' },1971      body: JSON.stringify({ password: $('password').value }),1972    });1973    if (r.ok) { $('password').value = ''; init(); }1974    else {1975      const d = await r.json().catch(() => ({}));1976      $('loginError').textContent = d.error || 'Erreur';1977    }1978  }19791980  init();1981})();198219831984198519861987// ===== STUDIO_SOCIAL v3 (générateurs + galerie + loading) =====1988(function(){1989  const $ = (id) => document.getElementById(id);1990  const SITE_LABEL = {"lou-ka":"Lou·Ka","immo-ka":"Immo·Ka","vrai-prix":"Vrai-Prix","auto-ka":"Auto·Ka","food-ka":"Food·Ka","fabri-ka":"Fabri·Ka","sorti-ka":"Sorti·Ka","job-ka":"Job·Ka",};1991  // overlay de chargement1992  let ov;1993  function showLoading(msg){1994    if(!ov){ ov=document.createElement('div'); ov.id='stLoad';1995      ov.innerHTML='<div class="stload-box"><div class="stload-spin"></div><div class="stload-msg"></div><div class="stload-sub">Ça peut prendre un moment…</div></div>';1996      document.body.appendChild(ov); }1997    ov.querySelector('.stload-msg').textContent=msg||'Génération…';1998    ov.classList.add('on');1999  }2000  function hideLoading(){ if(ov) ov.classList.remove('on'); }2001  async function copyText(txt, btn){2002    try { await navigator.clipboard.writeText(txt); }2003    catch(e){ const t=document.createElement('textarea'); t.value=txt; document.body.appendChild(t); t.select(); try{document.execCommand('copy');}catch(_){}2004      document.body.removeChild(t); }2005    if(btn){ const o=btn.innerHTML; btn.innerHTML='✓ Copié'; setTimeout(()=>btn.innerHTML=o,1400); }2006  }2007  function esc(s){ return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }2008  function renderGallery(items, generating){2009    const el=$('stGallery'); if(!el) return;2010    if($('stCount')) $('stCount').textContent = items.length ? (items.length+' visuel'+(items.length>1?'s':'')) : '';2011    let html = '';2012    if(generating){ html += '<div class="st-item st-skel"><div class="st-media"><div class="st-shimmer"></div><span class="st-badge">⏳ en création…</span></div><div class="st-cap">L\'Agent KA génère un visuel…</div></div>'; }2013    if(!items.length && !generating){ el.innerHTML='<div class="soc-sub">Aucun visuel encore — génère ton premier post ou reel ci-dessus.</div>'; return; }2014    html += items.map(function(it,idx){2015      const media = it.kind==='reel'2016        ? '<video src="'+it.url+'" muted loop playsinline preload="metadata" onmouseover="this.play()" onmouseout="this.pause()"></video>'2017        : '<img src="'+it.url+'" loading="lazy">';2018      const badge = (SITE_LABEL[it.site]||it.site)+(it.kind==='reel'?' · Reel':' · Post');2019      return '<div class="st-item"><div class="st-media">'+media+'<span class="st-badge">'+esc(badge)+'</span></div>'2020        + '<div class="st-cap" id="stcap'+idx+'">'+esc(it.caption||'')+'</div>'2021        + '<div class="st-item-actions"><a class="btn-cta small" href="'+it.url+'" download="'+it.site+'-'+it.kind+(it.kind==='reel'?'.mp4':'.png')+'">⬇︎</a>'2022        + '<button class="btn-ghost small" data-copy="'+idx+'">📋 Copier</button></div></div>';2023    }).join('');2024    el.innerHTML = html;2025    el.querySelectorAll('[data-copy]').forEach(function(b){ b.onclick=function(){ copyText(items[+b.dataset.copy].caption||'', b); }; });2026  }2027  let polling=false;2028  async function loadGallery(){2029    try{ const d=await (await fetch('/api/social/gallery')).json();2030      const gen = d.gen && d.gen.running;2031      renderGallery(d.items||[], gen);2032      if($('stGenStatus')) $('stGenStatus').textContent = gen ? '⏳ génération des reels en cours…' : '';2033      if(gen && !polling){ polling=true; setTimeout(function(){ polling=false; loadGallery(); }, 10000); }2034    }catch(e){}2035  }2036  async function genOne(url, body, msg){2037    showLoading(msg);2038    try{ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});2039      const d=await r.json(); if(!d.draft){ alert('Échec : '+(d.error||'?')); } else { await loadGallery(); }2040    }catch(e){ alert('Échec : '+e.message); }2041    hideLoading();2042  }2043  function bind(){2044    if($('stPostGen')) $('stPostGen').onclick=()=>genOne('/api/social/generate',{site:$('stPostSite').value,prompt:$('stPostPrompt').value},'🖼️ Génération de l\'image…');2045    if($('stReelGen')) $('stReelGen').onclick=()=>genOne('/api/social/reel/generate',{site:$('stReelSite').value,prompt:$('stReelPrompt').value},'🎬 Génération du reel (vidéo + musique)…');2046    if($('stReel10')) $('stReel10').onclick=async ()=>{2047      try{ const r=await fetch('/api/social/reel/genbatch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});2048        const d=await r.json(); if($('stGenStatus')) $('stGenStatus').textContent=d.started?'⏳ 10 reels en génération…':(d.reason||'déjà en cours'); loadGallery();2049      }catch(e){ alert('Échec : '+e.message); }2050    };2051    if($('stRefresh')) $('stRefresh').onclick=loadGallery;2052  }2053  bind();2054  window.__loadGallery = loadGallery;2055})();20562057