SPB Git

spb/admin-ka

Public
40.9 KB · 883 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', '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 === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); }64    if (name === 'settings') refreshSettingsView();65  }66  document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); });67  function navBadge(view) {68    if (curView === view) return;69    const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`);70    if (b) b.classList.remove('hidden');71  }7273  // ---------- bannières ----------74  function banner(kind, html, onclick, ttl = 6000) {75    const b = el('div', 'banner ' + kind, html);76    if (onclick) b.onclick = () => { onclick(); b.remove(); };77    $('banners').appendChild(b);78    if (navigator.vibrate) navigator.vibrate(60);79    setTimeout(() => b.remove(), ttl);80  }8182  // ---------- scroll / messages ----------83  function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; }84  function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; }85  function addMsg(node) {86    $('welcome').classList.add('hidden');87    const wasNear = nearBottom();88    const w = workingEl(); if (w) messagesEl.insertBefore(node, w); else messagesEl.appendChild(node);89    scrollBottom(wasNear);90  }91  function renderMd(container, text) {92    container.innerHTML = marked.parse(text || '');93    container.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });94  }95  function truncPre(text, limit = 3000) {96    const pre = el('pre');97    const full = String(text ?? '');98    if (full.length <= limit) { pre.textContent = full; return pre; }99    pre.textContent = full.slice(0, limit);100    const wrap = el('div');101    const btn = el('button', 'see-more', 'voir plus (' + Math.round((full.length - limit) / 1000) + ' k de plus)');102    btn.onclick = () => { pre.textContent = full; btn.remove(); };103    wrap.append(pre, btn);104    return wrap;105  }106107  function workingEl() { return messagesEl.querySelector('.working'); }108  function setRunning(r) {109    isRunning = r;110    sendBtn.classList.toggle('hidden', r);111    stopBtn.classList.toggle('hidden', !r);112    let w = workingEl();113    if (r && !w) {114      $('welcome').classList.add('hidden');115      w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span>');116      messagesEl.appendChild(w);117      scrollBottom(true);118    } else if (!r && w) w.remove();119    if (!r) { finalizeLive(); finalizeThink(); }120  }121122  // ---------- streaming live ----------123  function ensureLive() {124    if (live) return live;125    const wrap = el('div', 'msg-claude');126    const md = el('div', 'md');127    wrap.appendChild(md);128    addMsg(wrap);129    live = { el: wrap, textEl: md, text: '' };130    return live;131  }132  function finalizeLive() { if (live) { live.el.remove(); live = null; } }133  function ensureThink() {134    if (liveThink) return liveThink;135    const box = el('div', 'thinking');136    addMsg(box);137    liveThink = { el: box, text: '' };138    return liveThink;139  }140  function finalizeThink() {141    if (!liveThink) return;142    const d = el('details', 'thinking-box', '<summary>réflexion</summary>');143    const inner = el('div', 'thinking'); inner.textContent = liveThink.text;144    d.appendChild(inner);145    liveThink.el.replaceWith(d);146    liveThink = null;147  }148149  // ---------- cartes outils ----------150  const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' };151  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' };152  function toolSubtitle(name, input) {153    input = input || {};154    if (name === 'Bash') return input.command || '';155    if (input.file_path) return input.file_path.replace(/^\/Users\/[^/]+\//, '~/');156    if (input.pattern) return input.pattern;157    if (input.url) return input.url;158    if (name === 'Task') return input.description || '';159    if (name === 'TodoWrite') return 'liste de tâches';160    return '';161  }162  function diffBlock(oldStr, newStr) {163    const d = el('div', 'diff');164    for (const l of String(oldStr || '').split('\n')) d.appendChild(el('div', 'dl del', esc('- ' + l)));165    for (const l of String(newStr || '').split('\n')) d.appendChild(el('div', 'dl add', esc('+ ' + l)));166    return d;167  }168  function toolBody(name, input) {169    const body = el('div', 'tool-body');170    input = input || {};171    if (name === 'Bash') {172      body.appendChild(el('div', 'label', 'commande'));173      body.appendChild(truncPre(input.command));174      if (input.description) body.appendChild(el('div', 'meta-line', esc(input.description)));175    } else if (name === 'Edit') {176      body.appendChild(diffBlock(input.old_string, input.new_string));177    } else if (name === 'MultiEdit') {178      for (const e of input.edits || []) body.appendChild(diffBlock(e.old_string, e.new_string));179    } else if (name === 'Write') {180      body.appendChild(el('div', 'label', 'contenu'));181      const d = el('div', 'diff');182      const lines = String(input.content || '').split('\n');183      for (const l of lines.slice(0, 40)) d.appendChild(el('div', 'dl add', esc('+ ' + l)));184      if (lines.length > 40) d.appendChild(el('div', 'dl', '… ' + (lines.length - 40) + ' lignes de plus'));185      body.appendChild(d);186    } else if (name === 'TodoWrite') {187      const ul = el('ul', 'todos');188      for (const t of input.todos || []) {189        const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';190        ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)));191      }192      body.appendChild(ul);193    } else if (name === 'Task') {194      body.appendChild(truncPre(input.prompt || JSON.stringify(input, null, 2)));195    } else if (name === 'ExitPlanMode') {196      const md = el('div', 'md'); renderMd(md, input.plan || ''); body.appendChild(md);197    } else {198      body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500));199    }200    return body;201  }202  function addToolCard(block) {203    finalizeLive(); finalizeThink();204    const name = block.name || '?';205    const shortName = name.startsWith('mcp__') ? name.split('__').slice(1).join(':') : name;206    const card = el('details', 'tool');207    const sum = el('summary');208    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">●</span>`;209    card.appendChild(sum);210    card.appendChild(toolBody(name, block.input));211    addMsg(card);212    toolCards.set(block.id, { card, stateEl: sum.querySelector('.t-state'), bodyEl: card.querySelector('.tool-body') });213  }214  function attachToolResult(block) {215    const t = toolCards.get(block.tool_use_id);216    if (!t) return;217    t.stateEl.className = 't-state ' + (block.is_error ? 'err' : 'ok');218    t.stateEl.textContent = block.is_error ? '✗' : '✓';219    let text = '';220    if (typeof block.content === 'string') text = block.content;221    else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n');222    if (text.trim()) {223      t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat'));224      t.bodyEl.appendChild(truncPre(text));225    }226  }227228  // ---------- permissions ----------229  function addPermCard(ev) {230    finalizeLive(); finalizeThink();231    const card = el('div', 'perm');232    const isPlan = ev.tool === 'ExitPlanMode';233    card.innerHTML = `<div class="perm-title">${isPlan ? '📋 Plan proposé' : '🔐 Permission — ' + esc(ev.tool)}</div>`;234    if (isPlan && ev.input?.plan) {235      const md = el('div', 'md'); renderMd(md, ev.input.plan); card.appendChild(md);236    } else {237      const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre);238    }239    const actions = el('div', 'perm-actions');240    const deny = el('button', 'perm-deny', 'Refuser');241    const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser');242    actions.append(deny, allow);243    if (!isPlan) {244      const always = el('button', 'perm-always', 'Toujours');245      always.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true, always: true });246      actions.insertBefore(always, allow);247    }248    card.appendChild(actions);249    deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false });250    allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true });251    addMsg(card);252    permCards.set(ev.requestId, card);253    scrollBottom(true);254    if (navigator.vibrate) navigator.vibrate([60, 60, 60]);255  }256  function resolvePermCard(ev) {257    const card = permCards.get(ev.requestId);258    if (!card) return;259    const actions = card.querySelector('.perm-actions');260    if (actions) actions.remove();261    card.appendChild(el('div', 'perm-resolved',262      ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : ''))));263  }264265  // ---------- rendu des événements ----------266  function renderEvent(ev) {267    if (ev.type === 'user_prompt') {268      finalizeLive(); finalizeThink();269      const b = el('div', 'msg-user'); b.textContent = ev.text; addMsg(b);270      return;271    }272    if (ev.type === 'status') { setRunning(ev.state === 'running'); return; }273    if (ev.type === 'error') { addMsg(el('div', 'error-box', esc(ev.message))); return; }274    if (ev.type === 'notice') { addMsg(el('div', 'notice-line', esc(ev.text))); return; }275    if (ev.type === 'permission_request') { addPermCard(ev); return; }276    if (ev.type === 'permission_resolved') { resolvePermCard(ev); return; }277    if (ev.type === 'permission_auto') { addMsg(el('div', 'meta-line', esc('🔓 auto-autorisé : ' + ev.key))); return; }278    if (ev.type === 'claude') { renderClaude(ev.event); return; }279  }280281  function renderClaude(ev) {282    if (ev.type === 'system' && ev.subtype === 'init') {283      addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`)));284      return;285    }286    if (ev.type === 'stream_event' && ev.event) {287      const se = ev.event;288      if (se.type === 'content_block_delta' && se.delta) {289        if (se.delta.type === 'text_delta') {290          const l = ensureLive();291          l.text += se.delta.text;292          renderMd(l.textEl, l.text);293          scrollBottom();294        } else if (se.delta.type === 'thinking_delta') {295          const t = ensureThink();296          t.text += se.delta.thinking;297          t.el.textContent = t.text.slice(-600);298          scrollBottom();299        }300      }301      return;302    }303    if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) {304      for (const block of ev.message.content) {305        if (block.type === 'text' && block.text && block.text.trim()) {306          finalizeLive(); finalizeThink();307          const wrap = el('div', 'msg-claude');308          const md = el('div', 'md');309          renderMd(md, block.text);310          wrap.appendChild(md);311          addMsg(wrap);312        } else if (block.type === 'tool_use') {313          addToolCard(block);314        } else if (block.type === 'thinking' && block.thinking) {315          if (liveThink) { liveThink.el.remove(); liveThink = null; }316          const d = el('details', 'thinking-box', '<summary>réflexion</summary>');317          const inner = el('div', 'thinking'); inner.textContent = block.thinking;318          d.appendChild(inner);319          addMsg(d);320        }321      }322      return;323    }324    if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) {325      for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block);326      return;327    }328    if (ev.type === 'result') {329      finalizeLive(); finalizeThink();330      const dur = ev.duration_ms ? (ev.duration_ms / 1000).toFixed(1) + ' s' : '';331      const cost = ev.total_cost_usd ? ' · ' + ev.total_cost_usd.toFixed(3) + ' $' : '';332      const turns = ev.num_turns ? ' · ' + ev.num_turns + ' tours' : '';333      addMsg(el('div', 'meta-line', esc('— fin ' + dur + cost + turns)));334      if (ev.is_error && ev.result) addMsg(el('div', 'error-box', esc(String(ev.result))));335      return;336    }337  }338339  // ---------- méta de session (chips + bandeau) ----------340  function refreshChips() {341    const model = currentChat ? currentChat.model : settings.model;342    const perm = currentChat ? currentChat.permMode : settings.permMode;343    $('chipModel').textContent = MODEL_LABEL[model] || model;344    $('chipPerm').textContent = PERM_LABEL[perm] || perm;345    $('chipPerm').classList.toggle('warn', perm === 'bypass');346    $('chipModel').classList.add('orange');347    const meta = $('sessMeta');348    if (currentChat && (currentChat.claudeSessionId || currentChat.totalCost)) {349      meta.classList.remove('hidden');350      $('metaModel').textContent = currentChat.lastModel || MODEL_LABEL[model];351      $('metaSid').textContent = currentChat.claudeSessionId ? 'sess ' + currentChat.claudeSessionId.slice(0, 8) : '';352      $('metaCost').textContent = fmt$(currentChat.totalCost);353      $('metaCtx').textContent = currentChat.contextTokens ? 'ctx ' + fmtTok(currentChat.contextTokens) + ' tok' : '';354    } else meta.classList.add('hidden');355  }356357  // ---------- WebSocket ----------358  function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }359  function setConn(state) {360    const pill = $('connPill');361    pill.className = 'conn-pill ' + state;362    $('connText').textContent = state === 'on' ? 'connecté' : state === 'mid' ? 'reconnexion…' : 'hors ligne';363  }364  function connect() {365    if (!wsWanted) return;366    setConn('mid');367    const proto = location.protocol === 'https:' ? 'wss' : 'ws';368    ws = new WebSocket(`${proto}://${location.host}/ws`);369    ws.onopen = () => {370      setConn('on');371      wsRetry = 1000;372      if (currentChat) send({ type: 'open', chatId: currentChat.id });373      if (curView === 'eco') send({ type: 'eco_sub' });374    };375    ws.onclose = () => {376      setConn('off');377      setTimeout(connect, wsRetry);378      wsRetry = Math.min(wsRetry * 1.6, 20000);379    };380    ws.onmessage = (e) => {381      let msg;382      try { msg = JSON.parse(e.data); } catch { return; }383      handleServer(msg);384    };385  }386  function handleServer(msg) {387    if (msg.type === 'chat_created') { currentChat = msg.chat; refreshChips(); return; }388    if (msg.type === 'chat_meta') {389      if (currentChat && msg.chat.id === currentChat.id) { currentChat = msg.chat; refreshChips(); }390      return;391    }392    if (msg.type === 'history') {393      currentChat = msg.chat;394      clearConversation(true);395      for (const ev of msg.events) { try { renderEvent(ev); } catch {} }396      setRunning(msg.running);397      for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value;398      refreshChips();399      scrollBottom(true);400      return;401    }402    if (msg.type === 'notify') {403      if (msg.kind === 'done') {404        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {405          banner('good', `✓ Tâche terminée — ${esc(msg.title || '')}`, () => openChat(msg.chatId));406          navBadge('chat'); navBadge('sessions');407        }408      } else if (msg.kind === 'perm') {409        if (!(curView === 'chat' && currentChat && currentChat.id === msg.chatId && !document.hidden)) {410          banner('warn', `🔐 Permission attendue (${esc(msg.tool || '')}) — ${esc(msg.title || '')}`, () => openChat(msg.chatId), 12000);411          navBadge('chat');412        }413      }414      return;415    }416    if (msg.type === 'eco_alert') {417      if (msg.kind === 'down') banner('bad', `🔴 ${esc(msg.site)} est HORS LIGNE — ${esc(msg.reason || '')}`, () => switchView('eco'), 12000);418      else banner('good', `🟢 ${esc(msg.site)} est de retour en ligne`, () => switchView('eco'));419      navBadge('eco');420      if (curView === 'eco') loadEco();421      return;422    }423    if (msg.type === 'eco') {424      if (msg.kind === 'checks' && ecoData && curView === 'eco') applyChecks(msg.items);425      return;426    }427    renderEvent(msg);428  }429430  function clearConversation(keepChat) {431    messagesEl.innerHTML = '';432    toolCards.clear(); permCards.clear();433    live = null; liveThink = null;434    if (!keepChat && !currentChat) $('welcome').classList.remove('hidden');435  }436  function openChat(chatId) {437    switchView('chat');438    currentChat = { id: chatId };439    clearConversation(true);440    send({ type: 'open', chatId });441  }442443  // ---------- composer ----------444  function sendPrompt() {445    const text = inputEl.value.trim();446    if (!text || isRunning) return;447    // commandes slash locales448    if (text === '/clear') { if (currentChat) send({ type: 'clear', chatId: currentChat.id }); inputEl.value = ''; autoGrow(); return; }449    if (text === '/cost') {450      if (currentChat) addMsg(el('div', 'meta-line', esc(`coût cumulé ${fmt$(currentChat.totalCost)} · ${currentChat.turns || 0} tours · ctx ${fmtTok(currentChat.contextTokens)} tok`)));451      inputEl.value = ''; autoGrow(); return;452    }453    if (text === '/resume') { switchView('sessions'); inputEl.value = ''; autoGrow(); return; }454    inputEl.value = '';455    autoGrow();456    hideSlash();457    send({458      type: 'start',459      chatId: currentChat ? currentChat.id : null,460      project: projectSel.value,461      model: currentChat ? currentChat.model : settings.model,462      permMode: currentChat ? currentChat.permMode : settings.permMode,463      prompt: text,464    });465  }466  sendBtn.onclick = sendPrompt;467  stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); };468  inputEl.addEventListener('keydown', (e) => {469    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); }470  });471  function autoGrow() {472    inputEl.style.height = 'auto';473    inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';474  }475  inputEl.addEventListener('input', () => {476    autoGrow();477    if (inputEl.value.startsWith('/')) showSlash(); else hideSlash();478  });479  inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300));480481  function showSlash() {482    const h = $('slashHint');483    h.classList.remove('hidden');484    if (!h.childElementCount) {485      for (const [cmd, tip] of [['/clear', 'effacer le contexte'], ['/compact', 'compacter la session'], ['/cost', 'coût de la session'], ['/resume', 'liste des sessions']]) {486        const b = el('button', '', `${cmd} <span style="color:var(--muted);font-weight:600">${tip}</span>`);487        b.onclick = () => { inputEl.value = cmd; hideSlash(); sendPrompt(); };488        h.appendChild(b);489      }490    }491  }492  function hideSlash() { $('slashHint').classList.add('hidden'); }493494  document.querySelectorAll('.hint').forEach((h) => {495    h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); };496  });497498  // ---------- chips ----------499  $('chipModel').onclick = () => {500    const order = ['fable', 'opus', 'sonnet', 'haiku'];501    const cur = currentChat ? currentChat.model : settings.model;502    const next = order[(order.indexOf(cur) + 1) % order.length];503    applyOpts({ model: next });504  };505  $('chipPerm').onclick = () => {506    const order = ['default', 'acceptEdits', 'plan', 'bypass'];507    const cur = currentChat ? currentChat.permMode : settings.permMode;508    const next = order[(order.indexOf(cur) + 1) % 4];509    if (next === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;510    applyOpts({ permMode: next });511  };512  function applyOpts(opts) {513    if (currentChat) {514      Object.assign(currentChat, opts);515      send({ type: 'set_opts', chatId: currentChat.id, model: currentChat.model, permMode: currentChat.permMode });516    }517    if (opts.model) settings.model = opts.model;518    if (opts.permMode) settings.permMode = opts.permMode;519    saveStore();520    refreshChips();521    refreshSettingsView();522  }523  $('chipMore').onclick = () => {524    const c = currentChat;525    openSheet(`526      <div class="sheet-h"><h3>Session</h3></div>527      <div class="sheet-sec">528        <div class="kv"><span>Projet</span><b>${esc(c ? c.projectName : projectSel.selectedOptions[0]?.textContent || '—')}</b></div>529        <div class="kv"><span>Session Claude Code</span><b>${c?.claudeSessionId ? c.claudeSessionId.slice(0, 12) + '…' : '—'}</b></div>530        <div class="kv"><span>Modèle du dernier tour</span><b>${esc(c?.lastModel || '—')}</b></div>531        <div class="kv"><span>Coût cumulé</span><b>${fmt$(c?.totalCost)}</b></div>532        <div class="kv"><span>Contexte</span><b>${c?.contextTokens ? fmtTok(c.contextTokens) + ' tokens' : '—'}</b></div>533      </div>534      <div class="sheet-sec">535        <div class="set-actions">536          <button class="btn-ghost" id="shClear">/clear — nouveau contexte</button>537          <button class="btn-ghost" id="shCompact">/compact</button>538          <button class="btn-ghost" id="shNew">+ Nouvelle conversation</button>539        </div>540      </div>`);541    $('shClear').onclick = () => { if (c) send({ type: 'clear', chatId: c.id }); closeSheet(); };542    $('shCompact').onclick = () => { closeSheet(); inputEl.value = '/compact'; sendPrompt(); };543    $('shNew').onclick = () => { closeSheet(); newChat(); };544  };545  function newChat() {546    currentChat = null;547    clearConversation();548    setRunning(false);549    refreshChips();550    switchView('chat');551  }552553  // ---------- sessions ----------554  async function loadSessions() {555    const r = await fetch('/api/chats');556    if (!r.ok) return;557    const { chats } = await r.json();558    const list = $('sessList');559    list.innerHTML = '';560    if (!chats.length) { list.appendChild(el('div', 'sess-empty', 'Aucune session. Lance une conversation dans l’onglet Chat.')); return; }561    const stLabel = { running: '● en cours', waiting_perm: '🔐 permission', idle: '✓ terminée' };562    for (const c of chats) {563      const card = el('div', 'sess-card');564      card.innerHTML = `565        <div class="sess-top">566          <span class="sess-title">${esc(c.title)}</span>567          <span class="sess-badge ${c.state}">${stLabel[c.state] || c.state}</span>568        </div>569        <div class="sess-info">570          <span>${esc(c.projectName || '')}</span>571          <span>${MODEL_LABEL[c.model] || c.model}</span>572          <span>${fmt$(c.totalCost)}</span>573          <span>${timeAgo(c.updated)}</span>574        </div>`;575      const actions = el('div', 'sess-actions');576      const open = el('button', '', c.state === 'idle' ? 'Reprendre' : 'Ouvrir');577      open.onclick = (e) => { e.stopPropagation(); openChat(c.id); };578      actions.appendChild(open);579      if (c.state === 'idle') {580        const del = el('button', 'del', 'Supprimer');581        del.onclick = async (e) => {582          e.stopPropagation();583          if (!confirm('Supprimer cette session ?')) return;584          await fetch('/api/chats/' + c.id, { method: 'DELETE' });585          card.remove();586          if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); }587        };588        actions.appendChild(del);589      }590      card.appendChild(actions);591      card.onclick = () => openChat(c.id);592      list.appendChild(card);593    }594  }595  $('newSessBtn').onclick = newChat;596597  // ---------- écosystème ----------598  function siteStatus(s) {599    if (s.incident || (s.last && !s.last.ok)) return 'down';600    if (s.last && s.last.ms > 4000) return 'slow';601    return s.last ? 'up' : 'unknown';602  }603  const ST = {604    up: { cls: 'up', ico: '●', lbl: 'EN LIGNE' },605    slow: { cls: 'slow', ico: '▲', lbl: 'LENT' },606    down: { cls: 'down', ico: '■', lbl: 'HORS LIGNE' },607    unknown: { cls: 'slow', ico: '▲', lbl: '…' },608  };609  function sparkline(spark, w = 120, h = 30) {610    if (!spark || spark.length < 2) return '<svg class="spark"></svg>';611    const ms = spark.map(p => p.ok ? p.ms : null);612    const max = Math.max(...ms.filter(v => v !== null), 100) * 1.15;613    const step = w / (spark.length - 1);614    let d = '', pen = false;615    spark.forEach((p, i) => {616      if (!p.ok) { pen = false; return; }617      const x = i * step, y = h - 3 - (p.ms / max) * (h - 8);618      d += (pen ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);619      pen = true;620    });621    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('');622    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>`;623  }624  async function loadEco() {625    const r = await fetch('/api/eco/summary');626    if (!r.ok) return;627    ecoData = await r.json();628    renderEco();629  }630  function applyChecks(items) {631    if (!ecoData) return;632    for (const it of items) {633      const s = ecoData.sites.find(x => x.app === it.site);634      if (!s) continue;635      s.last = { ts: it.ts, ms: it.ms, code: it.code, ok: it.ok, err: it.err };636      s.spark.push({ ts: it.ts, ms: it.ms, ok: it.ok });637      if (s.spark.length > 40) s.spark.shift();638    }639    renderEco();640  }641  function renderEco() {642    const sites = ecoData.sites;643    const up = sites.filter(s => siteStatus(s) === 'up').length;644    const down = sites.filter(s => siteStatus(s) === 'down').length;645    const lat = sites.filter(s => s.last?.ok).map(s => s.last.ms);646    const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : 0;647    $('ecoStamp').textContent = 'maj ' + new Date().toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });648    $('ecoOverview').innerHTML = `649      <div class="eco-stat ok"><div class="v">${up}/${sites.length}</div><div class="l">en ligne</div></div>650      <div class="eco-stat ${down ? 'bad' : ''}"><div class="v">${down}</div><div class="l">hors ligne</div></div>651      <div class="eco-stat"><div class="v">${avg}<small style="font-size:12px"> ms</small></div><div class="l">latence moy.</div></div>`;652    const grid = $('ecoGrid');653    grid.innerHTML = '';654    const order = { down: 0, slow: 1, unknown: 2, up: 3 };655    for (const s of [...sites].sort((a, b) => order[siteStatus(a)] - order[siteStatus(b)])) {656      const st = ST[siteStatus(s)];657      const card = el('div', 'site-card' + (siteStatus(s) === 'down' ? ' down' : ''));658      card.innerHTML = `659        <div class="site-top">660          <span class="site-name">${esc(s.label)}</span>661          <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>662        </div>663        <div class="site-mid">664          <div class="site-ms">${s.last?.ok ? s.last.ms : '—'}<small> ms</small></div>665          ${sparkline(s.spark)}666        </div>667        <div class="site-foot">668          <span>up 24h <b>${s.uptime24 ?? '—'}%</b></span>669          <span>p95 <b>${s.p95_24 ?? '—'} ms</b></span>670          <span>${esc(s.node)}</span>671          ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}672        </div>`;673      card.onclick = () => openSiteSheet(s.app);674      grid.appendChild(card);675    }676  }677678  async function openSiteSheet(app) {679    const r = await fetch('/api/eco/site/' + app);680    if (!r.ok) return;681    const d = await r.json();682    const s = ecoData.sites.find(x => x.app === app) || {};683    const st = ST[siteStatus(s)];684    const certDays = s.certExpires ? Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000)) : null;685    openSheet(`686      <div class="sheet-h">687        <h3>${esc(d.label)}</h3>688        <span class="st-pill ${st.cls}">${st.ico} ${st.lbl}</span>689      </div>690      <div class="sheet-sec">691        <h4>Disponibilité</h4>692        <div class="tile-row">693          <div class="tile"><div class="v">${s.uptime24 ?? '—'}%</div><div class="l">24 h</div></div>694          <div class="tile"><div class="v">${s.uptime7 ?? '—'}%</div><div class="l">7 j</div></div>695          <div class="tile"><div class="v">${s.uptime30 ?? '—'}%</div><div class="l">30 j</div></div>696        </div>697        <div style="margin-top:10px">${uptimeDays(d.days)}</div>698      </div>699      <div class="sheet-sec">700        <h4>Latence — 24 h</h4>701        ${latencyChart(d.series)}702        <div class="tile-row" style="margin-top:10px">703          <div class="tile"><div class="v">${s.avg24 ?? '—'}</div><div class="l">moy ms</div></div>704          <div class="tile"><div class="v">${s.p95_24 ?? '—'}</div><div class="l">p95 ms</div></div>705          <div class="tile"><div class="v">${s.last?.code || '—'}</div><div class="l">HTTP</div></div>706        </div>707      </div>708      <div class="sheet-sec">709        <h4>Service</h4>710        <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>711        <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>712        <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>713        <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>714        <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>715        <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>716        <div class="kv"><span>Certificat SSL</span><b>${certDays !== null ? 'expire dans ' + certDays + ' j' : '—'}</b></div>717      </div>718      ${d.incidents.length ? `<div class="sheet-sec"><h4>Incidents — 30 j</h4>${d.incidents.map(i => `719        <div class="inc-item"><span class="${i.ended ? 'inc-ok' : 'inc-dot'}">${i.ended ? '✓' : '■'}</span>720        <span>${new Date(i.started).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}721        ${i.ended ? ' → ' + Math.round((i.ended - i.started) / 60000) + ' min' : ' — EN COURS'} · ${esc(i.reason || '')}</span></div>`).join('')}</div>` : ''}722      ${d.errors.length ? `<div class="sheet-sec"><h4>Erreurs récentes</h4>${d.errors.slice(0, 8).map(e => `723        <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>` : ''}724      <button class="btn-cta" style="width:100%" id="shChat">Ouvrir dans le chat →</button>725    `);726    $('shChat').onclick = () => {727      closeSheet();728      newChat();729      for (const o of projectSel.options) if (o.value.startsWith(d.app + '@')) projectSel.value = o.value;730    };731    attachChartTips();732  }733734  function uptimeDays(days) {735    if (!days) return '';736    const cells = days.map(dd => {737      const u = dd.uptime;738      const c = u === null ? 'var(--line)' : u >= 99.5 ? 'var(--good)' : u >= 95 ? 'var(--warn)' : 'var(--bad)';739      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>`;740    }).join('');741    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>`;742  }743744  function latencyChart(series) {745    const W = 340, H = 130, PL = 38, PB = 18, PT = 8;746    if (!series || series.length < 2) return '<div style="color:var(--muted);font-size:13px">Pas encore assez de données.</div>';747    const ok = series.filter(p => p.ok);748    const max = Math.max(...ok.map(p => p.ms), 100) * 1.15;749    const x = (t) => PL + ((t - series[0].t) / (series[series.length - 1].t - series[0].t)) * (W - PL - 6);750    const y = (ms) => PT + (1 - ms / max) * (H - PT - PB);751    let d = '', pen = false;752    const pts = [];753    for (const p of series) {754      if (!p.ok) { pen = false; continue; }755      const px = x(p.t), py = y(p.ms);756      d += (pen ? 'L' : 'M') + px.toFixed(1) + ' ' + py.toFixed(1);757      pts.push({ x: px, y: py, ms: Math.round(p.ms), t: p.t });758      pen = true;759    }760    const failMarks = series.filter(p => !p.ok).map(p =>761      `<rect x="${(x(p.t) - 2).toFixed(1)}" y="${H - PB - 6}" width="4" height="6" fill="var(--bad)"/>`).join('');762    const gridLines = [0.25, 0.5, 0.75, 1].map(f => {763      const gy = y(max * f / 1.15);764      return `<line x1="${PL}" y1="${gy}" x2="${W - 6}" y2="${gy}" stroke="var(--grid)" stroke-width="1"/>765        <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>`;766    }).join('');767    const lastPt = pts[pts.length - 1];768    const lastLbl = lastPt ? `<circle cx="${lastPt.x}" cy="${lastPt.y}" r="3.5" fill="var(--orange-deep)" stroke="var(--card)" stroke-width="2"/>769      <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>` : '';770    const t0 = new Date(series[0].t), t1 = new Date(series[series.length - 1].t);771    const fmtH = (dt) => dt.getHours().toString().padStart(2, '0') + 'h';772    return `<div style="position:relative">773      <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 })))}'>774        ${gridLines}775        <path d="${d}" fill="none" stroke="var(--orange-deep)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>776        ${failMarks}${lastLbl}777        <text x="${PL}" y="${H - 4}" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t0)}</text>778        <text x="${W - 6}" y="${H - 4}" text-anchor="end" font-size="9" fill="var(--muted)" font-family="var(--mono)">${fmtH(t1)}</text>779      </svg></div>`;780  }781  function attachChartTips() {782    document.querySelectorAll('.lat-chart').forEach((svg) => {783      const pts = JSON.parse(svg.dataset.pts || '[]');784      if (!pts.length) return;785      const wrap = svg.parentElement;786      let tip = null;787      const show = (clientX) => {788        const rect = svg.getBoundingClientRect();789        const vx = (clientX - rect.left) / rect.width * 340;790        let best = pts[0];791        for (const p of pts) if (Math.abs(p.x - vx) < Math.abs(best.x - vx)) best = p;792        if (!tip) { tip = el('div', 'chart-tip'); wrap.appendChild(tip); }793        tip.textContent = `${new Date(best.t).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} · ${best.ms} ms`;794        tip.style.left = (best.x / 340 * rect.width) + 'px';795        tip.style.top = (best.y / 130 * rect.height) + 'px';796      };797      svg.addEventListener('pointerdown', (e) => show(e.clientX));798      svg.addEventListener('pointermove', (e) => { if (e.buttons || e.pointerType === 'mouse') show(e.clientX); });799      svg.addEventListener('pointerleave', () => { if (tip) { tip.remove(); tip = null; } });800    });801  }802803  // rafraîchissement périodique de l'onglet éco (en plus du push)804  setInterval(() => { if (curView === 'eco' && !document.hidden) loadEco(); }, 60000);805  document.addEventListener('visibilitychange', () => {806    if (!document.hidden && curView === 'eco') loadEco();807  });808809  // ---------- feuille ----------810  function openSheet(html) {811    $('sheetBody').innerHTML = html;812    $('sheet').classList.remove('hidden');813  }814  function closeSheet() { $('sheet').classList.add('hidden'); }815  $('sheetBack').onclick = closeSheet;816817  // ---------- réglages ----------818  function refreshSettingsView() {819    document.querySelectorAll('#segModel button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.model));820    document.querySelectorAll('#segPerm button').forEach(b => b.classList.toggle('on', b.dataset.v === settings.permMode));821    $('setSid').textContent = currentChat?.claudeSessionId ? currentChat.claudeSessionId.slice(0, 12) + '…' : '—';822    $('setCost').textContent = currentChat ? fmt$(currentChat.totalCost) : '—';823    $('setCtx').textContent = currentChat?.contextTokens ? fmtTok(currentChat.contextTokens) + ' tokens' : '—';824  }825  document.querySelectorAll('#segModel button').forEach(b => { b.onclick = () => applyOpts({ model: b.dataset.v }); });826  document.querySelectorAll('#segPerm button').forEach(b => {827    b.onclick = () => {828      if (b.dataset.v === 'bypass' && !confirm('Mode BYPASS : Claude Code exécutera TOUTES les actions sans demander. Continuer ?')) return;829      applyOpts({ permMode: b.dataset.v });830    };831  });832  $('btnClear').onclick = () => { if (currentChat) { send({ type: 'clear', chatId: currentChat.id }); switchView('chat'); } };833  $('btnCompact').onclick = () => { switchView('chat'); inputEl.value = '/compact'; sendPrompt(); };834  $('logoutBtn').onclick = async () => {835    wsWanted = false;836    await fetch('/api/logout', { method: 'POST' });837    location.reload();838  };839840  // ---------- login / init ----------841  async function init() {842    const me = await fetch('/api/me');843    if (!me.ok) {844      $('login').classList.remove('hidden');845      $('app').classList.add('hidden');846      return;847    }848    const info = await me.json();849    $('setHost').textContent = (info.node || 'M3U96a') + ':3300';850    $('login').classList.add('hidden');851    $('app').classList.remove('hidden');852    const pr = await fetch('/api/projects');853    const { projects } = await pr.json();854    projectSel.innerHTML = '';855    for (const p of projects) {856      const o = document.createElement('option');857      o.value = p.id; o.textContent = p.name + ' · ' + p.node;858      projectSel.appendChild(o);859    }860    if (settings.projectId) projectSel.value = settings.projectId;861    projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); };862    refreshChips();863    refreshSettingsView();864    connect();865  }866  $('loginBtn').onclick = doLogin;867  $('password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });868  async function doLogin() {869    const r = await fetch('/api/login', {870      method: 'POST',871      headers: { 'Content-Type': 'application/json' },872      body: JSON.stringify({ password: $('password').value }),873    });874    if (r.ok) { $('password').value = ''; init(); }875    else {876      const d = await r.json().catch(() => ({}));877      $('loginError').textContent = d.error || 'Erreur';878    }879  }880881  init();882})();883
Files · bde8b3aebfee1ae085dc6f4ccfd05e1e8a6d7356
Menu

Theme