SPB Git

spb/admin-ka

Public
61.5 KB · 1,260 lines javascript
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 ----------54  const views = ['chat', 'sessions', 'prompts', 'eco', 'settings'];55  function switchView(name) {56    curView = name;57    for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name);58    document.querySelectorAll('.nav-btn').forEach((b) => {59      b.classList.toggle('active', b.dataset.view === name);60      if (b.dataset.view === name) b.querySelector('.badge').classList.add('hidden');61    });62    if (name === 'sessions') loadSessions();63    if (name === 'prompts') loadPrompts();64    if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }65    if (name === 'settings') refreshSettingsView();66  }67  document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });68  function navBadge(view) {69    if (curView === view) return;70    const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);71    if (b) b.classList.remove('hidden');72  }7374  // ---------- bannières ----------75  function banner(kind, html, onclick, ttl = 6000) {76    const b = el('div', 'banner ' + kind, html);77    if (onclick) b.onclick = () => { onclick(); b.remove(); };78    $('banners').appendChild(b);79    if (navigator.vibrate) navigator.vibrate(60);80    setTimeout(() => b.remove(), ttl);81  }8283  // ---------- scroll / messages ----------84  function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }85  function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }86  function addMsg(node) {87    $('welcome').classList.add('hidden');88    const wasNear = nearBottom();89    const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);90    scrollBottom(wasNear);91  }92  function renderMd(container, text) {93    container.innerHTML = marked.parse(text || '');94    container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });95  }96  // bouton copier sur tous les blocs de code (délégué, marche pour tout <pre>)97  messagesEl.addEventListener('click', (e) => {98    const pre = e.target.closest('pre');99    if (!pre || e.target.closest('button') || pre.dataset.copying) return;100    if (window.getSelection().toString()) return;101    navigator.clipboard.writeText(pre.textContent).then(() => {102      pre.dataset.copying = '1';103      const tag = el('span', 'copied-tag', 'copié ✓');104      pre.appendChild(tag);105      setTimeout(() => { tag.remove(); delete pre.dataset.copying; }, 1200);106    }).catch(() => {});107  });108  function truncPre(text, limit = 3000) {109    const pre = el('pre');110    const full = String(text ?? '');111    if (full.length <= limit) { pre.textContent = full; return pre; }112    pre.textContent = full.slice(0, limit);113    const wrap = el('div');114    const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');115    btn.onclick = () => { pre.textContent = full; btn.remove(); };116    wrap.append(pre, btn);117    return wrap;118  }119120  function workingEl() { return messagesEl.querySelector('.working'); }121  let workTimer = null, workStart = 0;122  function setRunning(r) {123    isRunning = r;124    sendBtn.classList.toggle('hidden', r);125    stopBtn.classList.toggle('hidden', !r);126    let w = workingEl();127    if (r && !w) {128      $('welcome').classList.add('hidden');129      w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span><b class="work-time"></b>');130      messagesEl.appendChild(w);131      scrollBottom(true);132      workStart = Date.now();133      clearInterval(workTimer);134      workTimer = setInterval(() => {135        const t = w.querySelector('.work-time');136        if (!t) { clearInterval(workTimer); return; }137        const s = Math.floor((Date.now() - workStart) / 1000);138        t.textContent = s >= 60 ? Math.floor(s / 60) + ' min ' + (s % 60) + ' s' : s + ' s';139      }, 1000);140    } else if (!r && w) { w.remove(); clearInterval(workTimer); }141    if (!r) {142      finalizeLive(); finalizeThink();143      flushQueued();144    }145  }146  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' };147  function setWorkingLabel(name, sub) {148    const w = workingEl(); if (!w) return;149    const span = w.querySelector('span'); if (!span) return;150    const verb = WORK_VERB[name] || 'utilise ' + name;151    const detail = sub ? ' ' + sub.slice(0, 46) : '';152    span.textContent = `Claude ${verb}${detail}…`;153  }154  // file d'attente : un message tapé pendant l'exécution part dès que Claude a fini155  let queuedPrompt = null;156  function flushQueued() {157    if (!queuedPrompt) return;158    const q = queuedPrompt; queuedPrompt = null;159    const n = messagesEl.querySelector('.queued-notice'); if (n) n.remove();160    doSend(q);161  }162163  // ---------- streaming live ----------164  function ensureLive() {165    if (live) return live;166    const w = workingEl(); if (w) { const s = w.querySelector('span'); if (s) s.textContent = 'Claude rédige…'; }167    const wrap = el('div', 'msg-claude');168    const md = el('div', 'md');169    wrap.appendChild(md);170    addMsg(wrap);171    live = { el: wrap, textEl: md, text: '' };172    return live;173  }174  function finalizeLive() { if (live) { live.el.remove(); live = null; } }175  function ensureThink() {176    if (liveThink) return liveThink;177    const box = el('div', 'thinking');178    addMsg(box);179    liveThink = { el: box, text: '' };180    return liveThink;181  }182  function finalizeThink() {183    if (!liveThink) return;184    const d = el('details', 'thinking-box', '<summary>réflexion</summary>');185    const inner = el('div', 'thinking'); inner.textContent = liveThink.text;186    d.appendChild(inner);187    liveThink.el.replaceWith(d);188    liveThink = null;189  }190191  // ---------- cartes outils ----------192  const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };193  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' };194  function toolSubtitle(name, input) {195    input = input || {};196    if (name === 'Bash') return input.command || '';197    if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');198    if (input.pattern) return input.pattern;199    if (input.url) return input.url;200    if (name === 'Task') return input.description || '';201    if (name === 'TodoWrite') return 'liste de tâches';202    return '';203  }204  function diffBlock(oldStr, newStr) {205    const d = el('div', 'diff');206    for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));207    for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));208    return d;209  }210  function toolBody(name, input) {211    const body = el('div', 'tool-body');212    input = input || {};213    if (name === 'Bash') {214      body.appendChild(el('div', 'label', 'commande'));215      body.appendChild(truncPre(input.command));216      if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));217    } else if (name === 'Edit') {218      body.appendChild(diffBlock(input.old_string, input.new_string));219    } else if (name === 'MultiEdit') {220      for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));221    } else if (name === 'Write') {222      body.appendChild(el('div', 'label', 'contenu'));223      const d = el('div', 'diff');224      const lines = String(input.content || '').split('\n');225      for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));226      if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));227      body.appendChild(d);228    } else if (name === 'TodoWrite') {229      const ul = el('ul', 'todos');230      for (const t of input.todos || []) {231        const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';232        ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));233      }234      body.appendChild(ul);235    } else if (name === 'Task') {236      body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));237    } else if (name === 'ExitPlanMode') {238      const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);239    } else {240      body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));241    }242    return body;243  }244  // outils dont on ouvre la carte par défaut (on veut VOIR ce qui est fait)245  const OPEN_BY_DEFAULT = new Set(['Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'ExitPlanMode']);246  function addToolCard(block) {247    finalizeLive(); finalizeThink();248    const name = block.name || '?';249    const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;250    const card = el('details', 'tool');251    if (OPEN_BY_DEFAULT.has(name)) card.open = true;252    const sum = el('summary');253    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>`;254    card.appendChild(sum);255    card.appendChild(toolBody(name, block.input));256    addMsg(card);257    toolCards.set(block.id, { card, name, stateEl: sum.querySelector('.t-state'), subEl: sum.querySelector('.t-sub'), bodyEl: card.querySelector('.tool-body') });258    // met à jour l'indicateur « en cours » avec l'action réelle259    setWorkingLabel(name, toolSubtitle(name, block.input));260  }261  function attachToolResult(block) {262    const t = toolCards.get(block.tool_use_id);263    if (!t) return;264    t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');265    t.stateEl.textContent = block.is_error ? '✗ erreur' : '✓';266    let text = '';267    if (typeof block.content === 'string') text = block.content;268    else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');269    text = text.trim();270    if (text) {271      // les cartes repliées (Read/Grep/…) montrent un aperçu du résultat dans l'en-tête272      if (!t.card.open && t.subEl) {273        const firstLine = text.split('\n').find(l => l.trim()) || '';274        t.subEl.textContent = firstLine.slice(0, 80);275      }276      t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));277      t.bodyEl.appendChild(truncPre(text));278    } else {279      t.bodyEl.appendChild(el('div', 'meta-line', block.is_error ? 'erreur (vide)' : 'terminé sans sortie'));280    }281  }282283  // ---------- permissions ----------284  function addPermCard(ev) {285    finalizeLive(); finalizeThink();286    const card = el('div', 'perm');287    const isPlan = ev.tool === 'ExitPlanMode';288    card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;289    if (isPlan && ev.input?.plan) {290      const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);291    } else {292      const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);293    }294    const actions = el('div', 'perm-actions');295    const deny = el('button', 'perm-deny', 'Refuser');296    const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');297    actions.append(deny, allow);298    if (!isPlan) {299      const always = el('button', 'perm-always', 'Toujours<br><small>cet outil</small>');300      always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });301      actions.insertBefore(always, allow);302      // « Tout autoriser » : le serveur approuve TOUTES les permissions de la303      // session — la tâche va au bout même téléphone rangé304      const all = el('button', 'perm-allall', '🔓 Tout autoriser — finir la tâche sans redemander');305      all.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, allowAll: true });306      card.appendChild(actions);307      card.appendChild(all);308    } else {309      card.appendChild(actions);310    }311    deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });312    allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });313    addMsg(card);314    permCards.set(ev.requestId, card);315    scrollBottom(true);316    if (navigator.vibrate) navigator.vibrate([60, 60, 60]);317  }318  function resolvePermCard(ev) {319    const card = permCards.get(ev.requestId);320    if (!card) return;321    const actions = card.querySelector('.perm-actions');322    if (actions) actions.remove();323    const allBtn = card.querySelector('.perm-allall');324    if (allBtn) allBtn.remove();325    card.appendChild(el('div', 'perm-resolved',326      ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));327  }328329  // ---------- rendu des événements ----------330  function renderEvent(ev) {331    if (ev.type === 'user_prompt') {332      finalizeLive(); finalizeThink();333      const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);334      return;335    }336    if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }337    if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }338    if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }339    if (ev.type === 'permission_request') { addPermCard(ev); return; }340    if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }341    if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }342    if (ev.type === 'claude') { renderClaude(ev.event); return; }343  }344345  function renderClaude(ev) {346    if (ev.type === 'system') {347      if (ev.subtype === 'init') {348        addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));349      } else if (ev.subtype === 'compact_boundary') {350        addMsg(el('div', 'meta-line', '⇣ contexte compacté'));351      } else if (ev.subtype && ev.subtype !== 'hook') {352        addMsg(el('div', 'meta-line', esc('⌁ ' + ev.subtype)));353      }354      return;355    }356    if (ev.type === 'stream_event' && ev.event) {357      const se = ev.event;358      if (se.type === 'content_block_delta' && se.delta) {359        if (se.delta.type === 'text_delta') {360          const l = ensureLive();361          l.text += se.delta.text;362          renderMd(l.textEl, l.text);363          scrollBottom();364        } else if (se.delta.type === 'thinking_delta') {365          const t = ensureThink();366          t.text += se.delta.thinking;367          t.el.textContent = t.text.slice(-600);368          scrollBottom();369        }370      }371      return;372    }373    if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {374      for (const block of ev.message.content) {375        if (block.type === 'text' && block.text && block.text.trim()) {376          finalizeLive(); finalizeThink();377          const wrap = el('div', 'msg-claude');378          const md = el('div', 'md');379          renderMd(md, block.text);380          wrap.appendChild(md);381          addMsg(wrap);382        } else if (block.type === 'tool_use') {383          addToolCard(block);384        } else if (block.type === 'thinking' && block.thinking) {385          if (liveThink) { liveThink.el.remove(); liveThink = null; }386          const d = el('details', 'thinking-box', '<summary>réflexion</summary>');387          const inner = el('div', 'thinking'); inner.textContent = block.thinking;388          d.appendChild(inner);389          addMsg(d);390        }391      }392      return;393    }394    if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {395      for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);396      return;397    }398    if (ev.type === 'result') {399      finalizeLive(); finalizeThink();400      const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';401      const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';402      const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';403      addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));404      if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));405      return;406    }407  }408409  // ---------- méta de session (chips + bandeau) ----------410  function refreshChips() {411    const model = currentChat ? currentChat.model : settings.model;412    const perm = currentChat ? currentChat.permMode : settings.permMode;413    $('chipModel').textContent = MODEL_LABEL[model] || model;414    $('chipPerm').textContent = PERM_LABEL[perm] || perm;415    $('chipPerm').classList.toggle('warn', perm === 'bypass');416    $('chipModel').classList.add('orange');417    const meta = $('sessMeta');418    if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {419      meta.classList.remove('hidden');420      $('metaModel').textContent = (currentChat.autoAllowAll ? '🔓 ' : '') + (currentChat.lastModel || MODEL_LABEL[model]);421      $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';422      $('metaCost').textContent = fmt$(currentChat.totalCost);423      $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok (' + Math.min(99, Math.round(currentChat.contextTokens / 2000)) + '%)' : '';424    } else meta.classList.add('hidden');425  }426427  // ---------- WebSocket ----------428  function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }429  function setConn(state) {430    const pill = $('connPill');431    pill.className = 'conn-pill ' + state;432    $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';433  }434  function connect() {435    if (!wsWanted) return;436    setConn('mid');437    const proto = location.protocol === 'https:' ? 'wss' : 'ws';438    ws = new WebSocket(`${proto}://${location.host}/ws`);439    ws.onopen = () => {440      setConn('on');441      wsRetry = 1000;442      if (currentChat) send({ type: 'open', chatId: currentChat.id });443      if (curView === 'eco') send({ type: 'eco_sub' });444    };445    ws.onclose = () => {446      setConn('off');447      setTimeout(connect, wsRetry);448      wsRetry = Math.min(wsRetry * 1.6, 20000);449    };450    ws.onmessage = (e) => {451      let msg;452      try { msg = JSON.parse(e.data); } catch { return; }453      handleServer(msg);454    };455  }456  function rememberChat(id) { settings.lastChatId = id; saveStore(); }457  function handleServer(msg) {458    if (msg.type === 'chat_created') { currentChat = msg.chat; rememberChat(msg.chat.id); refreshChips(); return; }459    if (msg.type === 'chat_meta') {460      if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }461      return;462    }463    if (msg.type === 'history') {464      currentChat = msg.chat;465      rememberChat(msg.chat.id);466      clearConversation(true);467      for (const ev of msg.events) { try { renderEvent(ev); } catch {} }468      setRunning(msg.running);469      for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;470      refreshChips();471      scrollBottom(true);472      return;473    }474    if (msg.type === 'notify') {475      if (msg.kind === 'done') {476        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {477          banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));478          navBadge('chat'); navBadge('sessions');479        }480      } else if (msg.kind === 'perm') {481        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {482          banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);483          navBadge('chat');484        }485      }486      return;487    }488    if (msg.type === 'eco_alert') {489      if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);490      else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));491      navBadge('eco');492      if (curView === 'eco') loadEco();493      return;494    }495    if (msg.type === 'eco') {496      if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);497      return;498    }499    renderEvent(msg);500  }501502  function clearConversation(keepChat) {503    messagesEl.innerHTML = '';504    toolCards.clear(); permCards.clear();505    live = null; liveThink = null;506    if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');507  }508  function openChat(chatId) {509    switchView('chat');510    currentChat = { id: chatId };511    clearConversation(true);512    send({ type: 'open', chatId });513  }514515  // ---------- composer ----------516  function doSend(text) {517    send({518      type: 'start',519      chatId: currentChat ? currentChat.id : null,520      project: projectSel.value,521      model: currentChat ? currentChat.model : settings.model,522      permMode: currentChat ? currentChat.permMode : settings.permMode,523      prompt: text,524    });525  }526  function sendPrompt() {527    const text = inputEl.value.trim();528    if (!text) return;529    // commandes slash locales530    if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }531    if (text === '/cost') {532      if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));533      inputEl.value = ''; autoGrow(); return;534    }535    if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }536    inputEl.value = '';537    autoGrow();538    hideSlash();539    if (isRunning) {540      // Claude travaille : le message part automatiquement dès la fin du tour541      queuedPrompt = queuedPrompt ? queuedPrompt + '\n' + text : text;542      let n = messagesEl.querySelector('.queued-notice');543      if (!n) { n = el('div', 'notice-line queued-notice'); messagesEl.appendChild(n); }544      n.textContent = '⏳ en attente de la fin du tour : « ' + queuedPrompt.slice(0, 80) + ' »';545      scrollBottom(true);546      return;547    }548    doSend(text);549  }550  sendBtn.onclick = sendPrompt;551  stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };552  inputEl.addEventListener('keydown', (e) => {553    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }554  });555  function autoGrow() {556    inputEl.style.height = 'auto';557    inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';558  }559  inputEl.addEventListener('input', () => {560    autoGrow();561    if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();562  });563  inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));564565  function showSlash() {566    const h = $('slashHint');567    h.classList.remove('hidden');568    if (!h.childElementCount) {569      for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {570        const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);571        b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };572        h.appendChild(b);573      }574    }575  }576  function hideSlash() { $('slashHint').classList.add('hidden'); }577578  document.querySelectorAll('.hint').forEach((h) => {579    h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };580  });581582  // ---------- upgradeur de prompt (Opus 4.8 + savoir Groupe KA) ----------583  const upgradeBtn = $('upgradeBtn');584  let upgrading = false;585  upgradeBtn.onclick = async () => {586    const raw = inputEl.value.trim();587    if (!raw || upgrading) return;588    upgrading = true;589    upgradeBtn.classList.add('busy');590    const original = inputEl.value;591    inputEl.disabled = true;592    inputEl.value = '✨ amélioration du prompt avec Opus 4.8…';593    try {594      const r = await fetch('/api/upgrade-prompt', {595        method: 'POST',596        headers: { 'Content-Type': 'application/json' },597        body: JSON.stringify({ prompt: raw, project: projectSel.value }),598      });599      const d = await r.json();600      if (r.ok && d.upgraded) {601        inputEl.value = d.upgraded;602        if (navigator.vibrate) navigator.vibrate(40);603      } else {604        inputEl.value = original;605        banner('bad', '✗ Upgrade impossible : ' + esc(d.error || 'erreur'), null, 5000);606      }607    } catch (e) {608      inputEl.value = original;609      banner('bad', '✗ Upgrade impossible (réseau)', null, 5000);610    } finally {611      inputEl.disabled = false;612      upgrading = false;613      upgradeBtn.classList.remove('busy');614      autoGrow();615      inputEl.focus();616    }617  };618619  // ---------- chips ----------620  $('chipModel').onclick = () => {621    const order = ['fable', 'opus', 'sonnet', 'haiku'];622    const cur = currentChat ? currentChat.model : settings.model;623    const next = order[(order.indexOf(cur) + 1) % order.length];624    applyOpts({ model: next });625  };626  $('chipPerm').onclick = () => {627    const order = ['default', 'acceptEdits', 'plan', 'bypass'];628    const cur = currentChat ? currentChat.permMode : settings.permMode;629    const next = order[(order.indexOf(cur) + 1) % 4];630    if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;631    applyOpts({ permMode: next });632  };633  function applyOpts(opts) {634    if (currentChat) {635      Object.assign(currentChat, opts);636      send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });637    }638    if (opts.model) settings.model = opts.model;639    if (opts.permMode) settings.permMode = opts.permMode;640    saveStore();641    refreshChips();642    refreshSettingsView();643  }644  $('chipMore').onclick = () => {645    const c = currentChat;646    openSheet(`647      <div class="sheet-h"><h3>Session</h3></div>648      <div class="sheet-sec">649        <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>650        <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>651        <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>652        <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>653        <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>654      </div>655      <div class="sheet-sec">656        <div class="set-actions">657          <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>658          <button class="btn-ghost" id="shCompact">/compact</button>659          <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>660        </div>661      </div>`);662    $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };663    $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };664    $('shNew').onclick = () => { closeSheet(); newChat(); };665  };666  function newChat() {667    currentChat = null;668    queuedPrompt = null;669    settings.lastChatId = null; saveStore();670    clearConversation();671    setRunning(false);672    refreshChips();673    switchView('chat');674  }675676  // ---------- sessions ----------677  async function loadSessions() {678    const r = await fetch('/api/chats');679    if (!r.ok) return;680    const { chats } = await r.json();681    const list = $('sessList');682    list.innerHTML = '';683    if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }684    const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };685    for (const c of chats) {686      const card = el('div', 'sess-card');687      card.innerHTML = `688        <div class="sess-top">689          <span class="sess-title">${esc(c.title)}</span>690          <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>691        </div>692        <div class="sess-info">693          <span>${esc(c.projectName || '')}</span>694          <span>${MODEL_LABEL[c.model] || c.model}</span>695          <span>${fmt$(c.totalCost)}</span>696          <span>${timeAgo(c.updated)}</span>697        </div>`;698      const actions = el('div', 'sess-actions');699      const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');700      open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };701      actions.appendChild(open);702      if (c.state === 'idle') {703        const del = el('button', 'del', 'Supprimer');704        del.onclick = async (e) => {705          e.stopPropagation();706          if (!confirm('Supprimer cette session ?')) return;707          await fetch('/api/chats/' + c.id, { method: 'DELETE' });708          card.remove();709          if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }710        };711        actions.appendChild(del);712      }713      card.appendChild(actions);714      card.onclick = () => openChat(c.id);715      list.appendChild(card);716    }717  }718  $('newSessBtn').onclick = newChat;719720  // ---------- Prompt Bank ----------721  function projectOptionsHTML(selected) {722    return [...projectSel.options].map(o =>723      `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.textContent)}</option>`).join('');724  }725  async function loadPrompts() {726    const r = await fetch('/api/prompts');727    if (!r.ok) return;728    const { prompts } = await r.json();729    const list = $('promptList');730    list.innerHTML = '';731    if (!prompts.length) { list.appendChild(el('div', 'sess-empty', 'Aucun prompt enregistré. « + Nouveau » pour en créer un — tu pourras l’améliorer et l’envoyer plus tard.')); return; }732    for (const pr of prompts) {733      const projName = pr.project ? ([...projectSel.options].find(o => o.value === pr.project)?.textContent || pr.project) : 'app au choix';734      const card = el('div', 'sess-card');735      card.innerHTML = `736        <div class="sess-top"><span class="sess-title">${esc(pr.title)}</span></div>737        <div class="prompt-preview">${esc(pr.text.slice(0, 220))}${pr.text.length > 220 ? '…' : ''}</div>738        <div class="sess-info"><span>🎯 ${esc(projName)}</span><span>${timeAgo(pr.updated)}</span></div>`;739      const actions = el('div', 'sess-actions');740      const sendB = el('button', 'p-send', 'Envoyer →');741      const editB = el('button', '', '✎ Éditer');742      const upB = el('button', '', '✨ Améliorer');743      const delB = el('button', 'del', 'Suppr.');744      actions.append(sendB, upB, editB, delB);745      card.appendChild(actions);746      sendB.onclick = () => openSendSheet(pr);747      editB.onclick = () => openPromptEditor(pr);748      delB.onclick = async () => { if (!confirm('Supprimer ce prompt ?')) return; await fetch('/api/prompts/' + pr.id, { method: 'DELETE' }); card.remove(); };749      upB.onclick = async () => {750        upB.textContent = '✨ …'; upB.disabled = true;751        try {752          const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: pr.text, project: pr.project }) });753          const d = await rr.json();754          if (rr.ok && d.upgraded) {755            await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: d.upgraded }) });756            banner('good', '✨ Prompt amélioré et enregistré', null, 3000);757            loadPrompts();758          } else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);759        } catch { banner('bad', '✗ réseau', null, 4000); }760        upB.textContent = '✨ Améliorer'; upB.disabled = false;761      };762      list.appendChild(card);763    }764  }765  $('newPromptBtn').onclick = () => openPromptEditor(null);766767  function openPromptEditor(pr) {768    const isNew = !pr;769    openSheet(`770      <div class="sheet-h"><h3>${isNew ? 'Nouveau prompt' : 'Éditer le prompt'}</h3></div>771      <div class="sheet-sec">772        <input id="peTitle" class="pe-input" placeholder="Titre (optionnel)" value="${esc(pr?.title || '')}">773        <textarea id="peText" class="pe-textarea" placeholder="Écris ton prompt ici…">${esc(pr?.text || '')}</textarea>774        <div class="set-label" style="margin-top:10px">App par défaut</div>775        <select id="peProject" class="pill-select" style="max-width:100%;width:100%">776          <option value="">— app au choix (demandé à l’envoi) —</option>777          ${projectOptionsHTML(pr?.project || '')}778        </select>779        <div class="set-actions" style="margin-top:12px">780          <button class="btn-ghost" id="peUpgrade">✨ Améliorer (Opus 4.8)</button>781          <button class="btn-cta small" id="peSave">Enregistrer</button>782        </div>783      </div>`);784    $('peUpgrade').onclick = async () => {785      const t = $('peText').value.trim(); if (!t) return;786      const b = $('peUpgrade'); b.textContent = '✨ amélioration…'; b.disabled = true;787      try {788        const rr = await fetch('/api/upgrade-prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: t, project: $('peProject').value }) });789        const d = await rr.json();790        if (rr.ok && d.upgraded) { $('peText').value = d.upgraded; if (navigator.vibrate) navigator.vibrate(40); }791        else banner('bad', '✗ ' + esc(d.error || 'échec'), null, 4000);792      } catch { banner('bad', '✗ réseau', null, 4000); }793      b.textContent = '✨ Améliorer (Opus 4.8)'; b.disabled = false;794    };795    $('peSave').onclick = async () => {796      const text = $('peText').value.trim(); if (!text) { banner('bad', 'Le prompt est vide', null, 3000); return; }797      const payload = { title: $('peTitle').value, text, project: $('peProject').value || null };798      if (isNew) await fetch('/api/prompts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });799      else await fetch('/api/prompts/' + pr.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });800      closeSheet(); loadPrompts();801    };802  }803804  function openSendSheet(pr) {805    openSheet(`806      <div class="sheet-h"><h3>Envoyer à Claude Code</h3></div>807      <div class="sheet-sec">808        <div class="prompt-preview" style="max-height:180px;overflow:auto">${esc(pr.text)}</div>809      </div>810      <div class="sheet-sec">811        <div class="set-label">App cible (nœud)</div>812        <select id="sendProject" class="pill-select" style="max-width:100%;width:100%">${projectOptionsHTML(pr.project || projectSel.value)}</select>813        <div class="set-label" style="margin-top:10px">Modèle · permissions</div>814        <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>815        <button class="btn-cta" style="width:100%;margin-top:12px" id="sendGo">🚀 Lancer sur Claude Code</button>816      </div>`);817    $('sendGo').onclick = () => {818      const proj = $('sendProject').value;819      closeSheet();820      sendPromptToApp(pr.text, proj);821    };822  }823824  function sendPromptToApp(text, projectId) {825    currentChat = null;826    queuedPrompt = null;827    clearConversation();828    setRunning(false);829    for (const o of projectSel.options) if (o.value === projectId) projectSel.value = o.value;830    switchView('chat');831    refreshChips();832    send({ type: 'start', chatId: null, project: projectId, model: settings.model, permMode: settings.permMode, prompt: text });833    banner('info', '🚀 Envoyé — la réponse arrive dans le Chat', null, 3000);834  }835836  // incidents globaux (bouton en tête de l'onglet Écosystème)837  $('incBtn').onclick = async () => {838    const r = await fetch('/api/eco/incidents');839    if (!r.ok) return;840    const { incidents } = await r.json();841    const label = (app) => (ecoData?.sites.find(s => s.app === app)?.label) || app;842    openSheet(`843      <div class="sheet-h"><h3>Incidents</h3></div>844      <div class="sheet-sec">845        <h4>Historique — 50 derniers</h4>846        ${incidents.length ? incidents.map(i => `847          <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>848          <span><b>${esc(label(i.site))}</b> · ${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}849          ${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('')850          : '<div style="color:var(--muted)">Aucun incident enregistré 🎉</div>'}851      </div>`);852  };853854  // ---------- écosystème ----------855  function siteStatus(s) {856    if (s.incident || (s.last && !s.last.ok)) return 'down';857    if (s.last && s.last.ms > 4000) return 'slow';858    return s.last ? 'up' : 'unknown';859  }860  const ST = {861    up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },862    slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },863    down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },864    unknown: { cls: 'slow', ico: '▲', lbl: '…' },865  };866  function sparkline(spark, w = 120, h = 30) {867    if (!spark || spark.length < 2) return '<svg class="spark"></svg>';868    const ms = spark.map(p => p.ok ? p.ms : null);869    const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;870    const step = w / (spark.length - 1);871    let d = '', pen = false;872    spark.forEach((p, i) => {873      if (!p.ok) { pen = false; return; }874      const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);875      d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);876      pen = true;877    });878    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('');879    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>`;880  }881  async function loadEco() {882    const [r, rn] = await Promise.all([fetch('/api/eco/summary'), fetch('/api/eco/nodes')]);883    if (!r.ok) return;884    ecoData = await r.json();885    renderEco();886    if (rn.ok) renderNodes(await rn.json());887  }888  function nbar(label, val, pct) {889    const c = pct > 88 ? 'var(--bad)' : pct > 70 ? 'var(--warn)' : 'var(--good)';890    return `<div class="nbar"><div class="nbar-l"><span>${label}</span><b>${val}</b></div>891      <div class="nbar-track"><div class="nbar-fill" style="width:${Math.max(2, Math.min(100, pct))}%;background:${c}"></div></div></div>`;892  }893  function renderNodes(data) {894    const grid = $('ecoNodes');895    if (!grid) return;896    grid.innerHTML = '';897    for (const n of data.nodes) {898      const L = n.last;899      const memPct = L && L.mem_total ? Math.round(L.mem_used / L.mem_total * 100) : null;900      const upApps = n.apps.filter(a => a.status === 'online').length;901      const badApp = n.apps.some(a => a.status && a.status !== 'online');902      const card = el('div', 'node-card' + (badApp ? ' warn' : ''));903      card.innerHTML = `904        <div class="node-top"><span class="node-name">${esc(n.node)}</span>905          <span class="node-apps${badApp ? ' bad' : ''}">${upApps}/${n.apps.length} apps</span></div>906        ${nbar('charge', L ? L.load1.toFixed(1) : '—', L ? Math.min(100, L.load1 / 32 * 100) : 0)}907        ${nbar('RAM', memPct !== null ? memPct + '%' : '—', memPct || 0)}908        ${nbar('disque', L ? L.disk_pct + '%' : '—', L ? L.disk_pct : 0)}`;909      grid.appendChild(card);910    }911  }912  function applyChecks(items) {913    if (!ecoData) return;914    for (const it of items) {915      const s = ecoData.sites.find(x => x.app === it.site);916      if (!s) continue;917      s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };918      s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });919      if (s.spark.length > 40) s.spark.shift();920    }921    renderEco();922  }923  function trendBadge(t) {924    if (t == null) return '';925    if (t <= -8) return `<span class="trend down" title="latence en baisse">▼ ${Math.abs(t)}%</span>`;926    if (t >= 8) return `<span class="trend up" title="latence en hausse">▲ ${t}%</span>`;927    return `<span class="trend flat">≈</span>`;928  }929  function statusBar(sd) {930    const total = (sd['2xx'] + sd['3xx'] + sd['4xx'] + sd['5xx'] + sd.err) || 1;931    const seg = (n, cls) => n ? `<i class="${cls}" style="width:${(n / total * 100).toFixed(1)}%" title="${cls} : ${n}"></i>` : '';932    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>`;933  }934  function renderEco() {935    const sites = ecoData.sites;936    const g = ecoData.global || {};937    $('ecoStamp').textContent = 'live · maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });938    // bandeau KPI du command center939    $('ecoOverview').innerHTML = `940      <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>941      <div class="kpi"><div class="v">${g.uptime24 ?? '—'}<small>%</small></div><div class="l">uptime 24h</div></div>942      <div class="kpi"><div class="v">${g.avgLatency}<small>ms</small></div><div class="l">latence moy.</div></div>943      <div class="kpi"><div class="v">${(g.checks24 / 1000).toFixed(1)}<small>k</small></div><div class="l">checks 24h</div></div>944      <div class="kpi ${g.fails24 ? 'bad' : ''}"><div class="v">${g.fails24}</div><div class="l">échecs 24h</div></div>945      <div class="kpi ${g.openIncidents ? 'bad' : ''}"><div class="v">${g.openIncidents}</div><div class="l">incidents</div></div>`;946    const grid = $('ecoGrid');947    grid.innerHTML = '';948    const order = { down: 0, slow: 1, unknown: 2, up: 3 };949    for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)] || (b.last?.ms || 0) - (a.last?.ms || 0))) {950      const st = ST[siteStatus(s)];951      const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));952      card.innerHTML = `953        <div class="site-top">954          <img class="site-logo" src="/icons/${s.app}" alt="" loading="lazy" onerror="this.style.display='none'">955          <span class="site-name">${esc(s.label)}</span>956          <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>957        </div>958        <div class="site-mid">959          <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small> ${trendBadge(s.trend)}</div>960          ${sparkline(s.spark)}961        </div>962        ${statusBar(s.status24 || { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 })}963        <div class="site-metrics">964          <div class="m"><b>${s.uptime24 ?? '—'}%</b><span>uptime</span></div>965          <div class="m"><b>${s.p95_24 ?? '—'}</b><span>p95 ms</span></div>966          <div class="m"><b>${s.p99_24 ?? '—'}</b><span>p99 ms</span></div>967          <div class="m"><b>${((s.checks24 || 0) / 1000).toFixed(1)}k</b><span>checks</span></div>968        </div>969        <div class="site-foot">970          <span>${esc(s.node)}</span>971          ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''}972          ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}973          ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}974        </div>`;975      card.onclick = () => openSiteSheet(s.app);976      grid.appendChild(card);977    }978  }979980  async function openSiteSheet(app) {981    const r = await fetch('/api/eco/site/' + app);982    if (!r.ok) return;983    const d = await r.json();984    const s = ecoData.sites.find(x => x.app === app) || {};985    const st = ST[siteStatus(s)];986    const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;987    const L = d.lat24 || {}; const C = d.counts24 || {}; const IS = d.incidentStats || {}; const SD = d.status24 || {};988    openSheet(`989      <div class="sheet-h">990        <img class="site-logo big" src="/icons/${d.app}" alt="" onerror="this.style.display='none'">991        <h3>${esc(d.label)}</h3>992        <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>993      </div>994      <div class="sheet-sec">995        <h4>Disponibilité</h4>996        <div class="tile-row">997          <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>998          <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>999          <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>1000        </div>1001        <div style="margin-top:10px">${uptimeDays(d.days)}</div>1002      </div>1003      <div class="sheet-sec">1004        <h4>Latence — percentiles 24 h ${trendBadge(d.trend)}</h4>1005        ${latencyChart(d.series)}1006        <div class="pgrid">1007          <div class="pc"><b>${L.min ?? '—'}</b><span>min</span></div>1008          <div class="pc"><b>${L.p50 ?? '—'}</b><span>p50</span></div>1009          <div class="pc"><b>${L.p90 ?? '—'}</b><span>p90</span></div>1010          <div class="pc hot"><b>${L.p95 ?? '—'}</b><span>p95</span></div>1011          <div class="pc hot"><b>${L.p99 ?? '—'}</b><span>p99</span></div>1012          <div class="pc"><b>${L.max ?? '—'}</b><span>max</span></div>1013        </div>1014      </div>1015      <div class="sheet-sec">1016        <h4>Codes HTTP & fiabilité — 24 h</h4>1017        ${statusBar(SD)}1018        <div class="legend">1019          <span><i class="sw s2"></i>2xx ${SD['2xx'] || 0}</span>1020          <span><i class="sw s3"></i>3xx ${SD['3xx'] || 0}</span>1021          <span><i class="sw s4"></i>4xx ${SD['4xx'] || 0}</span>1022          <span><i class="sw s5"></i>5xx ${SD['5xx'] || 0}</span>1023          <span><i class="sw se"></i>err ${SD.err || 0}</span>1024        </div>1025        <div class="tile-row" style="margin-top:10px">1026          <div class="tile"><div class="v">${C.total ?? '—'}</div><div class="l">checks</div></div>1027          <div class="tile"><div class="v ${C.fail ? 'red' : ''}">${C.fail ?? '—'}</div><div class="l">échecs</div></div>1028          <div class="tile"><div class="v">${C.avgBytes ? Math.round(C.avgBytes / 1024) + 'k' : '—'}</div><div class="l">taille moy</div></div>1029        </div>1030      </div>1031      <div class="sheet-sec">1032        <h4>Incidents — 30 j</h4>1033        <div class="tile-row">1034          <div class="tile"><div class="v ${IS.count30 ? 'red' : ''}">${IS.count30 ?? 0}</div><div class="l">incidents</div></div>1035          <div class="tile"><div class="v">${IS.mttrMin != null ? IS.mttrMin + ' min' : '—'}</div><div class="l">MTTR</div></div>1036          <div class="tile"><div class="v">${IS.longestMin ? IS.longestMin + ' min' : '—'}</div><div class="l">pire arrêt</div></div>1037        </div>1038      </div>1039      <div class="sheet-sec">1040        <h4>Service</h4>1041        <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>1042        <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>1043        <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>1044        <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>1045        <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>1046        <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>1047        <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>1048      </div>1049      ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `1050        <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>1051        <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}1052        ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}1053      ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `1054        <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>` : ''}1055      <div class="sheet-sec">1056        <h4>Actions d'administration</h4>1057        <div class="admin-actions">1058          <button class="btn-ghost" id="aRestart">↻ Redémarrer PM2</button>1059          <button class="btn-ghost" id="aLogs">📜 Logs récents</button>1060          <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>1061          <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>1062        </div>1063        <div id="aOut" class="admin-out hidden"></div>1064      </div>1065      <button class="btn-cta" style="width:100%" id="shChat">💬 Demander à Claude Code →</button>1066    `);1067    $('shChat').onclick = () => {1068      closeSheet();1069      newChat();1070      for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;1071    };1072    const out = () => $('aOut');1073    const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };1074    $('aRestart').onclick = async () => {1075      if (!confirm(`Redémarrer ${d.label} (pm2 restart) sur ${d.node} ?`)) return;1076      showOut('<div class="admin-load">redémarrage…</div>');1077      const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });1078      const j = await r.json();1079      showOut(`<div class="${j.ok ? 'admin-ok' : 'admin-err'}">${j.ok ? '✓ redémarré' : '✗ échec'} — statut : ${esc(j.output || '')}</div>`);1080      if (navigator.vibrate) navigator.vibrate(60);1081    };1082    $('aLogs').onclick = async () => {1083      showOut('<div class="admin-load">chargement des logs…</div>');1084      const r = await fetch('/api/admin/logs/' + d.app);1085      const j = await r.json();1086      showOut(`<pre>${esc((j.logs || '').slice(-4000))}</pre>`);1087    };1088    $('aCommits').onclick = async () => {1089      showOut('<div class="admin-load">…</div>');1090      const r = await fetch('/api/admin/commits/' + d.app);1091      const j = await r.json();1092      showOut(`<pre>${esc(j.text || '')}</pre>`);1093    };1094    attachChartTips();1095  }10961097  function uptimeDays(days) {1098    if (!days) return '';1099    const cells = days.map(dd => {1100      const u = dd.uptime;1101      const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';1102      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>`;1103    }).join('');1104    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>`;1105  }11061107  function latencyChart(series) {1108    const W = 340, H = 130, PL = 38, PB = 18, PT = 8;1109    if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';1110    const ok = series.filter(p => p.ok);1111    const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;1112    const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);1113    const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);1114    let d = '', pen = false;1115    const pts = [];1116    for (const p of series) {1117      if (!p.ok) { pen = false; continue; }1118      const px = x(p.t), py = y(p.ms);1119      d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);1120      pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });1121      pen = true;1122    }1123    const failMarks = series.filter(p => !p.ok).map(p =>1124      `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');1125    const gridLines = [0.25, 0.5, 0.75, 1].map(f => {1126      const gy = y(max * f / 1.15);1127      return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>1128        <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>`;1129    }).join('');1130    const lastPt = pts[pts.length - 1];1131    const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>1132      <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>` : '';1133    const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);1134    const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';1135    return `<div style="position:relative">1136      <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 })))}'>1137        ${gridLines}1138        <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>1139        ${failMarks}${lastLbl}1140        <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>1141        <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>1142      </svg></div>`;1143  }1144  function attachChartTips() {1145    document.querySelectorAll('.lat-chart').forEach((svg) => {1146      const pts = JSON.parse(svg.dataset.pts || '[]');1147      if (!pts.length) return;1148      const wrap = svg.parentElement;1149      let tip = null;1150      const show = (clientX) => {1151        const rect = svg.getBoundingClientRect();1152        const vx = (clientX - rect.left) / rect.width * 340;1153        let best = pts[0];1154        for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;1155        if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }1156        tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;1157        tip.style.left = (best.x / 340 * rect.width) + 'px';1158        tip.style.top = (best.y / 130 * rect.height) + 'px';1159      };1160      svg.addEventListener('pointerdown', (e) => show(e.clientX));1161      svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });1162      svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });1163    });1164  }11651166  // rafraîchissement périodique de l'onglet éco (en plus du push)1167  setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);1168  // retour au premier plan (iOS coupe le WS quand l'écran se verrouille) :1169  // reconnexion IMMÉDIATE + resynchronisation de la conversation en cours1170  document.addEventListener('visibilitychange', () => {1171    if (document.hidden) return;1172    if (!ws || ws.readyState !== 1) {1173      wsRetry = 500;1174      try { ws && ws.close(); } catch {}1175      connect();1176    } else if (currentChat) {1177      send({ type: 'open', chatId: currentChat.id });1178    }1179    if (curView === 'eco') loadEco();1180  });1181  window.addEventListener('online', () => { wsRetry = 500; if (!ws || ws.readyState !== 1) connect(); });11821183  // ---------- feuille ----------1184  function openSheet(html) {1185    $('sheetBody').innerHTML = html;1186    $('sheet').classList.remove('hidden');1187  }1188  function closeSheet() { $('sheet').classList.add('hidden'); }1189  $('sheetBack').onclick = closeSheet;11901191  // ---------- réglages ----------1192  function refreshSettingsView() {1193    document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));1194    document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));1195    $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';1196    $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';1197    $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';1198  }1199  document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });1200  document.querySelectorAll('#segPerm button').forEach(b => {1201    b.onclick = () => {1202      if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;1203      applyOpts({ permMode: b.dataset.v });1204    };1205  });1206  $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };1207  $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };1208  $('logoutBtn').onclick = async () => {1209    wsWanted = false;1210    await fetch('/api/logout', { method: 'POST' });1211    location.reload();1212  };12131214  // ---------- login / init ----------1215  async function init() {1216    const me = await fetch('/api/me');1217    if (!me.ok) {1218      $('login').classList.remove('hidden');1219      $('app').classList.add('hidden');1220      return;1221    }1222    const info = await me.json();1223    $('setHost').textContent = (info.node || 'M3U96a') + ':3300';1224    $('login').classList.add('hidden');1225    $('app').classList.remove('hidden');1226    const pr = await fetch('/api/projects');1227    const { projects } = await pr.json();1228    projectSel.innerHTML = '';1229    for (const p of projects) {1230      const o = document.createElement('option');1231      o.value = p.id; o.textContent = p.name + ' · ' + p.node;1232      projectSel.appendChild(o);1233    }1234    if (settings.projectId) projectSel.value = settings.projectId;1235    projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); };1236    refreshChips();1237    refreshSettingsView();1238    // reprise automatique : on rouvre la dernière conversation (historique +1239    // live si une tâche tourne encore) même après fermeture complète de l'app1240    if (settings.lastChatId) currentChat = { id: settings.lastChatId };1241    connect();1242  }1243  $('loginBtn').onclick = doLogin;1244  $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });1245  async function doLogin() {1246    const r = await fetch('/api/login', {1247      method: 'POST',1248      headers: { 'Content-Type': 'application/json' },1249      body: JSON.stringify({ password: $('password').value }),1250    });1251    if (r.ok) { $('password').value = ''; init(); }1252    else {1253      const d = await r.json().catch(() => ({}));1254      $('loginError').textContent = d.error || 'Erreur';1255    }1256  }12571258  init();1259})();1260
Files · c3e59860c2ebb2d0666b7e96d80a59555d88f7e1
Menu

Theme