admin-ka v2 — vrai Claude Code piloté, sessions détachées, Écosystème live, identité Ka
- Modèles par session: Fable 5 (défaut), Opus 5, Sonnet 5, Haiku (--model explicite) - Modes de permission: normal (Autoriser/Refuser/Toujours), acceptEdits, plan, bypass - Sessions détachées du WebSocket + replay du transcript à la reconnexion + notifications - Monitoring Écosystème: 13 sites, checks 45 s, SSL, incidents, sweep nœuds 5 min, SQLite, push WS - UI Groupe KA: crème/orange néo-brutaliste, bottom nav 4 onglets, graphiques SVG validés - /clear /compact /cost /resume, coût cumulé, tokens de contexte Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 changed files +1,529 −524
added
CLAUDE.md
+48 −0
@@ -0,0 +1,48 @@ | ||
| 1 | +# CLAUDE.md — administration-ka.com (Admin-Ka v2) | |
| 2 | + | |
| 3 | +Console d'administration du Groupe KA : app chat mobile-first qui pilote le **vrai Claude Code CLI** sur le cluster MacLustr, plus un monitoring temps réel de tous les sites du groupe. | |
| 4 | + | |
| 5 | +## Exigences non négociables | |
| 6 | + | |
| 7 | +1. **Vrai Claude Code, jamais l'API Messages.** Le backend lance le binaire `claude` (headless : `claude -p --output-format stream-json --verbose --include-partial-messages`), avec `cwd` = le repo de l'app choisie. Chaque app KA s'exécute **sur son nœud de déploiement** (via SSH pour les nœuds distants) — le repo de prod est la seule source de vérité, aucun clone. Tous les événements du stream (texte, thinking, tool_use, tool_result, result) sont relayés et rendus comme dans le terminal (diffs colorés, blocs outils repliables, coût/tokens). | |
| 8 | +2. **Options Claude Code dans l'UI** : modèle par session (Opus/Sonnet/Haiku, `--model`), mode de permissions (`default` avec cartes Autoriser/Refuser/Toujours, `acceptEdits`, `plan`, bypass derrière confirmation), choix du projet/cwd, `/clear` `/compact` `/cost` `/resume`, affichage live du modèle actif, session ID, coût cumulé, tokens de contexte. | |
| 9 | +3. **Sessions détachées** : le process claude ne dépend JAMAIS du WebSocket. Transcript JSONL persisté par conversation ; à la reconnexion l'app rejoue l'historique manqué puis reprend le live. États : en cours / en attente de permission / inactive. Reconnexion auto avec backoff + indicateur. Notifications in-app quand une tâche se termine ou attend une permission. | |
| 10 | +4. **Écosystème** : health checks HTTPS toutes les 45 s des 13 sites KA depuis M3U96a (latence, code, SSL), sweep des nœuds toutes les 5 min (charge/RAM/disque + PM2 cpu/mem/status + dernier commit), historique SQLite (`data/eco.sqlite`, rétention 35 j), incidents (2 échecs consécutifs → alerte, rétablissement → alerte verte), uptime 24h/7j/30j, moy/p95, sparklines + graphique 24 h + barres d'uptime 14 j, push temps réel via le même WebSocket. | |
| 11 | +5. **Identité Groupe KA** : crème `#F2F1EC`, orange `#F97316` (traits de graphe `#e05f00`), encre `#1a1611`, cartes à bords noirs 2 px + ombres décalées, pills, typo ronde, pastille « Ka ». Header fixe + bottom nav (Chat / Sessions / Écosystème / Réglages) avec safe-areas. Statuts JAMAIS en couleur seule (● ▲ ■ + libellé). | |
| 12 | +6. **Rien de simulé** : tout est branché sur le vrai CLI et les vrais sites. | |
| 13 | + | |
| 14 | +## Architecture | |
| 15 | + | |
| 16 | +``` | |
| 17 | +iPhone ⇄ wss://www.administration-ka.com (ngrok) ⇄ backend :3300 (M3U96a) | |
| 18 | + backend ⇄ claude CLI (local, apps M3U96a) | |
| 19 | + backend ⇄ ssh <nœud> claude CLI (apps distantes, repo de prod) | |
| 20 | + └ tunnel SSH inverse -R : cartes de permission (perm-mcp MCP) | |
| 21 | + backend ⇄ monitor.js : health checks + sweep nœuds + SQLite + push WS | |
| 22 | +``` | |
| 23 | + | |
| 24 | +- `server/server.js` — HTTP + WS + orchestration claude + états de session. | |
| 25 | +- `server/monitor.js` — collecteur Écosystème (node:sqlite intégré, aucune dépendance native). | |
| 26 | +- `server/perm-mcp.js` — serveur MCP stdio pour `--permission-prompt-tool mcp__adminka__approve` ; déployé aussi sur M3U96b/M4M64a/M4M64b/M2M32 dans `~/.adminka/`. | |
| 27 | +- `public/` — frontend une page, 4 onglets, aucun framework. | |
| 28 | + | |
| 29 | +## Pièges connus (ne pas re-découvrir) | |
| 30 | + | |
| 31 | +- **Tailscale inter-nœuds bloqué (ACL)** : depuis M3U96a, joindre les nœuds via les noms Bonjour `.local` (config `~/.ssh/config` en place). M2U64 = sous-réseau 192.168.0.x. | |
| 32 | +- **IPv4 LAN non routée entre nœuds** (EHOSTUNREACH) : les permissions distantes passent par le tunnel SSH inverse, pas par le LAN. | |
| 33 | +- `ControlPath` ssh avec hostnames .local trop long → `%C`. | |
| 34 | +- Sans `--model`, les nœuds retombent sur leur défaut local (Sonnet) → toujours passer `--model` explicitement. | |
| 35 | +- Auth headless par `ANTHROPIC_API_KEY` de `~/.claude/.env` (pas de login OAuth sur les nœuds). | |
| 36 | +- Chaque run reçoit `--append-system-prompt` : après toute modif → rebuild + pm2 restart + healthcheck + commit/push **spbgit** (origin, pas GitHub). | |
| 37 | + | |
| 38 | +## Exploitation | |
| 39 | + | |
| 40 | +```bash | |
| 41 | +# sur M3U96a | |
| 42 | +launchctl kickstart -k gui/501/io.adminka.backend # redémarrer backend | |
| 43 | +launchctl kickstart -k gui/501/io.adminka.ngrok # redémarrer tunnel | |
| 44 | +tail -f ~/apps/admin-ka/data/backend.err.log | |
| 45 | +node server/set-password.js '<nouveau>' # changer le mot de passe | |
| 46 | +``` | |
| 47 | + | |
| 48 | +Registre des déploiements : `~/Desktop/cluster-skill/cluster-deployments.json` (laptop, source de vérité). Repo : spbgit `~/srv/git/admin-ka.git`. | |
modified
public/app.js
+542 −105
@@ -1,4 +1,4 @@ | ||
| 1 | −/* admin-ka — client chat mobile pour Claude Code */ | |
| 1 | +/* Admin-Ka v2 — client : chat Claude Code + sessions détachées + écosystème */ | |
| 2 | 2 | (() => { |
| 3 | 3 | const $ = (id) => document.getElementById(id); |
| 4 | 4 | const messagesEl = $('messages'); |
@@ -6,34 +6,82 @@ | ||
| 6 | 6 | const sendBtn = $('sendBtn'); |
| 7 | 7 | const stopBtn = $('stopBtn'); |
| 8 | 8 | const projectSel = $('projectSel'); |
| 9 | − const modeBtn = $('modeBtn'); | |
| 10 | − const connDot = $('connDot'); | |
| 11 | − const statusText = $('statusText'); | |
| 12 | 9 | |
| 13 | 10 | marked.setOptions({ breaks: true, mangle: false, headerIds: false }); |
| 14 | 11 | |
| 15 | − let ws = null; | |
| 16 | − let wsRetry = 1000; | |
| 17 | − let currentChat = null; // {id, title, project, mode} | |
| 18 | − let mode = 'safe'; | |
| 12 | + // ---------- é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)); } | |
| 20 | + | |
| 21 | + let ws = null, wsRetry = 1000, wsWanted = true; | |
| 22 | + let currentChat = null; // publicChat du serveur | |
| 19 | 23 | let isRunning = false; |
| 20 | − let live = null; // bloc de streaming en cours {el, textEl, text} | |
| 21 | − let liveThink = null; | |
| 22 | − const toolCards = new Map(); // tool_use_id -> {card, state, bodyEl} | |
| 23 | − const permCards = new Map(); // requestId -> card | |
| 24 | + let live = null, liveThink = null; | |
| 25 | + let curView = 'chat'; | |
| 26 | + let ecoData = null; | |
| 27 | + const toolCards = new Map(); | |
| 28 | + const permCards = new Map(); | |
| 29 | + | |
| 30 | + 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' }; | |
| 24 | 32 | |
| 25 | − // ---------- helpers ---------- | |
| 33 | + // ---------- utilitaires ---------- | |
| 26 | 34 | function esc(s) { return String(s).replace(/[&<>"]/g, (c) => ({'&':'&','<':'<','>':'>','"':'"'}[c])); } |
| 27 | − function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 120; } | |
| 28 | − function scrollBottom(force) { | |
| 29 | − if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; | |
| 30 | − } | |
| 31 | 35 | function el(tag, cls, html) { |
| 32 | 36 | const e = document.createElement(tag); |
| 33 | 37 | if (cls) e.className = cls; |
| 34 | 38 | if (html !== undefined) e.innerHTML = html; |
| 35 | 39 | return e; |
| 36 | 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' : '—'; } | |
| 52 | + | |
| 53 | + // ---------- 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 | + } | |
| 72 | + | |
| 73 | + // ---------- 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 | + } | |
| 81 | + | |
| 82 | + // ---------- scroll / messages ---------- | |
| 83 | + function nearBottom() { return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < 130; } | |
| 84 | + function scrollBottom(force) { if (force || nearBottom()) messagesEl.scrollTop = messagesEl.scrollHeight; } | |
| 37 | 85 | function addMsg(node) { |
| 38 | 86 | $('welcome').classList.add('hidden'); |
| 39 | 87 | const wasNear = nearBottom(); |
@@ -56,7 +104,6 @@ | ||
| 56 | 104 | return wrap; |
| 57 | 105 | } |
| 58 | 106 | |
| 59 | − // ---------- indicateur "travaille" ---------- | |
| 60 | 107 | function workingEl() { return messagesEl.querySelector('.working'); } |
| 61 | 108 | function setRunning(r) { |
| 62 | 109 | isRunning = r; |
@@ -65,12 +112,10 @@ | ||
| 65 | 112 | let w = workingEl(); |
| 66 | 113 | if (r && !w) { |
| 67 | 114 | $('welcome').classList.add('hidden'); |
| 68 | − w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude travaille…</span>'); | |
| 115 | + w = el('div', 'working', '<div class="dots"><i></i><i></i><i></i></div><span>Claude Code travaille…</span>'); | |
| 69 | 116 | messagesEl.appendChild(w); |
| 70 | 117 | scrollBottom(true); |
| 71 | − } else if (!r && w) { | |
| 72 | − w.remove(); | |
| 73 | − } | |
| 118 | + } else if (!r && w) w.remove(); | |
| 74 | 119 | if (!r) { finalizeLive(); finalizeThink(); } |
| 75 | 120 | } |
| 76 | 121 | |
@@ -94,17 +139,15 @@ | ||
| 94 | 139 | } |
| 95 | 140 | function finalizeThink() { |
| 96 | 141 | if (!liveThink) return; |
| 97 | − const txt = liveThink.text; | |
| 98 | − const d = el('details', 'thinking-box'); | |
| 99 | − d.innerHTML = '<summary>réflexion</summary>'; | |
| 100 | − const inner = el('div', 'thinking'); inner.textContent = txt; | |
| 142 | + const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); | |
| 143 | + const inner = el('div', 'thinking'); inner.textContent = liveThink.text; | |
| 101 | 144 | d.appendChild(inner); |
| 102 | 145 | liveThink.el.replaceWith(d); |
| 103 | 146 | liveThink = null; |
| 104 | 147 | } |
| 105 | 148 | |
| 106 | 149 | // ---------- cartes outils ---------- |
| 107 | − const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓' }; | |
| 150 | + const TOOL_ICONS = { Bash: '❯', Read: '📄', Edit: '✏️', MultiEdit: '✏️', Write: '📝', Glob: '🔍', Grep: '🔍', LS: '📁', Task: '🤖', WebFetch: '🌐', WebSearch: '🌐', TodoWrite: '☑️', NotebookRead: '📓', NotebookEdit: '📓', ExitPlanMode: '📋' }; | |
| 108 | 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' }; |
| 109 | 152 | function toolSubtitle(name, input) { |
| 110 | 153 | input = input || {}; |
@@ -144,12 +187,13 @@ | ||
| 144 | 187 | const ul = el('ul', 'todos'); |
| 145 | 188 | for (const t of input.todos || []) { |
| 146 | 189 | const icon = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐'; |
| 147 | − const li = el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content)); | |
| 148 | − ul.appendChild(li); | |
| 190 | + ul.appendChild(el('li', t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'doing' : '', esc(icon + ' ' + t.content))); | |
| 149 | 191 | } |
| 150 | 192 | body.appendChild(ul); |
| 151 | 193 | } else if (name === 'Task') { |
| 152 | 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); | |
| 153 | 197 | } else { |
| 154 | 198 | body.appendChild(truncPre(JSON.stringify(input, null, 2), 1500)); |
| 155 | 199 | } |
@@ -176,8 +220,7 @@ | ||
| 176 | 220 | if (typeof block.content === 'string') text = block.content; |
| 177 | 221 | else if (Array.isArray(block.content)) text = block.content.map((c) => c.type === 'text' ? c.text : '[' + c.type + ']').join('\n'); |
| 178 | 222 | if (text.trim()) { |
| 179 | − const lbl = el('div', 'label', block.is_error ? 'erreur' : 'résultat'); | |
| 180 | − t.bodyEl.appendChild(lbl); | |
| 223 | + t.bodyEl.appendChild(el('div', 'label', block.is_error ? 'erreur' : 'résultat')); | |
| 181 | 224 | t.bodyEl.appendChild(truncPre(text)); |
| 182 | 225 | } |
| 183 | 226 | } |
@@ -186,26 +229,37 @@ | ||
| 186 | 229 | function addPermCard(ev) { |
| 187 | 230 | finalizeLive(); finalizeThink(); |
| 188 | 231 | const card = el('div', 'perm'); |
| 189 | − card.innerHTML = `<div class="perm-title">🔐 Permission demandée — ${esc(ev.tool)}</div>`; | |
| 190 | − const pre = el('pre'); pre.textContent = JSON.stringify(ev.input, null, 2); card.appendChild(pre); | |
| 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 | + } | |
| 191 | 239 | const actions = el('div', 'perm-actions'); |
| 192 | 240 | const deny = el('button', 'perm-deny', 'Refuser'); |
| 193 | − const allow = el('button', 'perm-allow', 'Autoriser'); | |
| 241 | + const allow = el('button', 'perm-allow', isPlan ? 'Approuver' : 'Autoriser'); | |
| 194 | 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 | + } | |
| 195 | 248 | card.appendChild(actions); |
| 196 | 249 | deny.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: false }); |
| 197 | 250 | allow.onclick = () => send({ type: 'perm', requestId: ev.requestId, allow: true }); |
| 198 | 251 | addMsg(card); |
| 199 | 252 | permCards.set(ev.requestId, card); |
| 200 | 253 | scrollBottom(true); |
| 201 | − if (navigator.vibrate) navigator.vibrate(80); | |
| 254 | + if (navigator.vibrate) navigator.vibrate([60, 60, 60]); | |
| 202 | 255 | } |
| 203 | 256 | function resolvePermCard(ev) { |
| 204 | 257 | const card = permCards.get(ev.requestId); |
| 205 | 258 | if (!card) return; |
| 206 | 259 | const actions = card.querySelector('.perm-actions'); |
| 207 | 260 | if (actions) actions.remove(); |
| 208 | − card.appendChild(el('div', 'perm-resolved', ev.allow ? '✓ Autorisé' : '✗ Refusé' + (ev.reason === 'timeout' ? ' (timeout)' : ''))); | |
| 261 | + card.appendChild(el('div', 'perm-resolved', | |
| 262 | + ev.allow ? ('✓ Autorisé' + (ev.always ? ' (toujours)' : '')) : ('✗ Refusé' + (ev.reason === 'timeout' ? ' (délai dépassé)' : '')))); | |
| 209 | 263 | } |
| 210 | 264 | |
| 211 | 265 | // ---------- rendu des événements ---------- |
@@ -217,14 +271,16 @@ | ||
| 217 | 271 | } |
| 218 | 272 | if (ev.type === 'status') { setRunning(ev.state === 'running'); return; } |
| 219 | 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; } | |
| 220 | 275 | if (ev.type === 'permission_request') { addPermCard(ev); return; } |
| 221 | 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; } | |
| 222 | 278 | if (ev.type === 'claude') { renderClaude(ev.event); return; } |
| 223 | 279 | } |
| 224 | 280 | |
| 225 | 281 | function renderClaude(ev) { |
| 226 | 282 | if (ev.type === 'system' && ev.subtype === 'init') { |
| 227 | − addMsg(el('div', 'meta-line', esc(`session ${String(ev.session_id || '').slice(0, 8)} · ${ev.model || ''} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); | |
| 283 | + addMsg(el('div', 'meta-line', esc(`⌁ ${ev.model || ''} · session ${String(ev.session_id || '').slice(0, 8)} · ${(ev.cwd || '').replace(/^\/Users\/[^/]+\//, '~/')}`))); | |
| 228 | 284 | return; |
| 229 | 285 | } |
| 230 | 286 | if (ev.type === 'stream_event' && ev.event) { |
@@ -256,20 +312,17 @@ | ||
| 256 | 312 | } else if (block.type === 'tool_use') { |
| 257 | 313 | addToolCard(block); |
| 258 | 314 | } else if (block.type === 'thinking' && block.thinking) { |
| 259 | − finalizeThink(); | |
| 315 | + if (liveThink) { liveThink.el.remove(); liveThink = null; } | |
| 260 | 316 | const d = el('details', 'thinking-box', '<summary>réflexion</summary>'); |
| 261 | 317 | const inner = el('div', 'thinking'); inner.textContent = block.thinking; |
| 262 | 318 | d.appendChild(inner); |
| 263 | 319 | addMsg(d); |
| 264 | − if (liveThink) { liveThink.el.remove(); liveThink = null; } | |
| 265 | 320 | } |
| 266 | 321 | } |
| 267 | 322 | return; |
| 268 | 323 | } |
| 269 | 324 | if (ev.type === 'user' && ev.message && Array.isArray(ev.message.content)) { |
| 270 | − for (const block of ev.message.content) { | |
| 271 | − if (block.type === 'tool_result') attachToolResult(block); | |
| 272 | − } | |
| 325 | + for (const block of ev.message.content) if (block.type === 'tool_result') attachToolResult(block); | |
| 273 | 326 | return; |
| 274 | 327 | } |
| 275 | 328 | if (ev.type === 'result') { |
@@ -283,67 +336,135 @@ | ||
| 283 | 336 | } |
| 284 | 337 | } |
| 285 | 338 | |
| 339 | + // ---------- 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 | + } | |
| 356 | + | |
| 286 | 357 | // ---------- WebSocket ---------- |
| 287 | 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 | + } | |
| 288 | 364 | function connect() { |
| 365 | + if (!wsWanted) return; | |
| 366 | + setConn('mid'); | |
| 289 | 367 | const proto = location.protocol === 'https:' ? 'wss' : 'ws'; |
| 290 | 368 | ws = new WebSocket(`${proto}://${location.host}/ws`); |
| 291 | 369 | ws.onopen = () => { |
| 292 | − connDot.classList.add('on'); | |
| 370 | + setConn('on'); | |
| 293 | 371 | wsRetry = 1000; |
| 294 | 372 | if (currentChat) send({ type: 'open', chatId: currentChat.id }); |
| 373 | + if (curView === 'eco') send({ type: 'eco_sub' }); | |
| 295 | 374 | }; |
| 296 | 375 | ws.onclose = () => { |
| 297 | − connDot.classList.remove('on'); | |
| 376 | + setConn('off'); | |
| 298 | 377 | setTimeout(connect, wsRetry); |
| 299 | 378 | wsRetry = Math.min(wsRetry * 1.6, 20000); |
| 300 | 379 | }; |
| 301 | 380 | ws.onmessage = (e) => { |
| 302 | 381 | let msg; |
| 303 | 382 | try { msg = JSON.parse(e.data); } catch { return; } |
| 304 | − if (msg.type === 'chat_created') { | |
| 305 | − currentChat = msg.chat; | |
| 306 | − return; | |
| 307 | − } | |
| 308 | − if (msg.type === 'history') { | |
| 309 | − currentChat = msg.chat; | |
| 310 | − clearConversation(); | |
| 311 | − for (const ev of msg.events) { try { renderEvent(ev); } catch {} } | |
| 312 | − setRunning(msg.running); | |
| 313 | − for (const o of projectSel.options) if (o.value === msg.chat.projectId) projectSel.value = o.value; | |
| 314 | − scrollBottom(true); | |
| 315 | − return; | |
| 316 | − } | |
| 317 | − renderEvent(msg); | |
| 383 | + handleServer(msg); | |
| 318 | 384 | }; |
| 319 | 385 | } |
| 320 | − function clearConversation() { | |
| 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 | + } | |
| 429 | + | |
| 430 | + function clearConversation(keepChat) { | |
| 321 | 431 | messagesEl.innerHTML = ''; |
| 322 | 432 | toolCards.clear(); permCards.clear(); |
| 323 | 433 | live = null; liveThink = null; |
| 324 | − if (!currentChat) $('welcome').classList.remove('hidden'); | |
| 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 }); | |
| 325 | 441 | } |
| 326 | − // suggestions de l'écran d'accueil → composer | |
| 327 | − document.querySelectorAll('.hint').forEach((h) => { | |
| 328 | − h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); }; | |
| 329 | − }); | |
| 330 | 442 | |
| 331 | − // ---------- actions ---------- | |
| 443 | + // ---------- composer ---------- | |
| 332 | 444 | function sendPrompt() { |
| 333 | 445 | const text = inputEl.value.trim(); |
| 334 | 446 | if (!text || isRunning) return; |
| 447 | + // commandes slash locales | |
| 448 | + 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; } | |
| 335 | 454 | inputEl.value = ''; |
| 336 | 455 | autoGrow(); |
| 456 | + hideSlash(); | |
| 337 | 457 | send({ |
| 338 | 458 | type: 'start', |
| 339 | 459 | chatId: currentChat ? currentChat.id : null, |
| 340 | 460 | project: projectSel.value, |
| 341 | − mode, | |
| 461 | + model: currentChat ? currentChat.model : settings.model, | |
| 462 | + permMode: currentChat ? currentChat.permMode : settings.permMode, | |
| 342 | 463 | prompt: text, |
| 343 | 464 | }); |
| 344 | 465 | } |
| 345 | 466 | sendBtn.onclick = sendPrompt; |
| 346 | − stopBtn.onclick = () => send({ type: 'interrupt' }); | |
| 467 | + stopBtn.onclick = () => { if (currentChat) send({ type: 'interrupt', chatId: currentChat.id }); }; | |
| 347 | 468 | inputEl.addEventListener('keydown', (e) => { |
| 348 | 469 | if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPrompt(); } |
| 349 | 470 | }); |
@@ -351,57 +472,367 @@ | ||
| 351 | 472 | inputEl.style.height = 'auto'; |
| 352 | 473 | inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; |
| 353 | 474 | } |
| 354 | − inputEl.addEventListener('input', autoGrow); | |
| 475 | + inputEl.addEventListener('input', () => { | |
| 476 | + autoGrow(); | |
| 477 | + if (inputEl.value.startsWith('/')) showSlash(); else hideSlash(); | |
| 478 | + }); | |
| 355 | 479 | inputEl.addEventListener('focus', () => setTimeout(() => scrollBottom(true), 300)); |
| 356 | 480 | |
| 357 | − $('newBtn').onclick = () => { currentChat = null; clearConversation(); setRunning(false); }; | |
| 481 | + 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'); } | |
| 358 | 493 | |
| 359 | − modeBtn.onclick = () => { | |
| 360 | − if (mode === 'safe') { | |
| 361 | − if (!confirm('Mode AUTO : Claude exécutera toutes les actions sans demander de permission. Continuer ?')) return; | |
| 362 | − mode = 'auto'; | |
| 363 | − } else { | |
| 364 | − mode = 'safe'; | |
| 494 | + document.querySelectorAll('.hint').forEach((h) => { | |
| 495 | + h.onclick = () => { inputEl.value = h.textContent; autoGrow(); inputEl.focus(); }; | |
| 496 | + }); | |
| 497 | + | |
| 498 | + // ---------- 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 }); | |
| 365 | 516 | } |
| 366 | − modeBtn.textContent = mode === 'auto' ? 'Auto' : 'Sûr'; | |
| 367 | − modeBtn.classList.toggle('auto', mode === 'auto'); | |
| 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(); }; | |
| 368 | 544 | }; |
| 545 | + function newChat() { | |
| 546 | + currentChat = null; | |
| 547 | + clearConversation(); | |
| 548 | + setRunning(false); | |
| 549 | + refreshChips(); | |
| 550 | + switchView('chat'); | |
| 551 | + } | |
| 369 | 552 | |
| 370 | − // ---------- drawer ---------- | |
| 371 | − const drawer = $('drawer'); | |
| 372 | − $('menuBtn').onclick = async () => { | |
| 373 | − drawer.classList.remove('hidden'); | |
| 553 | + // ---------- sessions ---------- | |
| 554 | + async function loadSessions() { | |
| 374 | 555 | const r = await fetch('/api/chats'); |
| 375 | 556 | if (!r.ok) return; |
| 376 | 557 | const { chats } = await r.json(); |
| 377 | − const list = $('chatList'); | |
| 558 | + const list = $('sessList'); | |
| 378 | 559 | list.innerHTML = ''; |
| 379 | − if (!chats.length) list.appendChild(el('div', 'chat-empty', 'Aucune conversation')); | |
| 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' }; | |
| 380 | 562 | for (const c of chats) { |
| 381 | − const item = el('div', 'chat-item' + (currentChat && currentChat.id === c.id ? ' active' : '')); | |
| 382 | − const when = new Date(c.updated).toLocaleString('fr-CA', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); | |
| 383 | − item.innerHTML = `<div class="ci-main"><span class="ci-title">${esc(c.title)}</span><span class="ci-meta">${esc(c.projectName || '')} · ${when}${c.running ? ' · <span class="ci-running">● en cours</span>' : ''}</span></div>`; | |
| 384 | − const delBtn = el('button', 'ci-del', '✕'); | |
| 385 | − delBtn.onclick = async (e) => { | |
| 386 | − e.stopPropagation(); | |
| 387 | − if (!confirm('Supprimer cette conversation ?')) return; | |
| 388 | − await fetch('/api/chats/' + c.id, { method: 'DELETE' }); | |
| 389 | − item.remove(); | |
| 390 | − if (currentChat && currentChat.id === c.id) { currentChat = null; clearConversation(); } | |
| 391 | − }; | |
| 392 | − if (!c.running) item.appendChild(delBtn); | |
| 393 | − item.onclick = () => { | |
| 394 | − drawer.classList.add('hidden'); | |
| 395 | − currentChat = { id: c.id }; | |
| 396 | − clearConversation(); | |
| 397 | − send({ type: 'open', chatId: c.id }); | |
| 398 | − }; | |
| 399 | − list.appendChild(item); | |
| 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); | |
| 400 | 593 | } |
| 594 | + } | |
| 595 | + $('newSessBtn').onclick = newChat; | |
| 596 | + | |
| 597 | + // ---------- é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: '…' }, | |
| 401 | 608 | }; |
| 402 | − $('drawerClose').onclick = () => drawer.classList.add('hidden'); | |
| 403 | − $('drawerBack').onclick = () => drawer.classList.add('hidden'); | |
| 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 | + } | |
| 677 | + | |
| 678 | + 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 | + } | |
| 733 | + | |
| 734 | + 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 | + } | |
| 743 | + | |
| 744 | + 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 | + } | |
| 802 | + | |
| 803 | + // 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 | + }); | |
| 808 | + | |
| 809 | + // ---------- 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; | |
| 816 | + | |
| 817 | + // ---------- 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(); }; | |
| 404 | 834 | $('logoutBtn').onclick = async () => { |
| 835 | + wsWanted = false; | |
| 405 | 836 | await fetch('/api/logout', { method: 'POST' }); |
| 406 | 837 | location.reload(); |
| 407 | 838 | }; |
@@ -414,6 +845,8 @@ | ||
| 414 | 845 | $('app').classList.add('hidden'); |
| 415 | 846 | return; |
| 416 | 847 | } |
| 848 | + const info = await me.json(); | |
| 849 | + $('setHost').textContent = (info.node || 'M3U96a') + ':3300'; | |
| 417 | 850 | $('login').classList.add('hidden'); |
| 418 | 851 | $('app').classList.remove('hidden'); |
| 419 | 852 | const pr = await fetch('/api/projects'); |
@@ -421,9 +854,13 @@ | ||
| 421 | 854 | projectSel.innerHTML = ''; |
| 422 | 855 | for (const p of projects) { |
| 423 | 856 | const o = document.createElement('option'); |
| 424 | − o.value = p.id; o.textContent = p.name; | |
| 857 | + o.value = p.id; o.textContent = p.name + ' · ' + p.node; | |
| 425 | 858 | projectSel.appendChild(o); |
| 426 | 859 | } |
| 860 | + if (settings.projectId) projectSel.value = settings.projectId; | |
| 861 | + projectSel.onchange = () => { settings.projectId = projectSel.value; saveStore(); }; | |
| 862 | + refreshChips(); | |
| 863 | + refreshSettingsView(); | |
| 427 | 864 | connect(); |
| 428 | 865 | } |
| 429 | 866 | $('loginBtn').onclick = doLogin; |
modified
public/index.html
+127 −53
@@ -3,86 +3,160 @@ | ||
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"> |
| 6 | −<meta name="theme-color" content="#ffffff"> | |
| 6 | +<meta name="theme-color" content="#F2F1EC"> | |
| 7 | 7 | <meta name="apple-mobile-web-app-capable" content="yes"> |
| 8 | 8 | <meta name="apple-mobile-web-app-status-bar-style" content="default"> |
| 9 | −<meta name="apple-mobile-web-app-title" content="Admin·KA"> | |
| 10 | −<title>Administration·KA</title> | |
| 11 | −<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='-apple-system,sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> | |
| 12 | −<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%233556f0'/%3E%3Ctext x='16' y='21.5' font-size='13' font-family='sans-serif' fill='white' text-anchor='middle' font-weight='700'%3EKA%3C/text%3E%3C/svg%3E"> | |
| 9 | +<meta name="apple-mobile-web-app-title" content="Admin-Ka"> | |
| 10 | +<title>Admin-Ka — Console Groupe KA</title> | |
| 11 | +<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='9' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='-apple-system,sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> | |
| 12 | +<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F97316'/%3E%3Ctext x='16' y='22' font-size='14' font-family='sans-serif' fill='%231a1611' text-anchor='middle' font-weight='800'%3EKa%3C/text%3E%3C/svg%3E"> | |
| 13 | 13 | <link rel="stylesheet" href="/vendor/hljs-github.min.css"> |
| 14 | 14 | <link rel="stylesheet" href="/style.css"> |
| 15 | 15 | </head> |
| 16 | 16 | <body> |
| 17 | 17 | |
| 18 | −<!-- Écran de connexion --> | |
| 18 | +<!-- ============ Connexion ============ --> | |
| 19 | 19 | <div id="login" class="login hidden"> |
| 20 | 20 | <div class="login-box"> |
| 21 | − <div class="login-mark">KA</div> | |
| 22 | − <h1 class="login-title">Administration·KA</h1> | |
| 21 | + <div class="ka-mark big">Ka</div> | |
| 22 | + <h1 class="login-title">Admin<span class="tiret">-</span>Ka</h1> | |
| 23 | 23 | <p class="login-sub">Console Claude Code · Groupe KA</p> |
| 24 | 24 | <input id="password" type="password" placeholder="Mot de passe" autocomplete="current-password"> |
| 25 | − <button id="loginBtn">Se connecter</button> | |
| 25 | + <button id="loginBtn" class="btn-cta">Se connecter</button> | |
| 26 | 26 | <div id="loginError" class="login-error"></div> |
| 27 | 27 | </div> |
| 28 | 28 | <div class="login-foot">M3U96a · MacLustr</div> |
| 29 | 29 | </div> |
| 30 | 30 | |
| 31 | −<!-- App --> | |
| 31 | +<!-- ============ App ============ --> | |
| 32 | 32 | <div id="app" class="app hidden"> |
| 33 | + | |
| 33 | 34 | <header class="header"> |
| 34 | − <button id="menuBtn" class="icon-btn" title="Conversations" aria-label="Conversations"> | |
| 35 | − <svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> | |
| 36 | − </button> | |
| 37 | − <div class="header-mid"> | |
| 38 | − <div class="project-row"> | |
| 39 | − <span id="connDot" class="dot" title="Connexion"></span> | |
| 40 | − <select id="projectSel" class="project-sel" title="Projet actif"></select> | |
| 41 | − </div> | |
| 35 | + <div class="brand"> | |
| 36 | + <span class="ka-mark">Ka</span> | |
| 37 | + <span class="brand-name">Admin<span class="tiret">-</span>Ka</span> | |
| 42 | 38 | </div> |
| 43 | − <button id="modeBtn" class="mode-btn" title="Mode permissions">Sûr</button> | |
| 44 | − <button id="newBtn" class="icon-btn accent" title="Nouvelle conversation" aria-label="Nouvelle conversation"> | |
| 45 | − <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg> | |
| 46 | − </button> | |
| 39 | + <div id="connPill" class="conn-pill off"><span class="conn-dot"></span><span id="connText">hors ligne</span></div> | |
| 47 | 40 | </header> |
| 48 | 41 | |
| 49 | − <main id="messages" class="messages"></main> | |
| 42 | + <!-- Bannière notifications / alertes --> | |
| 43 | + <div id="banners" class="banners"></div> | |
| 50 | 44 | |
| 51 | − <div id="welcome" class="welcome"> | |
| 52 | − <div class="welcome-mark">KA</div> | |
| 53 | − <h2>Administration·KA</h2> | |
| 54 | − <p>Parle à Claude Code, directement sur le nœud de chaque app du Groupe KA.</p> | |
| 55 | − <div class="hints"> | |
| 56 | − <button class="hint">Quel est l'état de l'app ?</button> | |
| 57 | − <button class="hint">Montre les derniers commits</button> | |
| 58 | − <button class="hint">Vérifie les erreurs PM2</button> | |
| 45 | + <!-- ======== Vue CHAT ======== --> | |
| 46 | + <section id="view-chat" class="view"> | |
| 47 | + <div class="chatbar"> | |
| 48 | + <select id="projectSel" class="pill-select" title="Projet (repo + nœud)"></select> | |
| 49 | + <button id="chipModel" class="chip" title="Modèle">opus</button> | |
| 50 | + <button id="chipPerm" class="chip" title="Permissions">normal</button> | |
| 51 | + <button id="chipMore" class="chip ghost" title="Options de session">⋯</button> | |
| 59 | 52 | </div> |
| 60 | − </div> | |
| 53 | + <div id="sessMeta" class="sess-meta hidden"> | |
| 54 | + <span id="metaModel"></span><span id="metaSid"></span><span id="metaCost"></span><span id="metaCtx"></span> | |
| 55 | + </div> | |
| 56 | + | |
| 57 | + <main id="messages" class="messages"></main> | |
| 58 | + | |
| 59 | + <div id="welcome" class="welcome"> | |
| 60 | + <div class="ka-mark big">Ka</div> | |
| 61 | + <h2>Console Claude Code</h2> | |
| 62 | + <p>Le vrai Claude Code, lancé directement sur le nœud de chaque app du Groupe KA.</p> | |
| 63 | + <div class="hints"> | |
| 64 | + <button class="hint">Quel modèle es-tu et dans quel répertoire travailles-tu ?</button> | |
| 65 | + <button class="hint">Montre les 5 derniers commits</button> | |
| 66 | + <button class="hint">État PM2 de l'app</button> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <footer class="composer"> | |
| 71 | + <div class="composer-inner"> | |
| 72 | + <textarea id="input" rows="1" placeholder="Demander à Claude Code…" enterkeyhint="send"></textarea> | |
| 73 | + <button id="stopBtn" class="send-btn stop hidden" title="Arrêter"><svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg></button> | |
| 74 | + <button id="sendBtn" class="send-btn" title="Envoyer"><svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></button> | |
| 75 | + </div> | |
| 76 | + <div id="slashHint" class="slash-hint hidden"></div> | |
| 77 | + </footer> | |
| 78 | + </section> | |
| 79 | + | |
| 80 | + <!-- ======== Vue SESSIONS ======== --> | |
| 81 | + <section id="view-sessions" class="view hidden"> | |
| 82 | + <div class="view-head"> | |
| 83 | + <h2>Sessions</h2> | |
| 84 | + <button id="newSessBtn" class="btn-cta small">+ Nouvelle</button> | |
| 85 | + </div> | |
| 86 | + <div id="sessList" class="sess-list"></div> | |
| 87 | + </section> | |
| 61 | 88 | |
| 62 | − <footer class="composer"> | |
| 63 | − <div class="composer-inner"> | |
| 64 | − <textarea id="input" rows="1" placeholder="Demander à Claude…" enterkeyhint="send"></textarea> | |
| 65 | − <button id="stopBtn" class="send-btn stop hidden" title="Arrêter" aria-label="Arrêter"> | |
| 66 | − <svg viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg> | |
| 67 | − </button> | |
| 68 | − <button id="sendBtn" class="send-btn" title="Envoyer" aria-label="Envoyer"> | |
| 69 | − <svg viewBox="0 0 24 24"><path d="M12 19V6M6 11l6-6 6 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg> | |
| 70 | − </button> | |
| 89 | + <!-- ======== Vue ÉCOSYSTÈME ======== --> | |
| 90 | + <section id="view-eco" class="view hidden"> | |
| 91 | + <div class="view-head"> | |
| 92 | + <h2>Écosystème</h2> | |
| 93 | + <span id="ecoStamp" class="eco-stamp"></span> | |
| 71 | 94 | </div> |
| 72 | − </footer> | |
| 95 | + <div id="ecoOverview" class="eco-overview"></div> | |
| 96 | + <div id="ecoGrid" class="eco-grid"><div class="eco-loading">Chargement…</div></div> | |
| 97 | + </section> | |
| 73 | 98 | |
| 74 | − <!-- Tiroir conversations --> | |
| 75 | − <div id="drawer" class="drawer hidden"> | |
| 76 | − <div id="drawerBack" class="drawer-back"></div> | |
| 77 | − <div class="drawer-panel"> | |
| 78 | − <div class="drawer-head"> | |
| 79 | − <span>Conversations</span> | |
| 80 | − <button id="drawerClose" class="icon-btn" aria-label="Fermer"> | |
| 81 | − <svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> | |
| 82 | − </button> | |
| 99 | + <!-- ======== Vue RÉGLAGES ======== --> | |
| 100 | + <section id="view-settings" class="view hidden"> | |
| 101 | + <div class="view-head"><h2>Réglages</h2></div> | |
| 102 | + <div class="set-card"> | |
| 103 | + <div class="set-label">Modèle par défaut</div> | |
| 104 | + <div class="seg" id="segModel"> | |
| 105 | + <button data-v="fable">Fable 5</button><button data-v="opus">Opus 5</button><button data-v="sonnet">Sonnet 5</button><button data-v="haiku">Haiku</button> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + <div class="set-card"> | |
| 109 | + <div class="set-label">Permissions par défaut</div> | |
| 110 | + <div class="seg vert" id="segPerm"> | |
| 111 | + <button data-v="default">Normal — demande dans le chat</button> | |
| 112 | + <button data-v="acceptEdits">acceptEdits — éditions auto</button> | |
| 113 | + <button data-v="plan">Plan — proposer avant d'agir</button> | |
| 114 | + <button data-v="bypass">⚠ Tout autoriser (bypass)</button> | |
| 83 | 115 | </div> |
| 84 | − <div id="chatList" class="chat-list"></div> | |
| 85 | − <button id="logoutBtn" class="logout-btn">Se déconnecter</button> | |
| 116 | + </div> | |
| 117 | + <div class="set-card"> | |
| 118 | + <div class="set-label">Conversation active</div> | |
| 119 | + <div class="set-row"><span>Session Claude Code</span><span id="setSid" class="mono">—</span></div> | |
| 120 | + <div class="set-row"><span>Coût cumulé</span><span id="setCost" class="mono">—</span></div> | |
| 121 | + <div class="set-row"><span>Contexte utilisé</span><span id="setCtx" class="mono">—</span></div> | |
| 122 | + <div class="set-actions"> | |
| 123 | + <button id="btnClear" class="btn-ghost">/clear — effacer le contexte</button> | |
| 124 | + <button id="btnCompact" class="btn-ghost">/compact — compacter</button> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + <div class="set-card"> | |
| 128 | + <div class="set-row"><span>Backend</span><span id="setHost" class="mono">M3U96a:3300</span></div> | |
| 129 | + <div class="set-row"><span>Version</span><span class="mono">2.0.0</span></div> | |
| 130 | + </div> | |
| 131 | + <button id="logoutBtn" class="btn-danger">Se déconnecter</button> | |
| 132 | + </section> | |
| 133 | + | |
| 134 | + <!-- ======== Bottom nav ======== --> | |
| 135 | + <nav class="bottomnav"> | |
| 136 | + <button data-view="chat" class="nav-btn active"> | |
| 137 | + <svg viewBox="0 0 24 24"><path d="M4 6a3 3 0 013-3h10a3 3 0 013 3v8a3 3 0 01-3 3H9l-4 4v-4a3 3 0 01-1-2.2z" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linejoin="round"/></svg> | |
| 138 | + <span>Chat</span><i class="badge hidden"></i> | |
| 139 | + </button> | |
| 140 | + <button data-view="sessions" class="nav-btn"> | |
| 141 | + <svg viewBox="0 0 24 24"><path d="M5 5h14M5 12h14M5 19h9" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> | |
| 142 | + <span>Sessions</span><i class="badge hidden"></i> | |
| 143 | + </button> | |
| 144 | + <button data-view="eco" class="nav-btn"> | |
| 145 | + <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> | |
| 146 | + <span>Écosystème</span><i class="badge hidden"></i> | |
| 147 | + </button> | |
| 148 | + <button data-view="settings" class="nav-btn"> | |
| 149 | + <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> | |
| 150 | + <span>Réglages</span><i class="badge hidden"></i> | |
| 151 | + </button> | |
| 152 | + </nav> | |
| 153 | + | |
| 154 | + <!-- ======== Feuille détail (éco / options) ======== --> | |
| 155 | + <div id="sheet" class="sheet hidden"> | |
| 156 | + <div id="sheetBack" class="sheet-back"></div> | |
| 157 | + <div class="sheet-panel"> | |
| 158 | + <div class="sheet-grip"></div> | |
| 159 | + <div id="sheetBody" class="sheet-body"></div> | |
| 86 | 160 | </div> |
| 87 | 161 | </div> |
| 88 | 162 | </div> |
modified
public/style.css
+392 −285
@@ -1,413 +1,520 @@ | ||
| 1 | +/* Admin-Ka — identité Groupe KA : crème + orange, néo-brutalisme doux */ | |
| 1 | 2 | :root { |
| 2 | − --bg: #f2f4f8; | |
| 3 | − --surface: #ffffff; | |
| 4 | − --surface2: #f6f8fb; | |
| 5 | − --border: #e4e8ef; | |
| 6 | − --border-strong: #d4dae3; | |
| 7 | − --text: #17203a; | |
| 8 | − --muted: #67708a; | |
| 9 | − --accent: #3556f0; | |
| 10 | − --accent-soft: #eef1fe; | |
| 11 | − --accent-grad: linear-gradient(135deg, #3556f0, #5a71f5); | |
| 12 | − --green: #17924a; | |
| 13 | − --green-soft: #e7f7ee; | |
| 14 | − --red: #d5303e; | |
| 15 | − --red-soft: #fdeeef; | |
| 16 | − --amber: #b26a00; | |
| 17 | − --amber-soft: #fff6e6; | |
| 18 | − --shadow-sm: 0 1px 2px rgba(23, 32, 58, 0.06), 0 1px 3px rgba(23, 32, 58, 0.08); | |
| 19 | − --shadow-md: 0 4px 16px rgba(23, 32, 58, 0.10); | |
| 20 | − --shadow-lg: 0 12px 40px rgba(23, 32, 58, 0.16); | |
| 21 | − --r-lg: 18px; | |
| 22 | − --r-md: 13px; | |
| 23 | − --r-sm: 9px; | |
| 3 | + --bg: #F2F1EC; | |
| 4 | + --card: #FCFBF8; | |
| 5 | + --card2: #F7F5EF; | |
| 6 | + --ink: #1a1611; | |
| 7 | + --muted: #6d675c; | |
| 8 | + --line: #e3dfd4; | |
| 9 | + --orange: #F97316; | |
| 10 | + --orange-deep: #e05f00; /* traits de graphique (contraste validé) */ | |
| 11 | + --orange-soft: #fee9d8; | |
| 12 | + --good: #178a4c; | |
| 13 | + --good-soft: #e2f3e8; | |
| 14 | + --warn: #946300; | |
| 15 | + --warn-soft: #f7edd4; | |
| 16 | + --bad: #d5303e; | |
| 17 | + --bad-soft: #fbe6e7; | |
| 18 | + --grid: #e5e1d8; | |
| 19 | + --r-xl: 26px; | |
| 20 | + --r-lg: 20px; | |
| 21 | + --r-md: 14px; | |
| 22 | + --r-sm: 10px; | |
| 23 | + --bshadow: 3px 3px 0 var(--ink); | |
| 24 | + --bshadow-sm: 2px 2px 0 var(--ink); | |
| 25 | + --z-banner: 140; | |
| 24 | 26 | --z-header: 100; |
| 25 | − --z-composer: 100; | |
| 26 | − --z-welcome: 50; | |
| 27 | − --z-drawer: 200; | |
| 27 | + --z-nav: 110; | |
| 28 | + --z-composer: 105; | |
| 29 | + --z-sheet: 200; | |
| 28 | 30 | --z-login: 300; |
| 29 | 31 | --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace; |
| 32 | + --round: ui-rounded, -apple-system, BlinkMacSystemFont, "SF Pro Rounded", "Segoe UI", Roboto, sans-serif; | |
| 30 | 33 | } |
| 31 | 34 | * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } |
| 32 | 35 | html, body { |
| 33 | 36 | margin: 0; padding: 0; |
| 34 | − background: var(--bg); color: var(--text); | |
| 35 | − font: 15.5px/1.55 -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif; | |
| 37 | + background: var(--bg); color: var(--ink); | |
| 38 | + font: 15.5px/1.55 var(--round); | |
| 36 | 39 | height: 100%; |
| 37 | 40 | overscroll-behavior: none; |
| 38 | − text-rendering: optimizeLegibility; | |
| 39 | 41 | } |
| 40 | 42 | .hidden { display: none !important; } |
| 41 | −button { font-family: inherit; } | |
| 43 | +button { font-family: inherit; color: inherit; } | |
| 44 | +.mono { font-family: var(--mono); font-size: 0.92em; } | |
| 45 | + | |
| 46 | +.ka-mark { | |
| 47 | + display: inline-flex; align-items: center; justify-content: center; | |
| 48 | + width: 34px; height: 34px; border-radius: 11px; | |
| 49 | + background: var(--orange); color: var(--ink); | |
| 50 | + border: 2px solid var(--ink); box-shadow: var(--bshadow-sm); | |
| 51 | + font-weight: 800; font-size: 15px; letter-spacing: -0.2px; | |
| 52 | + flex-shrink: 0; | |
| 53 | +} | |
| 54 | +.ka-mark.big { width: 68px; height: 68px; border-radius: 21px; font-size: 28px; box-shadow: var(--bshadow); margin: 0 auto; } | |
| 55 | +.tiret { color: var(--orange); font-weight: 800; } | |
| 56 | + | |
| 57 | +.btn-cta { | |
| 58 | + background: var(--orange); color: var(--ink); | |
| 59 | + border: 2px solid var(--ink); border-radius: 999px; | |
| 60 | + box-shadow: var(--bshadow); | |
| 61 | + font-weight: 800; font-size: 16px; padding: 13px 22px; cursor: pointer; | |
| 62 | + transition: transform 0.08s, box-shadow 0.08s; | |
| 63 | +} | |
| 64 | +.btn-cta:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } | |
| 65 | +.btn-cta.small { font-size: 13.5px; padding: 8px 16px; box-shadow: var(--bshadow-sm); } | |
| 66 | +.btn-ghost { | |
| 67 | + background: var(--card); border: 1.5px solid var(--ink); border-radius: 999px; | |
| 68 | + font-weight: 700; font-size: 13.5px; padding: 10px 16px; cursor: pointer; | |
| 69 | +} | |
| 70 | +.btn-danger { | |
| 71 | + display: block; margin: 18px auto calc(20px); | |
| 72 | + background: var(--card); border: 2px solid var(--bad); color: var(--bad); | |
| 73 | + border-radius: 999px; font-weight: 800; font-size: 14.5px; padding: 12px 26px; cursor: pointer; | |
| 74 | +} | |
| 42 | 75 | |
| 43 | 76 | /* ================= Login ================= */ |
| 44 | 77 | .login { |
| 45 | 78 | position: fixed; inset: 0; z-index: var(--z-login); |
| 46 | − background: | |
| 47 | − radial-gradient(1200px 600px at 50% -200px, #e3e9ff 0%, transparent 60%), | |
| 48 | − var(--bg); | |
| 79 | + background: var(--bg); | |
| 49 | 80 | display: flex; flex-direction: column; align-items: center; justify-content: center; |
| 50 | − padding: 24px; | |
| 81 | + padding: 24px; gap: 18px; | |
| 51 | 82 | } |
| 52 | 83 | .login-box { |
| 53 | 84 | width: 100%; max-width: 360px; |
| 54 | − background: var(--surface); | |
| 55 | − border: 1px solid var(--border); | |
| 56 | − border-radius: 24px; | |
| 57 | − box-shadow: var(--shadow-lg); | |
| 58 | − padding: 36px 28px 28px; | |
| 59 | − display: flex; flex-direction: column; gap: 12px; | |
| 60 | − text-align: center; | |
| 61 | −} | |
| 62 | −.login-mark { | |
| 63 | − width: 64px; height: 64px; margin: 0 auto 4px; | |
| 64 | − background: var(--accent-grad); color: #fff; | |
| 65 | − border-radius: 20px; | |
| 66 | − display: flex; align-items: center; justify-content: center; | |
| 67 | − font-size: 22px; font-weight: 800; letter-spacing: 0.5px; | |
| 68 | − box-shadow: 0 8px 24px rgba(53, 86, 240, 0.35); | |
| 85 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-xl); | |
| 86 | + box-shadow: 6px 6px 0 var(--ink); | |
| 87 | + padding: 34px 26px 26px; | |
| 88 | + display: flex; flex-direction: column; gap: 13px; text-align: center; | |
| 69 | 89 | } |
| 70 | −.login-title { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; } | |
| 71 | −.login-sub { margin: 0 0 10px; color: var(--muted); font-size: 13.5px; } | |
| 90 | +.login-title { margin: 6px 0 0; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; } | |
| 91 | +.login-sub { margin: 0 0 8px; color: var(--muted); font-size: 13.5px; font-weight: 600; } | |
| 72 | 92 | .login input { |
| 73 | − background: var(--surface2); border: 1.5px solid var(--border); color: var(--text); | |
| 93 | + background: var(--bg); border: 2px solid var(--ink); color: var(--ink); | |
| 74 | 94 | border-radius: var(--r-md); padding: 14px 16px; font-size: 16px; outline: none; |
| 75 | − transition: border-color 0.15s, box-shadow 0.15s; | |
| 76 | −} | |
| 77 | −.login input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); } | |
| 78 | −.login button { | |
| 79 | − background: var(--accent-grad); color: #fff; font-weight: 600; font-size: 16px; | |
| 80 | − border: none; border-radius: var(--r-md); padding: 14px; cursor: pointer; | |
| 81 | − box-shadow: 0 4px 14px rgba(53, 86, 240, 0.3); | |
| 82 | − transition: transform 0.1s; | |
| 83 | −} | |
| 84 | −.login button:active { transform: scale(0.98); } | |
| 85 | −.login-error { color: var(--red); font-size: 13px; min-height: 18px; } | |
| 86 | −.login-foot { margin-top: 20px; color: var(--muted); font-size: 12px; font-family: var(--mono); } | |
| 95 | + font-family: inherit; | |
| 96 | +} | |
| 97 | +.login input:focus { border-color: var(--orange); } | |
| 98 | +.login-error { color: var(--bad); font-size: 13px; min-height: 18px; font-weight: 600; } | |
| 99 | +.login-foot { color: var(--muted); font-size: 12px; font-family: var(--mono); } | |
| 87 | 100 | |
| 88 | −/* ================= Layout ================= */ | |
| 89 | −.app { display: flex; flex-direction: column; height: 100dvh; } | |
| 101 | +/* ================= Coquille ================= */ | |
| 102 | +.app { display: flex; flex-direction: column; height: 100dvh; position: relative; } | |
| 90 | 103 | |
| 91 | 104 | .header { |
| 92 | 105 | position: sticky; top: 0; z-index: var(--z-header); |
| 93 | − display: flex; align-items: center; gap: 8px; | |
| 94 | − background: rgba(255, 255, 255, 0.92); | |
| 95 | − -webkit-backdrop-filter: saturate(180%) blur(14px); | |
| 96 | − backdrop-filter: saturate(180%) blur(14px); | |
| 97 | − border-bottom: 1px solid var(--border); | |
| 98 | − padding: 8px 10px; | |
| 106 | + display: flex; align-items: center; justify-content: space-between; gap: 10px; | |
| 107 | + background: var(--bg); | |
| 108 | + border-bottom: 2px solid var(--ink); | |
| 109 | + padding: 8px 14px; | |
| 99 | 110 | padding-top: calc(8px + env(safe-area-inset-top)); |
| 100 | 111 | } |
| 101 | −.header-mid { flex: 1; min-width: 0; } | |
| 102 | −.project-row { | |
| 103 | − display: flex; align-items: center; gap: 8px; | |
| 104 | − background: var(--surface2); | |
| 105 | − border: 1px solid var(--border); | |
| 106 | − border-radius: 999px; | |
| 107 | − padding: 6px 12px 6px 12px; | |
| 108 | −} | |
| 109 | −.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); flex-shrink: 0; transition: background 0.3s; } | |
| 110 | −.dot.on { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); } | |
| 111 | −.project-sel { | |
| 112 | − flex: 1; min-width: 0; | |
| 113 | − background: none; color: var(--text); border: none; | |
| 114 | − font-size: 15px; font-weight: 600; outline: none; | |
| 115 | − appearance: none; -webkit-appearance: none; | |
| 116 | − text-overflow: ellipsis; | |
| 117 | − padding-right: 14px; | |
| 118 | − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%2367708a' stroke-width='2.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| 119 | − background-repeat: no-repeat; background-position: right center; background-size: 13px; | |
| 120 | −} | |
| 121 | −.icon-btn { | |
| 122 | − background: none; border: none; color: var(--text); | |
| 123 | − width: 40px; height: 40px; border-radius: 12px; cursor: pointer; flex-shrink: 0; | |
| 124 | − display: flex; align-items: center; justify-content: center; | |
| 125 | − transition: background 0.15s; | |
| 126 | −} | |
| 127 | −.icon-btn svg { width: 21px; height: 21px; } | |
| 128 | −.icon-btn:active { background: var(--surface2); } | |
| 129 | −.icon-btn.accent { color: var(--accent); background: var(--accent-soft); } | |
| 130 | −.mode-btn { | |
| 131 | − border: 1px solid var(--border); background: var(--green-soft); color: var(--green); | |
| 132 | − font-size: 12.5px; font-weight: 700; border-radius: 999px; padding: 7px 13px; | |
| 133 | − cursor: pointer; flex-shrink: 0; letter-spacing: 0.2px; | |
| 134 | −} | |
| 135 | −.mode-btn.auto { color: var(--amber); background: var(--amber-soft); } | |
| 112 | +.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 19px; letter-spacing: -0.4px; } | |
| 113 | +.conn-pill { | |
| 114 | + display: flex; align-items: center; gap: 6px; | |
| 115 | + border: 1.5px solid var(--ink); border-radius: 999px; | |
| 116 | + background: var(--card); font-size: 11.5px; font-weight: 700; | |
| 117 | + padding: 4px 10px; | |
| 118 | +} | |
| 119 | +.conn-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); } | |
| 120 | +.conn-pill.on .conn-dot { background: var(--good); } | |
| 121 | +.conn-pill.mid .conn-dot { background: var(--warn); animation: pulse 1s infinite; } | |
| 122 | +@keyframes pulse { 50% { opacity: 0.35; } } | |
| 136 | 123 | |
| 137 | −/* ================= Welcome ================= */ | |
| 138 | −.welcome { | |
| 139 | − position: absolute; inset: 0; z-index: var(--z-welcome); | |
| 140 | − display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| 141 | − gap: 8px; padding: 32px; text-align: center; pointer-events: none; | |
| 124 | +/* bannières notification / alerte */ | |
| 125 | +.banners { position: fixed; top: calc(52px + env(safe-area-inset-top)); left: 0; right: 0; z-index: var(--z-banner); display: flex; flex-direction: column; gap: 8px; padding: 8px 14px; pointer-events: none; } | |
| 126 | +.banner { | |
| 127 | + pointer-events: auto; cursor: pointer; | |
| 128 | + border: 2px solid var(--ink); border-radius: var(--r-md); | |
| 129 | + box-shadow: var(--bshadow); padding: 11px 14px; | |
| 130 | + font-weight: 700; font-size: 13.5px; | |
| 131 | + display: flex; gap: 10px; align-items: center; | |
| 132 | + animation: drop-in 0.25s ease-out; | |
| 133 | +} | |
| 134 | +@keyframes drop-in { from { transform: translateY(-14px); opacity: 0; } to { transform: none; opacity: 1; } } | |
| 135 | +.banner.info { background: var(--orange-soft); } | |
| 136 | +.banner.good { background: var(--good-soft); } | |
| 137 | +.banner.bad { background: var(--bad-soft); } | |
| 138 | +.banner.warn { background: var(--warn-soft); } | |
| 139 | + | |
| 140 | +/* vues */ | |
| 141 | +.view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } | |
| 142 | +#view-sessions, #view-eco, #view-settings { overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 14px 14px calc(86px + env(safe-area-inset-bottom)); } | |
| 143 | +.view-head { display: flex; align-items: center; justify-content: space-between; margin: 4px 2px 14px; } | |
| 144 | +.view-head h2 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.5px; } | |
| 145 | + | |
| 146 | +/* ================= Bottom nav ================= */ | |
| 147 | +.bottomnav { | |
| 148 | + position: fixed; bottom: 0; left: 0; right: 0; z-index: var(--z-nav); | |
| 149 | + display: flex; | |
| 150 | + background: var(--card); | |
| 151 | + border-top: 2px solid var(--ink); | |
| 152 | + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); | |
| 153 | +} | |
| 154 | +.nav-btn { | |
| 155 | + position: relative; flex: 1; | |
| 156 | + background: none; border: none; cursor: pointer; | |
| 157 | + display: flex; flex-direction: column; align-items: center; gap: 2px; | |
| 158 | + color: var(--muted); font-size: 10.5px; font-weight: 700; | |
| 159 | + padding: 6px 2px; border-radius: var(--r-md); | |
| 142 | 160 | } |
| 143 | −.welcome > * { pointer-events: auto; } | |
| 144 | −.welcome-mark { | |
| 145 | − width: 72px; height: 72px; | |
| 146 | − background: var(--accent-grad); color: #fff; | |
| 147 | − border-radius: 22px; | |
| 148 | − display: flex; align-items: center; justify-content: center; | |
| 149 | − font-size: 25px; font-weight: 800; | |
| 150 | − box-shadow: 0 10px 30px rgba(53, 86, 240, 0.3); | |
| 151 | − margin-bottom: 8px; | |
| 161 | +.nav-btn svg { width: 23px; height: 23px; } | |
| 162 | +.nav-btn.active { color: var(--ink); } | |
| 163 | +.nav-btn.active svg { color: var(--orange-deep); } | |
| 164 | +.nav-btn .badge { | |
| 165 | + position: absolute; top: 3px; right: calc(50% - 17px); | |
| 166 | + width: 9px; height: 9px; border-radius: 50%; | |
| 167 | + background: var(--orange); border: 1.5px solid var(--ink); | |
| 152 | 168 | } |
| 153 | −.welcome h2 { margin: 0; font-size: 21px; letter-spacing: -0.3px; } | |
| 154 | −.welcome p { margin: 0; color: var(--muted); font-size: 14.5px; max-width: 300px; } | |
| 155 | −.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 16px; } | |
| 156 | −.hint { | |
| 157 | − background: var(--surface); border: 1px solid var(--border); | |
| 158 | − color: var(--text); font-size: 13.5px; | |
| 159 | − border-radius: 999px; padding: 9px 15px; cursor: pointer; | |
| 160 | − box-shadow: var(--shadow-sm); | |
| 169 | + | |
| 170 | +/* ================= Vue chat ================= */ | |
| 171 | +.chatbar { | |
| 172 | + display: flex; gap: 7px; align-items: center; | |
| 173 | + padding: 9px 12px; overflow-x: auto; scrollbar-width: none; | |
| 174 | + border-bottom: 1.5px solid var(--line); | |
| 175 | + background: var(--bg); | |
| 176 | +} | |
| 177 | +.chatbar::-webkit-scrollbar { display: none; } | |
| 178 | +.pill-select { | |
| 179 | + appearance: none; -webkit-appearance: none; | |
| 180 | + background: var(--card); color: var(--ink); | |
| 181 | + border: 2px solid var(--ink); border-radius: 999px; | |
| 182 | + font-weight: 800; font-size: 13.5px; font-family: inherit; | |
| 183 | + padding: 7px 26px 7px 13px; | |
| 184 | + box-shadow: var(--bshadow-sm); | |
| 185 | + max-width: 46vw; text-overflow: ellipsis; | |
| 186 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 9l6 6 6-6' stroke='%231a1611' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); | |
| 187 | + background-repeat: no-repeat; background-position: right 9px center; background-size: 12px; | |
| 188 | +} | |
| 189 | +.chip { | |
| 190 | + border: 1.5px solid var(--ink); border-radius: 999px; | |
| 191 | + background: var(--card2); font-weight: 700; font-size: 12.5px; | |
| 192 | + padding: 7px 12px; cursor: pointer; white-space: nowrap; flex-shrink: 0; | |
| 193 | +} | |
| 194 | +.chip.orange { background: var(--orange-soft); } | |
| 195 | +.chip.warn { background: var(--warn-soft); color: var(--warn); } | |
| 196 | +.chip.ghost { border-style: dashed; color: var(--muted); } | |
| 197 | +.sess-meta { | |
| 198 | + display: flex; gap: 12px; flex-wrap: wrap; | |
| 199 | + padding: 6px 14px; font-size: 11px; font-family: var(--mono); color: var(--muted); | |
| 200 | + border-bottom: 1.5px solid var(--line); background: var(--card2); | |
| 161 | 201 | } |
| 162 | −.hint:active { background: var(--surface2); } | |
| 163 | 202 | |
| 164 | −/* ================= Messages ================= */ | |
| 165 | −.app { position: relative; } | |
| 166 | 203 | .messages { |
| 167 | 204 | flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; |
| 168 | − padding: 16px 14px 24px; | |
| 205 | + padding: 16px 13px calc(20px); | |
| 169 | 206 | display: flex; flex-direction: column; gap: 12px; |
| 170 | 207 | max-width: 780px; width: 100%; margin: 0 auto; |
| 171 | 208 | } |
| 172 | 209 | @keyframes msg-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } |
| 173 | −.messages > * { animation: msg-in 0.22s ease-out; } | |
| 210 | +.messages > * { animation: msg-in 0.2s ease-out; } | |
| 174 | 211 | |
| 175 | 212 | .msg-user { |
| 176 | − align-self: flex-end; max-width: 85%; | |
| 177 | − background: var(--accent-grad); color: #fff; | |
| 178 | − border-radius: 20px 20px 6px 20px; | |
| 179 | − padding: 11px 16px; | |
| 213 | + align-self: flex-end; max-width: 86%; | |
| 214 | + background: var(--orange); color: var(--ink); | |
| 215 | + border: 2px solid var(--ink); border-radius: var(--r-lg) var(--r-lg) 6px var(--r-lg); | |
| 216 | + box-shadow: var(--bshadow-sm); | |
| 217 | + padding: 10px 15px; font-weight: 600; | |
| 180 | 218 | white-space: pre-wrap; word-break: break-word; |
| 181 | − box-shadow: 0 3px 10px rgba(53, 86, 240, 0.25); | |
| 182 | − font-size: 15.5px; | |
| 183 | 219 | } |
| 184 | 220 | .msg-claude { |
| 185 | 221 | align-self: stretch; |
| 186 | − background: var(--surface); | |
| 187 | − border: 1px solid var(--border); | |
| 188 | − border-radius: var(--r-lg); | |
| 189 | − padding: 13px 16px; | |
| 190 | − box-shadow: var(--shadow-sm); | |
| 191 | − word-break: break-word; | |
| 222 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | |
| 223 | + box-shadow: var(--bshadow-sm); | |
| 224 | + padding: 13px 16px; word-break: break-word; | |
| 192 | 225 | } |
| 193 | 226 | .msg-claude .md > :first-child { margin-top: 0; } |
| 194 | 227 | .msg-claude .md > :last-child { margin-bottom: 0; } |
| 195 | 228 | .md p { margin: 0.55em 0; } |
| 196 | 229 | .md pre { |
| 197 | − background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--r-sm); | |
| 230 | + background: #f2efe7; border: 1.5px solid var(--line); border-radius: var(--r-sm); | |
| 198 | 231 | padding: 11px; overflow-x: auto; font-size: 12.5px; line-height: 1.5; |
| 199 | 232 | } |
| 200 | 233 | .md code { font-family: var(--mono); font-size: 0.88em; } |
| 201 | −.md :not(pre) > code { background: var(--accent-soft); color: #2c46c8; padding: 1px 6px; border-radius: 6px; } | |
| 202 | −.md h1, .md h2, .md h3, .md h4 { font-size: 1.02em; margin: 0.9em 0 0.35em; } | |
| 234 | +.md :not(pre) > code { background: var(--orange-soft); padding: 1px 6px; border-radius: 6px; } | |
| 235 | +.md h1, .md h2, .md h3, .md h4 { font-size: 1.03em; margin: 0.9em 0 0.35em; } | |
| 203 | 236 | .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 13px; } |
| 204 | −.md th, .md td { border: 1px solid var(--border); padding: 5px 9px; } | |
| 205 | −.md th { background: var(--surface2); } | |
| 206 | −.md a { color: var(--accent); } | |
| 237 | +.md th, .md td { border: 1.5px solid var(--line); padding: 5px 9px; } | |
| 238 | +.md th { background: var(--card2); } | |
| 239 | +.md a { color: var(--orange-deep); font-weight: 700; } | |
| 207 | 240 | .md ul, .md ol { padding-left: 1.35em; margin: 0.5em 0; } |
| 208 | −.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--border-strong); color: var(--muted); } | |
| 209 | −.md hr { border: none; border-top: 1px solid var(--border); } | |
| 241 | +.md blockquote { margin: 0.6em 0; padding: 2px 12px; border-left: 3px solid var(--orange); color: var(--muted); } | |
| 210 | 242 | |
| 211 | 243 | .thinking { |
| 212 | 244 | color: var(--muted); font-style: italic; font-size: 13px; |
| 213 | − border-left: 2.5px solid var(--border-strong); padding-left: 12px; | |
| 245 | + border-left: 3px solid var(--line); padding-left: 12px; | |
| 214 | 246 | white-space: pre-wrap; word-break: break-word; |
| 215 | 247 | } |
| 216 | −details.thinking-box { padding: 2px 4px; } | |
| 217 | −details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; } | |
| 218 | − | |
| 248 | +details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: pointer; font-weight: 600; } | |
| 219 | 249 | .meta-line { |
| 220 | 250 | align-self: center; text-align: center; |
| 221 | 251 | color: var(--muted); font-size: 11px; font-family: var(--mono); |
| 222 | − background: var(--surface2); border-radius: 999px; padding: 3px 12px; | |
| 252 | + background: var(--card2); border: 1.5px solid var(--line); border-radius: 999px; padding: 3px 12px; | |
| 223 | 253 | max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 224 | 254 | } |
| 255 | +.notice-line { align-self: center; color: var(--warn); font-size: 12px; font-weight: 700; } | |
| 225 | 256 | .error-box { |
| 226 | − background: var(--red-soft); border: 1px solid #f3c2c6; | |
| 227 | − color: var(--red); border-radius: var(--r-md); padding: 11px 14px; | |
| 228 | − font-size: 13px; white-space: pre-wrap; word-break: break-word; | |
| 257 | + background: var(--bad-soft); border: 2px solid var(--bad); | |
| 258 | + color: var(--bad); border-radius: var(--r-md); padding: 11px 14px; | |
| 259 | + font-size: 13px; white-space: pre-wrap; word-break: break-word; font-weight: 600; | |
| 229 | 260 | } |
| 230 | 261 | |
| 231 | −/* ================= Cartes outils ================= */ | |
| 262 | +/* --- cartes outils --- */ | |
| 232 | 263 | .tool { |
| 233 | − background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); | |
| 234 | − overflow: hidden; font-size: 13px; | |
| 235 | − box-shadow: var(--shadow-sm); | |
| 264 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-md); | |
| 265 | + overflow: hidden; font-size: 13px; box-shadow: var(--bshadow-sm); | |
| 236 | 266 | } |
| 237 | 267 | .tool summary { |
| 238 | 268 | display: flex; align-items: center; gap: 10px; |
| 239 | − padding: 9px 13px; cursor: pointer; list-style: none; user-select: none; | |
| 240 | − min-height: 44px; | |
| 269 | + padding: 9px 12px; cursor: pointer; list-style: none; user-select: none; min-height: 44px; | |
| 241 | 270 | } |
| 242 | 271 | .tool summary::-webkit-details-marker { display: none; } |
| 243 | 272 | .tool .t-icon { |
| 244 | − flex-shrink: 0; width: 28px; height: 28px; border-radius: 8px; | |
| 273 | + flex-shrink: 0; width: 28px; height: 28px; border-radius: 9px; | |
| 274 | + border: 1.5px solid var(--ink); | |
| 245 | 275 | display: flex; align-items: center; justify-content: center; |
| 246 | − font-size: 14px; background: var(--surface2); | |
| 276 | + font-size: 13px; background: var(--card2); | |
| 247 | 277 | } |
| 248 | −.tool .t-icon.bash { background: #eceff5; } | |
| 249 | −.tool .t-icon.edit { background: var(--amber-soft); } | |
| 250 | −.tool .t-icon.read { background: #e8f3fd; } | |
| 251 | −.tool .t-icon.web { background: #e9f6f2; } | |
| 252 | −.tool .t-name { font-weight: 700; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; color: var(--text); } | |
| 278 | +.tool .t-icon.bash { background: var(--ink); color: var(--orange); } | |
| 279 | +.tool .t-icon.edit { background: var(--orange-soft); } | |
| 280 | +.tool .t-icon.read { background: var(--card2); } | |
| 281 | +.tool .t-icon.web { background: var(--good-soft); } | |
| 282 | +.tool .t-name { font-weight: 800; font-family: var(--mono); font-size: 12.5px; flex-shrink: 0; } | |
| 253 | 283 | .tool .t-sub { |
| 254 | 284 | color: var(--muted); font-family: var(--mono); font-size: 12px; |
| 255 | 285 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; |
| 256 | 286 | } |
| 257 | −.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 700; border-radius: 999px; padding: 3px 8px; } | |
| 258 | −.tool .t-state.pending { color: var(--amber); background: var(--amber-soft); } | |
| 259 | −.tool .t-state.ok { color: var(--green); background: var(--green-soft); } | |
| 260 | −.tool .t-state.err { color: var(--red); background: var(--red-soft); } | |
| 261 | −.tool-body { border-top: 1px solid var(--border); padding: 9px 13px 11px; background: var(--surface2); } | |
| 287 | +.tool .t-state { flex-shrink: 0; font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 9px; border: 1.5px solid transparent; } | |
| 288 | +.tool .t-state.pending { color: var(--warn); background: var(--warn-soft); } | |
| 289 | +.tool .t-state.ok { color: var(--good); background: var(--good-soft); } | |
| 290 | +.tool .t-state.err { color: var(--bad); background: var(--bad-soft); } | |
| 291 | +.tool-body { border-top: 2px solid var(--ink); padding: 9px 13px 11px; background: var(--card2); } | |
| 262 | 292 | .tool-body pre { |
| 263 | − margin: 6px 0; background: #ffffff; border: 1px solid var(--border); | |
| 293 | + margin: 6px 0; background: var(--card); border: 1.5px solid var(--line); | |
| 264 | 294 | border-radius: var(--r-sm); padding: 9px; |
| 265 | 295 | overflow-x: auto; font-size: 12px; font-family: var(--mono); |
| 266 | 296 | white-space: pre-wrap; word-break: break-word; |
| 267 | 297 | } |
| 268 | −.tool-body .label { | |
| 269 | − color: var(--muted); font-size: 10.5px; font-weight: 700; | |
| 270 | − text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px; | |
| 271 | −} | |
| 272 | −.see-more { background: none; border: none; color: var(--accent); font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 4px 0; } | |
| 298 | +.tool-body .label { color: var(--muted); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-top: 6px; } | |
| 299 | +.see-more { background: none; border: none; color: var(--orange-deep); font-size: 12.5px; font-weight: 800; cursor: pointer; padding: 4px 0; } | |
| 273 | 300 | |
| 274 | −/* diffs — style GitHub clair */ | |
| 275 | −.diff { | |
| 276 | − font-family: var(--mono); font-size: 12px; margin: 6px 0; | |
| 277 | − border: 1px solid var(--border); border-radius: var(--r-sm); | |
| 278 | − overflow: hidden; background: #fff; | |
| 279 | −} | |
| 301 | +.diff { font-family: var(--mono); font-size: 12px; margin: 6px 0; border: 1.5px solid var(--line); border-radius: var(--r-sm); overflow: hidden; background: var(--card); } | |
| 280 | 302 | .diff .dl { padding: 2px 10px; white-space: pre-wrap; word-break: break-word; } |
| 281 | −.diff .del { background: #ffebe9; color: #82071e; } | |
| 282 | −.diff .add { background: #e6ffec; color: #055d20; } | |
| 303 | +.diff .del { background: #ffe4e1; color: #8c1622; } | |
| 304 | +.diff .add { background: #ddf3e4; color: #0c5326; } | |
| 283 | 305 | |
| 284 | −/* todos */ | |
| 285 | 306 | .todos { margin: 4px 0; padding: 0; list-style: none; font-size: 13.5px; } |
| 286 | 307 | .todos li { padding: 3px 0; } |
| 287 | 308 | .todos .done { color: var(--muted); text-decoration: line-through; } |
| 288 | −.todos .doing { color: var(--accent); font-weight: 600; } | |
| 309 | +.todos .doing { color: var(--orange-deep); font-weight: 800; } | |
| 289 | 310 | |
| 290 | −/* ================= Permission ================= */ | |
| 311 | +/* --- permission --- */ | |
| 291 | 312 | .perm { |
| 292 | − background: var(--amber-soft); border: 1.5px solid #f0c36b; | |
| 313 | + background: var(--warn-soft); border: 2px solid var(--ink); | |
| 293 | 314 | border-radius: var(--r-lg); padding: 14px; font-size: 13.5px; |
| 294 | − box-shadow: var(--shadow-md); | |
| 315 | + box-shadow: 4px 4px 0 var(--ink); | |
| 295 | 316 | } |
| 296 | −.perm-title { font-weight: 700; color: var(--amber); margin-bottom: 8px; font-size: 14px; } | |
| 317 | +.perm-title { font-weight: 800; margin-bottom: 8px; font-size: 14.5px; } | |
| 297 | 318 | .perm pre { |
| 298 | − background: #fff; border: 1px solid #eddcb8; border-radius: var(--r-sm); padding: 9px; | |
| 319 | + background: var(--card); border: 1.5px solid var(--line); border-radius: var(--r-sm); padding: 9px; | |
| 299 | 320 | overflow-x: auto; font-size: 12px; font-family: var(--mono); |
| 300 | 321 | white-space: pre-wrap; word-break: break-word; |
| 301 | 322 | max-height: 200px; overflow-y: auto; margin: 0; |
| 302 | 323 | } |
| 303 | −.perm-actions { display: flex; gap: 10px; margin-top: 12px; } | |
| 324 | +.perm-actions { display: flex; gap: 8px; margin-top: 12px; } | |
| 304 | 325 | .perm-actions button { |
| 305 | − flex: 1; padding: 12px; border-radius: var(--r-md); border: none; | |
| 306 | − font-size: 15px; font-weight: 700; cursor: pointer; min-height: 46px; | |
| 307 | −} | |
| 308 | −.perm-allow { background: var(--green); color: #fff; box-shadow: 0 3px 10px rgba(23, 146, 74, 0.3); } | |
| 309 | −.perm-deny { background: #fff; color: var(--red); border: 1.5px solid #f3c2c6 !important; } | |
| 310 | −.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 600; } | |
| 326 | + flex: 1; padding: 11px 6px; border-radius: 999px; | |
| 327 | + font-size: 13.5px; font-weight: 800; cursor: pointer; min-height: 46px; | |
| 328 | + border: 2px solid var(--ink); | |
| 329 | +} | |
| 330 | +.perm-allow { background: var(--good); color: #fff; box-shadow: var(--bshadow-sm); } | |
| 331 | +.perm-always { background: var(--good-soft); color: var(--good); } | |
| 332 | +.perm-deny { background: var(--card); color: var(--bad); } | |
| 333 | +.perm-resolved { color: var(--muted); font-size: 12.5px; margin-top: 10px; font-weight: 700; } | |
| 311 | 334 | |
| 312 | −/* ================= Indicateur travail ================= */ | |
| 313 | 335 | .working { |
| 314 | 336 | align-self: flex-start; |
| 315 | 337 | display: flex; align-items: center; gap: 10px; |
| 316 | − color: var(--muted); font-size: 13.5px; | |
| 317 | − background: var(--surface); border: 1px solid var(--border); | |
| 318 | − border-radius: 999px; padding: 8px 16px; | |
| 319 | − box-shadow: var(--shadow-sm); | |
| 338 | + font-size: 13.5px; font-weight: 700; | |
| 339 | + background: var(--card); border: 2px solid var(--ink); | |
| 340 | + border-radius: 999px; padding: 8px 16px; box-shadow: var(--bshadow-sm); | |
| 320 | 341 | } |
| 321 | 342 | .dots { display: flex; gap: 4px; } |
| 322 | −.dots i { | |
| 323 | − width: 6px; height: 6px; border-radius: 50%; background: var(--accent); | |
| 324 | − animation: pulse 1.2s ease-in-out infinite; | |
| 343 | +.dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1px solid var(--ink); animation: bounce 1.1s ease-in-out infinite; } | |
| 344 | +.dots i:nth-child(2) { animation-delay: 0.14s; } | |
| 345 | +.dots i:nth-child(3) { animation-delay: 0.28s; } | |
| 346 | +@keyframes bounce { 0%, 100% { transform: translateY(0); opacity: 0.5; } 40% { transform: translateY(-4px); opacity: 1; } } | |
| 347 | + | |
| 348 | +/* --- accueil --- */ | |
| 349 | +.welcome { | |
| 350 | + position: absolute; inset: 130px 0 140px; z-index: 5; | |
| 351 | + display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| 352 | + gap: 9px; padding: 28px; text-align: center; pointer-events: none; | |
| 353 | +} | |
| 354 | +.welcome > * { pointer-events: auto; } | |
| 355 | +.welcome h2 { margin: 6px 0 0; font-size: 22px; font-weight: 800; letter-spacing: -0.4px; } | |
| 356 | +.welcome p { margin: 0; color: var(--muted); font-size: 14px; max-width: 300px; font-weight: 600; } | |
| 357 | +.hints { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; } | |
| 358 | +.hint { | |
| 359 | + background: var(--card); border: 1.5px solid var(--ink); | |
| 360 | + font-size: 13px; font-weight: 700; | |
| 361 | + border-radius: 999px; padding: 9px 14px; cursor: pointer; | |
| 362 | + box-shadow: var(--bshadow-sm); | |
| 325 | 363 | } |
| 326 | −.dots i:nth-child(2) { animation-delay: 0.15s; } | |
| 327 | −.dots i:nth-child(3) { animation-delay: 0.3s; } | |
| 328 | −@keyframes pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 40% { opacity: 1; transform: scale(1.1); } } | |
| 329 | 364 | |
| 330 | −/* ================= Composer ================= */ | |
| 365 | +/* --- composer --- */ | |
| 331 | 366 | .composer { |
| 332 | 367 | position: sticky; bottom: 0; z-index: var(--z-composer); |
| 333 | − background: linear-gradient(to top, var(--bg) 65%, transparent); | |
| 334 | − padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); | |
| 368 | + background: var(--bg); | |
| 369 | + padding: 8px 12px calc(72px + env(safe-area-inset-bottom)); | |
| 335 | 370 | } |
| 336 | 371 | .composer-inner { |
| 337 | 372 | display: flex; align-items: flex-end; gap: 8px; |
| 338 | 373 | max-width: 780px; margin: 0 auto; |
| 339 | − background: var(--surface); | |
| 340 | − border: 1px solid var(--border); | |
| 341 | − border-radius: 26px; | |
| 342 | − padding: 7px 7px 7px 18px; | |
| 343 | − box-shadow: var(--shadow-md); | |
| 374 | + background: var(--card); | |
| 375 | + border: 2px solid var(--ink); border-radius: var(--r-xl); | |
| 376 | + box-shadow: var(--bshadow); | |
| 377 | + padding: 6px 6px 6px 17px; | |
| 344 | 378 | } |
| 345 | 379 | #input { |
| 346 | − flex: 1; background: none; color: var(--text); | |
| 347 | − border: none; | |
| 348 | − padding: 9px 0; font-size: 16px; line-height: 1.45; | |
| 349 | − resize: none; outline: none; max-height: 140px; | |
| 350 | − font-family: inherit; | |
| 380 | + flex: 1; background: none; color: var(--ink); | |
| 381 | + border: none; padding: 10px 0; font-size: 16px; line-height: 1.45; | |
| 382 | + resize: none; outline: none; max-height: 140px; font-family: inherit; font-weight: 600; | |
| 351 | 383 | } |
| 352 | −#input::placeholder { color: var(--muted); } | |
| 384 | +#input::placeholder { color: var(--muted); font-weight: 600; } | |
| 353 | 385 | .send-btn { |
| 354 | − width: 40px; height: 40px; border-radius: 50%; border: none; | |
| 355 | − background: var(--accent-grad); color: #fff; | |
| 386 | + width: 42px; height: 42px; border-radius: 50%; | |
| 387 | + border: 2px solid var(--ink); | |
| 388 | + background: var(--orange); color: var(--ink); | |
| 356 | 389 | cursor: pointer; flex-shrink: 0; |
| 357 | 390 | display: flex; align-items: center; justify-content: center; |
| 358 | − box-shadow: 0 3px 10px rgba(53, 86, 240, 0.35); | |
| 359 | − transition: transform 0.1s, opacity 0.15s; | |
| 391 | + box-shadow: var(--bshadow-sm); | |
| 392 | + transition: transform 0.08s; | |
| 360 | 393 | } |
| 361 | 394 | .send-btn svg { width: 19px; height: 19px; } |
| 362 | −.send-btn:active { transform: scale(0.92); } | |
| 395 | +.send-btn:active { transform: translate(1px, 1px); box-shadow: none; } | |
| 363 | 396 | .send-btn:disabled { opacity: 0.35; } |
| 364 | −.send-btn.stop { background: var(--red); box-shadow: 0 3px 10px rgba(213, 48, 62, 0.35); } | |
| 365 | − | |
| 366 | −/* ================= Drawer ================= */ | |
| 367 | −.drawer { position: fixed; inset: 0; z-index: var(--z-drawer); } | |
| 368 | −.drawer-back { position: absolute; inset: 0; background: rgba(23, 32, 58, 0.35); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); } | |
| 369 | −@keyframes slide-in { from { transform: translateX(-100%); } to { transform: none; } } | |
| 370 | −.drawer-panel { | |
| 371 | − position: absolute; top: 0; left: 0; bottom: 0; width: min(330px, 88vw); | |
| 372 | − background: var(--surface); | |
| 373 | − box-shadow: var(--shadow-lg); | |
| 374 | − display: flex; flex-direction: column; | |
| 375 | − padding-top: env(safe-area-inset-top); | |
| 376 | − animation: slide-in 0.22s ease-out; | |
| 377 | − border-radius: 0 20px 20px 0; | |
| 378 | −} | |
| 379 | −.drawer-head { | |
| 380 | − display: flex; align-items: center; justify-content: space-between; | |
| 381 | − padding: 14px 10px 12px 20px; border-bottom: 1px solid var(--border); | |
| 382 | − font-weight: 700; font-size: 17px; | |
| 383 | −} | |
| 384 | −.chat-list { flex: 1; overflow-y: auto; padding: 8px; } | |
| 385 | −.chat-item { | |
| 386 | − padding: 12px 12px; border-radius: var(--r-md); cursor: pointer; | |
| 387 | − display: flex; align-items: center; gap: 8px; | |
| 388 | − margin-bottom: 2px; | |
| 389 | −} | |
| 390 | −.chat-item:active { background: var(--surface2); } | |
| 391 | −.chat-item.active { background: var(--accent-soft); } | |
| 392 | −.chat-item .ci-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } | |
| 393 | −.chat-item .ci-title { font-size: 14.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 394 | −.chat-item .ci-meta { font-size: 11.5px; color: var(--muted); } | |
| 395 | −.chat-item .ci-running { color: var(--green); font-weight: 700; } | |
| 396 | −.ci-del { | |
| 397 | − background: none; border: none; color: var(--muted); cursor: pointer; | |
| 398 | − width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0; | |
| 399 | − display: flex; align-items: center; justify-content: center; font-size: 15px; | |
| 400 | −} | |
| 401 | −.ci-del:active { background: var(--red-soft); color: var(--red); } | |
| 402 | −.chat-empty { color: var(--muted); text-align: center; padding: 32px 16px; font-size: 14px; } | |
| 403 | −.logout-btn { | |
| 404 | − margin: 10px 16px calc(14px + env(safe-area-inset-bottom)); | |
| 405 | − background: var(--surface2); border: 1px solid var(--border); color: var(--red); | |
| 406 | − border-radius: var(--r-md); padding: 13px; font-size: 14.5px; font-weight: 600; cursor: pointer; | |
| 397 | +.send-btn.stop { background: var(--bad); color: #fff; } | |
| 398 | +.slash-hint { | |
| 399 | + max-width: 780px; margin: 6px auto 0; display: flex; gap: 6px; flex-wrap: wrap; | |
| 407 | 400 | } |
| 408 | − | |
| 409 | −/* écrans plus larges */ | |
| 410 | −@media (min-width: 700px) { | |
| 411 | − .header { padding-left: 16px; padding-right: 16px; } | |
| 412 | − .messages { padding: 24px 20px 32px; } | |
| 401 | +.slash-hint button { | |
| 402 | + border: 1.5px solid var(--ink); background: var(--card2); border-radius: 999px; | |
| 403 | + font-size: 12px; font-weight: 700; padding: 5px 11px; cursor: pointer; | |
| 413 | 404 | } |
| 405 | + | |
| 406 | +/* ================= Sessions ================= */ | |
| 407 | +.sess-list { display: flex; flex-direction: column; gap: 12px; max-width: 780px; margin: 0 auto; width: 100%; } | |
| 408 | +.sess-card { | |
| 409 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | |
| 410 | + box-shadow: var(--bshadow-sm); padding: 13px 15px; cursor: pointer; | |
| 411 | + display: flex; flex-direction: column; gap: 6px; | |
| 412 | +} | |
| 413 | +.sess-top { display: flex; align-items: center; gap: 8px; } | |
| 414 | +.sess-title { flex: 1; min-width: 0; font-weight: 800; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 415 | +.sess-badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; } | |
| 416 | +.sess-badge.running { background: var(--orange); animation: pulse 1.4s infinite; } | |
| 417 | +.sess-badge.waiting_perm { background: var(--warn-soft); color: var(--warn); } | |
| 418 | +.sess-badge.idle { background: var(--card2); color: var(--muted); } | |
| 419 | +.sess-info { display: flex; gap: 10px; flex-wrap: wrap; font-size: 11.5px; color: var(--muted); font-family: var(--mono); } | |
| 420 | +.sess-actions { display: flex; gap: 8px; margin-top: 4px; } | |
| 421 | +.sess-actions button { | |
| 422 | + border: 1.5px solid var(--ink); border-radius: 999px; background: var(--card2); | |
| 423 | + font-size: 12px; font-weight: 800; padding: 6px 13px; cursor: pointer; | |
| 424 | +} | |
| 425 | +.sess-actions .del { color: var(--bad); } | |
| 426 | +.sess-empty { color: var(--muted); text-align: center; padding: 40px 16px; font-weight: 600; } | |
| 427 | + | |
| 428 | +/* ================= Écosystème ================= */ | |
| 429 | +.eco-stamp { font-size: 11px; color: var(--muted); font-family: var(--mono); } | |
| 430 | +.eco-overview { | |
| 431 | + display: flex; gap: 10px; margin-bottom: 14px; | |
| 432 | + max-width: 780px; margin-left: auto; margin-right: auto; | |
| 433 | +} | |
| 434 | +.eco-stat { | |
| 435 | + flex: 1; background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | |
| 436 | + box-shadow: var(--bshadow-sm); padding: 10px 12px; text-align: center; | |
| 437 | +} | |
| 438 | +.eco-stat .v { font-size: 22px; font-weight: 800; letter-spacing: -0.5px; } | |
| 439 | +.eco-stat .l { font-size: 10.5px; color: var(--muted); font-weight: 800; text-transform: uppercase; letter-spacing: 0.6px; } | |
| 440 | +.eco-stat.ok .v { color: var(--good); } | |
| 441 | +.eco-stat.bad .v { color: var(--bad); } | |
| 442 | +.eco-grid { | |
| 443 | + display: grid; grid-template-columns: 1fr; gap: 12px; | |
| 444 | + max-width: 780px; margin: 0 auto; width: 100%; | |
| 445 | +} | |
| 446 | +@media (min-width: 560px) { .eco-grid { grid-template-columns: 1fr 1fr; } } | |
| 447 | +.eco-loading { color: var(--muted); text-align: center; padding: 40px; font-weight: 700; } | |
| 448 | +.site-card { | |
| 449 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | |
| 450 | + box-shadow: var(--bshadow-sm); padding: 12px 14px; cursor: pointer; | |
| 451 | + display: flex; flex-direction: column; gap: 8px; | |
| 452 | + transition: transform 0.08s; | |
| 453 | +} | |
| 454 | +.site-card:active { transform: translate(2px, 2px); box-shadow: none; } | |
| 455 | +.site-card.down { border-color: var(--bad); box-shadow: 3px 3px 0 var(--bad); } | |
| 456 | +.site-top { display: flex; align-items: center; gap: 8px; } | |
| 457 | +.site-name { font-weight: 800; font-size: 16px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 458 | +.st-pill { font-size: 10.5px; font-weight: 800; border-radius: 999px; padding: 3px 10px; border: 1.5px solid var(--ink); flex-shrink: 0; display: flex; gap: 4px; align-items: center; } | |
| 459 | +.st-pill.up { background: var(--good-soft); color: var(--good); } | |
| 460 | +.st-pill.slow { background: var(--warn-soft); color: var(--warn); } | |
| 461 | +.st-pill.down { background: var(--bad); color: #fff; } | |
| 462 | +.site-mid { display: flex; align-items: flex-end; gap: 10px; } | |
| 463 | +.site-ms { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; } | |
| 464 | +.site-ms small { font-size: 11px; color: var(--muted); font-weight: 700; } | |
| 465 | +.spark { flex: 1; height: 30px; } | |
| 466 | +.site-foot { display: flex; gap: 9px; flex-wrap: wrap; font-size: 10.5px; color: var(--muted); font-family: var(--mono); } | |
| 467 | +.site-foot b { color: var(--ink); } | |
| 468 | + | |
| 469 | +/* feuille détail */ | |
| 470 | +.sheet { position: fixed; inset: 0; z-index: var(--z-sheet); } | |
| 471 | +.sheet-back { position: absolute; inset: 0; background: rgba(26, 22, 17, 0.45); } | |
| 472 | +@keyframes sheet-up { from { transform: translateY(60%); } to { transform: none; } } | |
| 473 | +.sheet-panel { | |
| 474 | + position: absolute; left: 0; right: 0; bottom: 0; | |
| 475 | + max-height: 88dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; | |
| 476 | + background: var(--bg); border: 2px solid var(--ink); border-bottom: none; | |
| 477 | + border-radius: var(--r-xl) var(--r-xl) 0 0; | |
| 478 | + padding: 8px 16px calc(20px + env(safe-area-inset-bottom)); | |
| 479 | + animation: sheet-up 0.22s ease-out; | |
| 480 | +} | |
| 481 | +.sheet-grip { width: 42px; height: 5px; border-radius: 3px; background: var(--line); margin: 6px auto 12px; } | |
| 482 | +.sheet-h { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } | |
| 483 | +.sheet-h h3 { margin: 0; font-size: 21px; font-weight: 800; flex: 1; } | |
| 484 | +.sheet-sec { background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); box-shadow: var(--bshadow-sm); padding: 13px 15px; margin-bottom: 12px; } | |
| 485 | +.sheet-sec h4 { margin: 0 0 9px; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); } | |
| 486 | +.kv { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; font-size: 13.5px; } | |
| 487 | +.kv b { font-family: var(--mono); font-weight: 700; } | |
| 488 | +.tile-row { display: flex; gap: 9px; } | |
| 489 | +.tile { flex: 1; background: var(--card2); border: 1.5px solid var(--line); border-radius: var(--r-md); padding: 8px 6px; text-align: center; } | |
| 490 | +.tile .v { font-size: 17px; font-weight: 800; } | |
| 491 | +.tile .l { font-size: 10px; color: var(--muted); font-weight: 800; text-transform: uppercase; } | |
| 492 | +.chart { width: 100%; } | |
| 493 | +.chart-tip { | |
| 494 | + position: absolute; pointer-events: none; transform: translate(-50%, -110%); | |
| 495 | + background: var(--ink); color: var(--bg); border-radius: 8px; | |
| 496 | + font-size: 11px; font-family: var(--mono); padding: 4px 8px; white-space: nowrap; | |
| 497 | +} | |
| 498 | +.inc-item { display: flex; gap: 8px; padding: 7px 0; border-bottom: 1.5px solid var(--line); font-size: 12.5px; align-items: baseline; } | |
| 499 | +.inc-item:last-child { border-bottom: none; } | |
| 500 | +.inc-dot { color: var(--bad); font-weight: 800; } | |
| 501 | +.inc-ok { color: var(--good); font-weight: 800; } | |
| 502 | + | |
| 503 | +/* ================= Réglages ================= */ | |
| 504 | +.set-card { | |
| 505 | + max-width: 780px; margin: 0 auto 12px; width: 100%; | |
| 506 | + background: var(--card); border: 2px solid var(--ink); border-radius: var(--r-lg); | |
| 507 | + box-shadow: var(--bshadow-sm); padding: 14px 16px; | |
| 508 | +} | |
| 509 | +.set-label { font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); margin-bottom: 10px; } | |
| 510 | +.seg { display: flex; gap: 7px; } | |
| 511 | +.seg.vert { flex-direction: column; } | |
| 512 | +.seg button { | |
| 513 | + flex: 1; border: 1.5px solid var(--ink); border-radius: 999px; | |
| 514 | + background: var(--card2); font-weight: 700; font-size: 13.5px; | |
| 515 | + padding: 10px 12px; cursor: pointer; text-align: center; | |
| 516 | +} | |
| 517 | +.seg.vert button { text-align: left; padding-left: 16px; } | |
| 518 | +.seg button.on { background: var(--orange); font-weight: 800; box-shadow: var(--bshadow-sm); } | |
| 519 | +.set-row { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; font-size: 14px; font-weight: 600; } | |
| 520 | +.set-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } | |
added
server/monitor.js
+240 −0
@@ -0,0 +1,240 @@ | ||
| 1 | +// monitor.js — collecteur Écosystème Groupe KA | |
| 2 | +// Health checks HTTPS (45 s) + certificats TLS, sweep des nœuds (5 min : | |
| 3 | +// charge, RAM, disque, PM2, dernier commit), incidents, historique SQLite, | |
| 4 | +// push temps réel via le WebSocket du backend. | |
| 5 | +import https from 'node:https'; | |
| 6 | +import path from 'node:path'; | |
| 7 | +import { execFile } from 'node:child_process'; | |
| 8 | +import { DatabaseSync } from 'node:sqlite'; | |
| 9 | + | |
| 10 | +// Sites du Groupe KA (source : cluster-deployments.json 2026-08-19) | |
| 11 | +export const SITES = [ | |
| 12 | + { app: 'lou-ka', label: 'Lou·Ka', domain: 'www.lou-ka.com', node: 'M3U96b', pm2: 'lou-ka-web', dir: '~/apps/lou-ka', deployed: '2026-08' }, | |
| 13 | + { app: 'immo-ka', label: 'Immo·Ka', domain: 'www.immo-ka.com', node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka', deployed: '2026-08' }, | |
| 14 | + { app: 'vrai-prix', label: 'Vrai-Prix', domain: 'www.vrai-prix.com', node: 'M3U96a', pm2: 'vrai-prix', dir: '~/apps/vrai-prix', deployed: '2026-08' }, | |
| 15 | + { app: 'valoplex', label: 'ValoPlex', domain: 'www.valoplex.com', node: 'M3U96a', pm2: 'valoplex', dir: '~/apps/valoplex', deployed: '2026-08' }, | |
| 16 | + { app: 'auto-ka', label: 'Auto·Ka', domain: 'www.auto-ka.com', node: 'M4M64b', pm2: 'auto-ka-web', dir: '~/auto-ka', deployed: '2026-08' }, | |
| 17 | + { app: 'fabri-ka', label: 'Fabri·Ka', domain: 'www.fabri-ka.com', node: 'M4M64a', pm2: 'fabri-ka-web', dir: '~/fabri-ka', deployed: '2026-08' }, | |
| 18 | + { app: 'food-ka', label: 'Food·Ka', domain: 'www.food-ka.com', node: 'M4M64b', pm2: 'food-ka-web', dir: '~/apps/food-ka', deployed: '2026-08' }, | |
| 19 | + { app: 'resto-ka', label: 'Resto·Ka', domain: 'www.resto-ka.com', node: 'M3U96b', pm2: 'resto-ka', dir: '~/apps/resto-ka', deployed: '2026-08' }, | |
| 20 | + { app: 'sorti-ka', label: 'Sorti·Ka', domain: 'www.sorti-ka.com', node: 'M3U96a', pm2: 'sorti-ka-web', dir: '~/apps/sorti-ka', deployed: '2026-08' }, | |
| 21 | + { app: 'crea-ka', label: 'Créa·Ka', domain: 'www.crea-ka.com', node: 'M3U96b', pm2: 'crea-ka-web', dir: '~/apps/crea-ka', deployed: '2026-08' }, | |
| 22 | + { app: 'job-ka', label: 'Job·Ka', domain: 'www.job-ka.com', node: 'M3U96a', pm2: 'job-ka-web', dir: '~/apps/job-ka', deployed: '2026-08' }, | |
| 23 | + { app: 'trouve-ka', label: 'Trouve·Ka', domain: 'www.trouve-ka.com', node: 'M2M32', pm2: 'tk-web', dir: '~/trouve-ka', deployed: '2026-08' }, | |
| 24 | + { app: 'groupe-ka', label: 'Groupe·Ka', domain: 'www.groupe-ka.com', node: 'M3U96b', pm2: 'groupe-ka', dir: '~/apps/groupe-ka', deployed: '2026-08' }, | |
| 25 | +]; | |
| 26 | + | |
| 27 | +const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32']; | |
| 28 | +const SELF_NODE = 'M3U96a'; | |
| 29 | +const CHECK_MS = 45_000; | |
| 30 | +const SWEEP_MS = 5 * 60_000; | |
| 31 | +const SLOW_MS = 4000; // seuil "lent" (▲) | |
| 32 | +const FAILS_FOR_INCIDENT = 2; // échecs consécutifs avant incident | |
| 33 | + | |
| 34 | +let db, emit; | |
| 35 | +const consecFails = {}; | |
| 36 | + | |
| 37 | +export function startMonitor({ dataDir, onEvent }) { | |
| 38 | + emit = onEvent || (() => {}); | |
| 39 | + db = new DatabaseSync(path.join(dataDir, 'eco.sqlite')); | |
| 40 | + db.exec(` | |
| 41 | + CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT); | |
| 42 | + CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts); | |
| 43 | + CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT); | |
| 44 | + CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER); | |
| 45 | + CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER); | |
| 46 | + CREATE INDEX IF NOT EXISTS idx_nodestats ON nodestats(node, ts); | |
| 47 | + CREATE TABLE IF NOT EXISTS appstats(app TEXT, ts INTEGER, cpu REAL, mem INTEGER, status TEXT, restarts INTEGER, last_commit INTEGER); | |
| 48 | + CREATE INDEX IF NOT EXISTS idx_appstats ON appstats(app, ts); | |
| 49 | + `); | |
| 50 | + checkAll(); | |
| 51 | + sweepNodes(); | |
| 52 | + setInterval(checkAll, CHECK_MS); | |
| 53 | + setInterval(sweepNodes, SWEEP_MS); | |
| 54 | + setInterval(cleanup, 6 * 3600_000); | |
| 55 | +} | |
| 56 | + | |
| 57 | +// ---------- health checks ---------- | |
| 58 | +function checkOne(site) { | |
| 59 | + return new Promise((resolve) => { | |
| 60 | + const t0 = Date.now(); | |
| 61 | + const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => { | |
| 62 | + const ms = Date.now() - t0; | |
| 63 | + const cert = res.socket.getPeerCertificate?.(); | |
| 64 | + if (cert && cert.valid_to) { | |
| 65 | + db.prepare('INSERT INTO cert(site,expires,issuer,checked) VALUES(?,?,?,?) ON CONFLICT(site) DO UPDATE SET expires=excluded.expires,issuer=excluded.issuer,checked=excluded.checked') | |
| 66 | + .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || '', Date.now()); | |
| 67 | + } | |
| 68 | + res.resume(); | |
| 69 | + resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null }); | |
| 70 | + }); | |
| 71 | + req.on('timeout', () => { req.destroy(new Error('timeout')); }); | |
| 72 | + req.on('error', (e) => resolve({ site: site.app, ts: Date.now(), ms: Date.now() - t0, code: 0, ok: 0, err: e.message.slice(0, 120) })); | |
| 73 | + }); | |
| 74 | +} | |
| 75 | + | |
| 76 | +async function checkAll() { | |
| 77 | + const results = await Promise.all(SITES.map(checkOne)); | |
| 78 | + const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err) VALUES(?,?,?,?,?,?)'); | |
| 79 | + for (const r of results) { | |
| 80 | + ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err); | |
| 81 | + handleIncident(r); | |
| 82 | + } | |
| 83 | + emit({ type: 'eco', kind: 'checks', items: results }); | |
| 84 | +} | |
| 85 | + | |
| 86 | +function handleIncident(r) { | |
| 87 | + const open = db.prepare('SELECT id FROM incidents WHERE site=? AND ended IS NULL').get(r.site); | |
| 88 | + if (!r.ok) { | |
| 89 | + consecFails[r.site] = (consecFails[r.site] || 0) + 1; | |
| 90 | + if (consecFails[r.site] >= FAILS_FOR_INCIDENT && !open) { | |
| 91 | + db.prepare('INSERT INTO incidents(site,started,reason) VALUES(?,?,?)') | |
| 92 | + .run(r.site, r.ts, r.err || `HTTP ${r.code}`); | |
| 93 | + emit({ type: 'eco_alert', kind: 'down', site: r.site, reason: r.err || `HTTP ${r.code}`, ts: r.ts }); | |
| 94 | + } | |
| 95 | + } else { | |
| 96 | + consecFails[r.site] = 0; | |
| 97 | + if (open) { | |
| 98 | + db.prepare('UPDATE incidents SET ended=? WHERE id=?').run(r.ts, open.id); | |
| 99 | + emit({ type: 'eco_alert', kind: 'up', site: r.site, ts: r.ts }); | |
| 100 | + } | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ---------- | |
| 105 | +function sweepNode(node) { | |
| 106 | + return new Promise((resolve) => { | |
| 107 | + const gitCmds = SITES.filter(s => s.node === node) | |
| 108 | + .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; '); | |
| 109 | + const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH | |
| 110 | +echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)" | |
| 111 | +echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)" | |
| 112 | +echo "PAGESIZE $(sysctl -n hw.pagesize 2>/dev/null)" | |
| 113 | +vm_stat 2>/dev/null | egrep "active|wired|occupied" | tr -d '.' | |
| 114 | +echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')" | |
| 115 | +echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)" | |
| 116 | +${gitCmds}`; | |
| 117 | + const args = node === SELF_NODE | |
| 118 | + ? ['-c', script] | |
| 119 | + : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script]; | |
| 120 | + const bin = node === SELF_NODE ? '/bin/bash' : 'ssh'; | |
| 121 | + execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => { | |
| 122 | + if (err && !stdout) return resolve(null); | |
| 123 | + try { parseSweep(node, stdout); } catch {} | |
| 124 | + resolve(null); | |
| 125 | + }); | |
| 126 | + }); | |
| 127 | +} | |
| 128 | + | |
| 129 | +function parseSweep(node, out) { | |
| 130 | + const ts = Date.now(); | |
| 131 | + const load = out.match(/^LOAD \{?\s*([\d.]+)/m); | |
| 132 | + const memTotal = Number(out.match(/^MEMTOTAL (\d+)/m)?.[1] || 0); | |
| 133 | + const pageSize = Number(out.match(/^PAGESIZE (\d+)/m)?.[1] || 16384); | |
| 134 | + let pages = 0; | |
| 135 | + for (const m of out.matchAll(/Pages (?:active|wired down|occupied by compressor):\s+(\d+)/g)) pages += Number(m[1]); | |
| 136 | + const memUsed = pages * pageSize; | |
| 137 | + const disk = Number(out.match(/^DISK (\d+)/m)?.[1] || 0); | |
| 138 | + db.prepare('INSERT INTO nodestats(node,ts,load1,mem_used,mem_total,disk_pct) VALUES(?,?,?,?,?,?)') | |
| 139 | + .run(node, ts, load ? Number(load[1]) : 0, memUsed, memTotal, disk); | |
| 140 | + | |
| 141 | + const commits = {}; | |
| 142 | + for (const m of out.matchAll(/^GIT (\S+) (\d+)/gm)) commits[m[1]] = Number(m[2]) * 1000; | |
| 143 | + | |
| 144 | + const pm2raw = out.match(/^PM2JSON (\[.*\])\s*$/m); | |
| 145 | + const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)'); | |
| 146 | + if (pm2raw) { | |
| 147 | + let list = []; | |
| 148 | + try { list = JSON.parse(pm2raw[1]); } catch {} | |
| 149 | + for (const site of SITES.filter(s => s.node === node)) { | |
| 150 | + const p = list.find(x => x.name === site.pm2) || list.find(x => (x.name || '').startsWith(site.app)); | |
| 151 | + ins.run(site.app, ts, | |
| 152 | + p ? (p.monit?.cpu ?? 0) : 0, | |
| 153 | + p ? (p.monit?.memory ?? 0) : 0, | |
| 154 | + p ? (p.pm2_env?.status || 'inconnu') : 'introuvable', | |
| 155 | + p ? (p.pm2_env?.restart_time ?? 0) : 0, | |
| 156 | + commits[site.app] || 0); | |
| 157 | + } | |
| 158 | + } else { | |
| 159 | + for (const site of SITES.filter(s => s.node === node)) { | |
| 160 | + ins.run(site.app, ts, 0, 0, 'inconnu', 0, commits[site.app] || 0); | |
| 161 | + } | |
| 162 | + } | |
| 163 | +} | |
| 164 | + | |
| 165 | +async function sweepNodes() { | |
| 166 | + await Promise.all(NODES.map(sweepNode)); | |
| 167 | + emit({ type: 'eco', kind: 'sweep', ts: Date.now() }); | |
| 168 | +} | |
| 169 | + | |
| 170 | +function cleanup() { | |
| 171 | + const cut = Date.now() - 35 * 24 * 3600_000; | |
| 172 | + db.prepare('DELETE FROM checks WHERE ts < ?').run(cut); | |
| 173 | + db.prepare('DELETE FROM nodestats WHERE ts < ?').run(cut); | |
| 174 | + db.prepare('DELETE FROM appstats WHERE ts < ?').run(cut); | |
| 175 | +} | |
| 176 | + | |
| 177 | +// ---------- requêtes ---------- | |
| 178 | +function uptime(site, sinceMs) { | |
| 179 | + const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs); | |
| 180 | + return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null; | |
| 181 | +} | |
| 182 | +function p95(site, sinceMs) { | |
| 183 | + const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs); | |
| 184 | + if (!rows.length) return null; | |
| 185 | + return rows[Math.min(rows.length - 1, Math.floor(rows.length * 0.95))].ms; | |
| 186 | +} | |
| 187 | + | |
| 188 | +export function ecoSummary() { | |
| 189 | + const out = []; | |
| 190 | + for (const s of SITES) { | |
| 191 | + const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app); | |
| 192 | + const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse(); | |
| 193 | + const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000); | |
| 194 | + const cert = db.prepare('SELECT expires FROM cert WHERE site=?').get(s.app); | |
| 195 | + const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app); | |
| 196 | + const stat = db.prepare('SELECT cpu,mem,status,restarts,last_commit,ts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app); | |
| 197 | + const nodeStat = db.prepare('SELECT load1,mem_used,mem_total,disk_pct,ts FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(s.node); | |
| 198 | + out.push({ | |
| 199 | + app: s.app, label: s.label, domain: s.domain, node: s.node, | |
| 200 | + last, spark, | |
| 201 | + uptime24: uptime(s.app, 24 * 3600_000), | |
| 202 | + uptime7: uptime(s.app, 7 * 24 * 3600_000), | |
| 203 | + uptime30: uptime(s.app, 30 * 24 * 3600_000), | |
| 204 | + avg24: avg?.a ? Math.round(avg.a) : null, | |
| 205 | + p95_24: p95(s.app, 24 * 3600_000), | |
| 206 | + certExpires: cert?.expires || null, | |
| 207 | + incident: inc || null, | |
| 208 | + proc: stat || null, | |
| 209 | + nodeStat: nodeStat || null, | |
| 210 | + }); | |
| 211 | + } | |
| 212 | + return { sites: out, ts: Date.now() }; | |
| 213 | +} | |
| 214 | + | |
| 215 | +export function ecoSite(app) { | |
| 216 | + const s = SITES.find(x => x.app === app); | |
| 217 | + if (!s) return null; | |
| 218 | + const since = Date.now() - 24 * 3600_000; | |
| 219 | + // ~200 seaux sur 24 h pour le graphique | |
| 220 | + const bucket = Math.ceil((24 * 3600_000) / 200); | |
| 221 | + const series = db.prepare(` | |
| 222 | + SELECT (ts/${bucket})*${bucket} AS t, AVG(ms) ms, MIN(ok) ok, COUNT(*) n | |
| 223 | + FROM checks WHERE site=? AND ts>? GROUP BY t ORDER BY t`).all(app, since); | |
| 224 | + const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30') | |
| 225 | + .all(app, Date.now() - 30 * 24 * 3600_000); | |
| 226 | + const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app); | |
| 227 | + // uptime quotidien 14 jours pour la barre | |
| 228 | + const days = []; | |
| 229 | + for (let i = 13; i >= 0; i--) { | |
| 230 | + const d0 = new Date(); d0.setHours(0, 0, 0, 0); | |
| 231 | + const start = d0.getTime() - i * 24 * 3600_000; | |
| 232 | + const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000); | |
| 233 | + days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null }); | |
| 234 | + } | |
| 235 | + return { app, label: s.label, domain: s.domain, node: s.node, dir: s.dir, series, incidents, errors, days }; | |
| 236 | +} | |
| 237 | + | |
| 238 | +export function ecoIncidents() { | |
| 239 | + return db.prepare(`SELECT site,started,ended,reason FROM incidents ORDER BY started DESC LIMIT 50`).all(); | |
| 240 | +} | |
modified
server/server.js
+180 −81
@@ -1,4 +1,6 @@ | ||
| 1 | −// admin-ka — backend : pont HTTP/WebSocket ⇄ Claude Code (stream-json) sur M3U96a | |
| 1 | +// admin-ka v2 — backend : pont HTTP/WebSocket ⇄ Claude Code CLI (stream-json) | |
| 2 | +// Sessions détachées (survivent à la déconnexion du client), permissions | |
| 3 | +// relayées en cartes, monitoring Écosystème temps réel. | |
| 2 | 4 | import http from 'node:http'; |
| 3 | 5 | import crypto from 'node:crypto'; |
| 4 | 6 | import fs from 'node:fs'; |
@@ -7,6 +9,7 @@ import os from 'node:os'; | ||
| 7 | 9 | import { spawn } from 'node:child_process'; |
| 8 | 10 | import { fileURLToPath } from 'node:url'; |
| 9 | 11 | import { WebSocketServer } from 'ws'; |
| 12 | +import { startMonitor, ecoSummary, ecoSite, ecoIncidents } from './monitor.js'; | |
| 10 | 13 | |
| 11 | 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 12 | 15 | const APP_DIR = path.join(__dirname, '..'); |
@@ -20,7 +23,7 @@ fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true }); | ||
| 20 | 23 | // ---------- config ---------- |
| 21 | 24 | const CONFIG_PATH = path.join(DATA_DIR, 'config.json'); |
| 22 | 25 | if (!fs.existsSync(CONFIG_PATH)) { |
| 23 | − console.error('config.json manquant — exécuter: npm run set-password -- <motdepasse>'); | |
| 26 | + console.error('config.json manquant — exécuter: node server/set-password.js <motdepasse>'); | |
| 24 | 27 | process.exit(1); |
| 25 | 28 | } |
| 26 | 29 | const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); |
@@ -28,7 +31,6 @@ const PORT = config.port || 3300; | ||
| 28 | 31 | const CLAUDE_BIN = config.claudeBin || '/opt/homebrew/bin/claude'; |
| 29 | 32 | const NODE_BIN = config.nodeBin || '/opt/homebrew/bin/node'; |
| 30 | 33 | |
| 31 | −// clé API Anthropic pour claude headless (depuis ~/.claude/.env) | |
| 32 | 34 | function loadAnthropicKey() { |
| 33 | 35 | try { |
| 34 | 36 | const envFile = fs.readFileSync(path.join(HOME, '.claude', '.env'), 'utf8'); |
@@ -50,8 +52,8 @@ const SESSIONS_PATH = path.join(DATA_DIR, 'sessions.json'); | ||
| 50 | 52 | let sessions = {}; |
| 51 | 53 | try { sessions = JSON.parse(fs.readFileSync(SESSIONS_PATH, 'utf8')); } catch {} |
| 52 | 54 | function saveSessions() { fs.writeFileSync(SESSIONS_PATH, JSON.stringify(sessions)); } |
| 53 | −const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000; // 7 jours d'inactivité | |
| 54 | −const SESSION_MAX_MS = 30 * 24 * 3600 * 1000; // 30 jours absolu | |
| 55 | +const SESSION_IDLE_MS = 7 * 24 * 3600 * 1000; | |
| 56 | +const SESSION_MAX_MS = 30 * 24 * 3600 * 1000; | |
| 55 | 57 | |
| 56 | 58 | function verifyPassword(pw) { |
| 57 | 59 | const hash = crypto.scryptSync(pw, Buffer.from(config.salt, 'hex'), 64).toString('hex'); |
@@ -77,7 +79,6 @@ function getSession(req) { | ||
| 77 | 79 | return m[1]; |
| 78 | 80 | } |
| 79 | 81 | |
| 80 | −// rate limit login : 8 échecs / 15 min / IP | |
| 81 | 82 | const loginFails = new Map(); |
| 82 | 83 | function clientIp(req) { |
| 83 | 84 | const xf = req.headers['x-forwarded-for']; |
@@ -98,8 +99,25 @@ function loginFailed(ip) { | ||
| 98 | 99 | const CHATS_PATH = path.join(DATA_DIR, 'chats.json'); |
| 99 | 100 | let chats = []; |
| 100 | 101 | try { chats = JSON.parse(fs.readFileSync(CHATS_PATH, 'utf8')); } catch {} |
| 102 | +// migration v1 → v2 | |
| 103 | +for (const c of chats) { | |
| 104 | + if (!c.model) c.model = 'fable'; | |
| 105 | + if (!c.permMode) c.permMode = c.mode === 'auto' ? 'bypass' : 'default'; | |
| 106 | + if (!c.state) c.state = 'idle'; | |
| 107 | + if (c.totalCost === undefined) c.totalCost = 0; | |
| 108 | + if (!c.alwaysAllow) c.alwaysAllow = []; | |
| 109 | +} | |
| 101 | 110 | function saveChats() { fs.writeFileSync(CHATS_PATH, JSON.stringify(chats, null, 1)); } |
| 102 | 111 | function getChat(id) { return chats.find(c => c.id === id); } |
| 112 | +function publicChat(c) { | |
| 113 | + return { | |
| 114 | + id: c.id, title: c.title, projectId: c.projectId, projectName: c.projectName, | |
| 115 | + model: c.model, permMode: c.permMode, state: c.state, | |
| 116 | + claudeSessionId: c.claudeSessionId, lastModel: c.lastModel || null, | |
| 117 | + totalCost: c.totalCost || 0, turns: c.turns || 0, contextTokens: c.contextTokens || 0, | |
| 118 | + created: c.created, updated: c.updated, | |
| 119 | + }; | |
| 120 | +} | |
| 103 | 121 | function transcriptPath(id) { return path.join(TRANSCRIPTS_DIR, id.replace(/[^a-zA-Z0-9_-]/g, '') + '.jsonl'); } |
| 104 | 122 | function appendTranscript(chatId, event) { |
| 105 | 123 | fs.appendFile(transcriptPath(chatId), JSON.stringify(event) + '\n', () => {}); |
@@ -112,15 +130,8 @@ function readTranscript(chatId) { | ||
| 112 | 130 | } catch { return []; } |
| 113 | 131 | } |
| 114 | 132 | |
| 115 | −// ---------- projets ---------- | |
| 116 | −// Doctrine remote-first Groupe Ka : chaque app KA se modifie DIRECTEMENT sur son | |
| 117 | −// nœud de déploiement (repo de prod = source de vérité). Le backend lance donc | |
| 118 | −// claude via SSH sur le bon nœud pour ces projets. | |
| 133 | +// ---------- projets (12 apps Groupe KA) ---------- | |
| 119 | 134 | const SELF_NODE = 'M3U96a'; |
| 120 | −const SELF_LAN_HOST = config.selfLanHost || 'MacStudio-de-simon-pierre-2.local'; | |
| 121 | −// Uniquement les 12 apps du Groupe KA (demande utilisateur 2026-08-19). | |
| 122 | −// node:null = app déployée sur M3U96a (exécution locale) ; sinon claude est | |
| 123 | −// lancé via SSH sur le nœud de déploiement, dans le repo de prod. | |
| 124 | 135 | const PROJECTS = [ |
| 125 | 136 | { id: 'lou-ka@M3U96b', name: 'Lou·Ka', node: 'M3U96b', dir: '~/apps/lou-ka' }, |
| 126 | 137 | { id: 'immo-ka@M4M64a', name: 'Immo·Ka', node: 'M4M64a', dir: '~/apps/immo-ka' }, |
@@ -136,16 +147,25 @@ const PROJECTS = [ | ||
| 136 | 147 | { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' }, |
| 137 | 148 | ]; |
| 138 | 149 | function listProjects() { |
| 139 | − return PROJECTS.map(p => ({ id: p.id, name: p.name + ' · ' + (p.node || SELF_NODE), node: p.node, dir: p.dir })); | |
| 140 | −} | |
| 141 | −function findProject(id) { | |
| 142 | − return listProjects().find(p => p.id === id) || null; | |
| 150 | + return PROJECTS.map(p => ({ id: p.id, name: p.name, node: p.node || SELF_NODE, dir: p.dir })); | |
| 143 | 151 | } |
| 152 | +function findProject(id) { return PROJECTS.find(p => p.id === id) || null; } | |
| 153 | + | |
| 154 | +// ---------- exécution Claude Code ---------- | |
| 155 | +const MODELS = ['fable', 'opus', 'sonnet', 'haiku']; | |
| 156 | +// mappage vers les IDs réels (les alias CLI des nœuds retombent sur d'anciennes versions) | |
| 157 | +const CLI_MODEL = { fable: 'claude-fable-5', opus: 'claude-opus-5', sonnet: 'claude-sonnet-5', haiku: 'claude-haiku-4-5-20251001' }; | |
| 158 | +const DEFAULT_MODEL = 'fable'; | |
| 159 | +const PERM_MODES = ['default', 'acceptEdits', 'plan', 'bypass']; | |
| 160 | +const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task'; | |
| 161 | + | |
| 162 | +const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; | |
| 163 | + | |
| 164 | +const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; | |
| 144 | 165 | |
| 145 | −// ---------- processus Claude en cours ---------- | |
| 146 | −// chatId -> { proc, buffer:'', permToken, killTimer } | |
| 166 | +// chatId -> { proc, permToken, stderr, remote } | |
| 147 | 167 | const running = new Map(); |
| 148 | −// requestId -> { chatId, resolve, tool, input, timer } | |
| 168 | +// requestId -> { chatId, resolve, timer, input, toolKey } | |
| 149 | 169 | const pendingPerms = new Map(); |
| 150 | 170 | |
| 151 | 171 | function broadcast(chatId, msg) { |
@@ -154,35 +174,51 @@ function broadcast(chatId, msg) { | ||
| 154 | 174 | if (ws.readyState === 1 && ws.chatId === chatId) ws.send(data); |
| 155 | 175 | } |
| 156 | 176 | } |
| 157 | − | |
| 177 | +function broadcastAll(msg) { | |
| 178 | + const data = JSON.stringify(msg); | |
| 179 | + for (const ws of wss.clients) if (ws.readyState === 1) ws.send(data); | |
| 180 | +} | |
| 181 | +function broadcastEco(msg) { | |
| 182 | + const data = JSON.stringify(msg); | |
| 183 | + for (const ws of wss.clients) if (ws.readyState === 1 && ws.eco) ws.send(data); | |
| 184 | +} | |
| 158 | 185 | function emitAndLog(chatId, msg) { |
| 159 | 186 | appendTranscript(chatId, msg); |
| 160 | 187 | broadcast(chatId, msg); |
| 161 | 188 | } |
| 189 | +function setChatState(chat, state) { | |
| 190 | + chat.state = state; | |
| 191 | + chat.updated = Date.now(); | |
| 192 | + saveChats(); | |
| 193 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | |
| 194 | +} | |
| 162 | 195 | |
| 163 | −const ALLOWED_READONLY = 'Read,Glob,Grep,LS,WebFetch,WebSearch,TodoWrite,NotebookRead,Task'; | |
| 164 | − | |
| 165 | −// Doctrine Groupe KA imposée à chaque session lancée par la console | |
| 166 | −const SYSTEM_APPEND = `RÈGLE OBLIGATOIRE (console administration-ka) : si tu as modifié des fichiers du projet pendant ce tour, tu DOIS avant de conclure : (1) rebuild si le projet a une étape de build ; (2) redéployer — pm2 restart <app> (ou le mécanisme en place) — et VÉRIFIER que l'app répond (healthcheck/port/site) ; (3) committer et pousser : git add + git commit + git push origin main — le remote origin est spbgit (git perso git.spboucher.ai), PAS GitHub. Ne termine jamais en laissant des modifications non commitées ou non redéployées. Si aucun fichier n'a été modifié, ignore cette règle.`; | |
| 167 | − | |
| 168 | −// quoting shell sûr pour la commande ssh distante | |
| 169 | −const shq = (s) => `'` + String(s).replace(/'/g, `'\\''`) + `'`; | |
| 196 | +function permKey(tool, input) { | |
| 197 | + if (tool === 'Bash') { | |
| 198 | + const first = String(input?.command || '').trim().split(/\s+/)[0] || '?'; | |
| 199 | + return 'Bash:' + first; | |
| 200 | + } | |
| 201 | + return tool; | |
| 202 | +} | |
| 170 | 203 | |
| 171 | −function startClaude(chat, prompt, mode) { | |
| 204 | +function startClaude(chat, prompt) { | |
| 172 | 205 | if (running.has(chat.id)) throw new Error('Une exécution est déjà en cours pour cette conversation.'); |
| 173 | 206 | const permToken = crypto.randomBytes(16).toString('hex'); |
| 207 | + const model = MODELS.includes(chat.model) ? chat.model : DEFAULT_MODEL; | |
| 208 | + const permMode = PERM_MODES.includes(chat.permMode) ? chat.permMode : 'default'; | |
| 174 | 209 | let proc; |
| 175 | 210 | |
| 176 | 211 | const baseArgs = ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', |
| 177 | − '--append-system-prompt', SYSTEM_APPEND]; | |
| 212 | + '--model', CLI_MODEL[model], '--append-system-prompt', SYSTEM_APPEND]; | |
| 178 | 213 | if (chat.claudeSessionId) baseArgs.push('--resume', chat.claudeSessionId); |
| 179 | 214 | |
| 180 | 215 | if (!chat.node) { |
| 181 | − // --- exécution locale (projet sur M3U96a) --- | |
| 216 | + // exécution locale (app déployée sur M3U96a) | |
| 182 | 217 | const args = [...baseArgs]; |
| 183 | − if (mode === 'auto') { | |
| 218 | + if (permMode === 'bypass') { | |
| 184 | 219 | args.push('--dangerously-skip-permissions'); |
| 185 | 220 | } else { |
| 221 | + args.push('--permission-mode', permMode); | |
| 186 | 222 | const mcpCfg = { |
| 187 | 223 | mcpServers: { |
| 188 | 224 | adminka: { |
@@ -204,21 +240,18 @@ function startClaude(chat, prompt, mode) { | ||
| 204 | 240 | proc = spawn(CLAUDE_BIN, args, { |
| 205 | 241 | cwd: chat.dir, |
| 206 | 242 | env: { |
| 207 | − ...process.env, | |
| 208 | − ANTHROPIC_API_KEY, | |
| 209 | − PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', | |
| 210 | − HOME, | |
| 243 | + ...process.env, ANTHROPIC_API_KEY, | |
| 244 | + PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', HOME, | |
| 211 | 245 | }, |
| 212 | 246 | stdio: ['pipe', 'pipe', 'pipe'], |
| 247 | + detached: false, | |
| 213 | 248 | }); |
| 214 | 249 | } else { |
| 215 | − // --- exécution distante : claude tourne SUR le nœud de déploiement de l'app, | |
| 216 | − // --- dans le repo de prod (source de vérité). Le prompt passe par stdin. | |
| 217 | − // --- Les demandes de permission reviennent par un tunnel SSH inverse | |
| 218 | − // --- (l'IPv4 LAN inter-nœuds n'est pas routée partout). | |
| 219 | − const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15']; | |
| 250 | + // exécution distante : claude tourne SUR le nœud de déploiement de l'app, | |
| 251 | + // dans le repo de prod. Permissions via tunnel SSH inverse (IPv4 LAN non routée). | |
| 252 | + const sshArgs = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=30', '-o', 'ServerAliveCountMax=6']; | |
| 220 | 253 | let cmd = 'exec /opt/homebrew/bin/claude ' + baseArgs.map(shq).join(' '); |
| 221 | − if (mode === 'auto') { | |
| 254 | + if (permMode === 'bypass') { | |
| 222 | 255 | cmd += ' --dangerously-skip-permissions'; |
| 223 | 256 | } else { |
| 224 | 257 | const revPort = 21000 + Math.floor(Math.random() * 3000); |
@@ -236,7 +269,7 @@ function startClaude(chat, prompt, mode) { | ||
| 236 | 269 | }, |
| 237 | 270 | }; |
| 238 | 271 | const cfgFile = `/tmp/adminka-mcp-${permToken}.json`; |
| 239 | − cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`; | |
| 272 | + cmd = `printf '%s' '${JSON.stringify(mcpCfg)}' > ${cfgFile} && ${cmd} --permission-mode ${permMode} --mcp-config ${cfgFile} --permission-prompt-tool mcp__adminka__approve --allowedTools '${ALLOWED_READONLY}'`; | |
| 240 | 273 | } |
| 241 | 274 | const remoteCmd = [ |
| 242 | 275 | `cd ${chat.dir}`, |
@@ -255,8 +288,9 @@ function startClaude(chat, prompt, mode) { | ||
| 255 | 288 | |
| 256 | 289 | const state = { proc, permToken, stderr: '', remote: chat.node || null }; |
| 257 | 290 | running.set(chat.id, state); |
| 258 | − audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, mode, prompt: prompt.slice(0, 2000) }); | |
| 291 | + audit({ kind: 'prompt', chatId: chat.id, project: chat.projectId, node: chat.node || SELF_NODE, dir: chat.dir, model, permMode, prompt: prompt.slice(0, 2000) }); | |
| 259 | 292 | emitAndLog(chat.id, { type: 'status', state: 'running' }); |
| 293 | + setChatState(chat, 'running'); | |
| 260 | 294 | |
| 261 | 295 | let buf = ''; |
| 262 | 296 | proc.stdout.on('data', (d) => { |
@@ -274,27 +308,30 @@ function startClaude(chat, prompt, mode) { | ||
| 274 | 308 | proc.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString()).slice(-8000); }); |
| 275 | 309 | proc.on('close', (code) => { |
| 276 | 310 | running.delete(chat.id); |
| 277 | − // refuser les permissions encore en attente pour ce chat | |
| 278 | 311 | for (const [rid, p] of pendingPerms) { |
| 279 | − if (p.chatId === chat.id) { p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); } | |
| 312 | + if (p.chatId === chat.id) { clearTimeout(p.timer); p.resolve({ behavior: 'deny', message: 'Exécution terminée' }); pendingPerms.delete(rid); } | |
| 280 | 313 | } |
| 281 | 314 | if (code !== 0 && code !== null) { |
| 282 | 315 | emitAndLog(chat.id, { type: 'error', message: `claude a quitté (code ${code})\n${state.stderr.slice(-1500)}` }); |
| 283 | 316 | } |
| 284 | 317 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); |
| 285 | − chat.updated = Date.now(); saveChats(); | |
| 318 | + setChatState(chat, 'idle'); | |
| 319 | + broadcastAll({ type: 'notify', kind: 'done', chatId: chat.id, title: chat.title, project: chat.projectName }); | |
| 286 | 320 | }); |
| 287 | 321 | proc.on('error', (err) => { |
| 288 | 322 | running.delete(chat.id); |
| 289 | 323 | emitAndLog(chat.id, { type: 'error', message: 'Impossible de lancer claude: ' + err.message }); |
| 290 | 324 | emitAndLog(chat.id, { type: 'status', state: 'idle' }); |
| 325 | + setChatState(chat, 'idle'); | |
| 291 | 326 | }); |
| 292 | 327 | } |
| 293 | 328 | |
| 294 | 329 | function handleClaudeEvent(chat, ev) { |
| 295 | − if (ev.type === 'system' && ev.subtype === 'init' && ev.session_id) { | |
| 296 | − chat.claudeSessionId = ev.session_id; | |
| 330 | + if (ev.type === 'system' && ev.subtype === 'init') { | |
| 331 | + if (ev.session_id) chat.claudeSessionId = ev.session_id; | |
| 332 | + if (ev.model) chat.lastModel = ev.model; | |
| 297 | 333 | saveChats(); |
| 334 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | |
| 298 | 335 | } |
| 299 | 336 | if (ev.type === 'assistant' && ev.message && Array.isArray(ev.message.content)) { |
| 300 | 337 | for (const block of ev.message.content) { |
@@ -304,10 +341,14 @@ function handleClaudeEvent(chat, ev) { | ||
| 304 | 341 | } |
| 305 | 342 | } |
| 306 | 343 | if (ev.type === 'result') { |
| 344 | + chat.totalCost = (chat.totalCost || 0) + (ev.total_cost_usd || 0); | |
| 345 | + chat.turns = (chat.turns || 0) + (ev.num_turns || 0); | |
| 346 | + const u = ev.usage || {}; | |
| 347 | + chat.contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); | |
| 348 | + saveChats(); | |
| 307 | 349 | audit({ kind: 'result', chatId: chat.id, subtype: ev.subtype, cost: ev.total_cost_usd, duration_ms: ev.duration_ms, turns: ev.num_turns }); |
| 308 | − chat.updated = Date.now(); saveChats(); | |
| 350 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | |
| 309 | 351 | } |
| 310 | − // ne jamais persister les stream_event (volumineux, reconstruits depuis les messages complets) | |
| 311 | 352 | if (ev.type === 'stream_event') { |
| 312 | 353 | broadcast(chat.id, { type: 'claude', event: ev }); |
| 313 | 354 | } else { |
@@ -352,24 +393,35 @@ const server = http.createServer(async (req, res) => { | ||
| 352 | 393 | |
| 353 | 394 | // --- endpoint interne de permission (long-poll par perm-mcp.js) --- |
| 354 | 395 | if (p === '/internal/perm' && req.method === 'POST') { |
| 355 | − // accessible depuis le LAN (perm-mcp des nœuds distants) — authentifié par le | |
| 356 | − // token de run (32 hex aléatoires, valable seulement pendant l'exécution) | |
| 357 | 396 | let body; |
| 358 | 397 | try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } |
| 359 | 398 | let chatId = null; |
| 360 | 399 | for (const [cid, st] of running) if (st.permToken === body.token) chatId = cid; |
| 361 | 400 | if (!chatId) return json(res, 403, { error: 'bad token' }); |
| 401 | + const chat = getChat(chatId); | |
| 402 | + const toolKey = permKey(body.tool_name, body.input); | |
| 403 | + | |
| 404 | + // « Toujours autoriser » déjà accordé pour cet outil dans cette conversation | |
| 405 | + if (chat && (chat.alwaysAllow || []).includes(toolKey)) { | |
| 406 | + audit({ kind: 'permission_auto', chatId, tool: body.tool_name, key: toolKey }); | |
| 407 | + emitAndLog(chatId, { type: 'permission_auto', tool: body.tool_name, key: toolKey, input: body.input }); | |
| 408 | + return json(res, 200, { behavior: 'allow', updatedInput: body.input || {} }); | |
| 409 | + } | |
| 410 | + | |
| 362 | 411 | const requestId = crypto.randomBytes(8).toString('hex'); |
| 363 | 412 | const p2 = new Promise((resolve) => { |
| 364 | 413 | const timer = setTimeout(() => { |
| 365 | 414 | pendingPerms.delete(requestId); |
| 366 | 415 | emitAndLog(chatId, { type: 'permission_resolved', requestId, allow: false, reason: 'timeout' }); |
| 367 | − resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 10 min)" }); | |
| 368 | − }, 10 * 60 * 1000); | |
| 369 | − pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input }); | |
| 416 | + if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); | |
| 417 | + resolve({ behavior: 'deny', message: "Pas de réponse de l'utilisateur (timeout 30 min)" }); | |
| 418 | + }, 30 * 60 * 1000); | |
| 419 | + pendingPerms.set(requestId, { chatId, resolve, timer, input: body.input, toolKey }); | |
| 370 | 420 | }); |
| 371 | 421 | audit({ kind: 'permission_request', chatId, tool: body.tool_name, input: JSON.stringify(body.input || {}).slice(0, 1000) }); |
| 372 | 422 | emitAndLog(chatId, { type: 'permission_request', requestId, tool: body.tool_name, input: body.input }); |
| 423 | + if (chat) setChatState(chat, 'waiting_perm'); | |
| 424 | + broadcastAll({ type: 'notify', kind: 'perm', chatId, title: chat?.title || '', tool: body.tool_name }); | |
| 373 | 425 | const decision = await p2; |
| 374 | 426 | return json(res, 200, decision); |
| 375 | 427 | } |
@@ -403,11 +455,10 @@ const server = http.createServer(async (req, res) => { | ||
| 403 | 455 | // --- API authentifiée --- |
| 404 | 456 | if (p.startsWith('/api/')) { |
| 405 | 457 | if (!getSession(req)) return json(res, 401, { error: 'auth' }); |
| 406 | − if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname() }); | |
| 458 | + if (p === '/api/me') return json(res, 200, { ok: true, host: os.hostname(), node: SELF_NODE, version: '2.0.0' }); | |
| 407 | 459 | if (p === '/api/projects') return json(res, 200, { projects: listProjects() }); |
| 408 | 460 | if (p === '/api/chats' && req.method === 'GET') { |
| 409 | − const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100) | |
| 410 | − .map(({ id, title, projectId, projectName, mode, updated, created }) => ({ id, title, projectId, projectName, mode, updated, created, running: running.has(id) })); | |
| 461 | + const list = [...chats].sort((a, b) => (b.updated || 0) - (a.updated || 0)).slice(0, 100).map(publicChat); | |
| 411 | 462 | return json(res, 200, { chats: list }); |
| 412 | 463 | } |
| 413 | 464 | if (p.startsWith('/api/chats/') && req.method === 'DELETE') { |
@@ -420,6 +471,12 @@ const server = http.createServer(async (req, res) => { | ||
| 420 | 471 | } |
| 421 | 472 | return json(res, 400, { error: 'introuvable ou en cours' }); |
| 422 | 473 | } |
| 474 | + if (p === '/api/eco/summary') return json(res, 200, ecoSummary()); | |
| 475 | + if (p === '/api/eco/incidents') return json(res, 200, { incidents: ecoIncidents() }); | |
| 476 | + if (p.startsWith('/api/eco/site/')) { | |
| 477 | + const d = ecoSite(p.split('/')[4]); | |
| 478 | + return d ? json(res, 200, d) : json(res, 404, { error: 'site inconnu' }); | |
| 479 | + } | |
| 423 | 480 | return json(res, 404, { error: 'not found' }); |
| 424 | 481 | } |
| 425 | 482 | |
@@ -449,6 +506,7 @@ server.on('upgrade', (req, socket, head) => { | ||
| 449 | 506 | |
| 450 | 507 | wss.on('connection', (ws) => { |
| 451 | 508 | ws.chatId = null; |
| 509 | + ws.eco = false; | |
| 452 | 510 | ws.isAlive = true; |
| 453 | 511 | ws.on('pong', () => { ws.isAlive = true; }); |
| 454 | 512 | ws.on('message', (raw) => { |
@@ -458,6 +516,7 @@ wss.on('connection', (ws) => { | ||
| 458 | 516 | ws.send(JSON.stringify({ type: 'error', message: e.message })); |
| 459 | 517 | } |
| 460 | 518 | }); |
| 519 | + // NB : la fermeture du socket ne touche JAMAIS aux processus claude en cours. | |
| 461 | 520 | }); |
| 462 | 521 | |
| 463 | 522 | setInterval(() => { |
@@ -472,9 +531,10 @@ function handleWsMessage(ws, msg) { | ||
| 472 | 531 | const chat = getChat(msg.chatId); |
| 473 | 532 | if (!chat) return ws.send(JSON.stringify({ type: 'error', message: 'Conversation introuvable' })); |
| 474 | 533 | ws.chatId = chat.id; |
| 534 | + // rejoue tout l'historique persisté puis raccroche le live | |
| 475 | 535 | ws.send(JSON.stringify({ |
| 476 | 536 | type: 'history', |
| 477 | − chat: { id: chat.id, title: chat.title, projectId: chat.projectId, projectName: chat.projectName, mode: chat.mode }, | |
| 537 | + chat: publicChat(chat), | |
| 478 | 538 | events: readTranscript(chat.id), |
| 479 | 539 | running: running.has(chat.id), |
| 480 | 540 | })); |
@@ -483,29 +543,49 @@ function handleWsMessage(ws, msg) { | ||
| 483 | 543 | if (msg.type === 'start') { |
| 484 | 544 | const prompt = String(msg.prompt || '').trim(); |
| 485 | 545 | if (!prompt) return; |
| 486 | − const mode = msg.mode === 'auto' ? 'auto' : 'safe'; | |
| 487 | 546 | let chat = msg.chatId ? getChat(msg.chatId) : null; |
| 488 | 547 | if (!chat) { |
| 489 | − const proj = findProject(msg.project) || listProjects()[0]; | |
| 548 | + const proj = findProject(msg.project) || PROJECTS[0]; | |
| 490 | 549 | chat = { |
| 491 | 550 | id: crypto.randomBytes(8).toString('hex'), |
| 492 | 551 | title: prompt.slice(0, 60), |
| 493 | − projectId: proj.id, | |
| 494 | − projectName: proj.name, | |
| 495 | − node: proj.node, | |
| 496 | − dir: proj.dir, | |
| 497 | − mode, | |
| 498 | − claudeSessionId: null, | |
| 499 | − created: Date.now(), | |
| 500 | − updated: Date.now(), | |
| 552 | + projectId: proj.id, projectName: proj.name, | |
| 553 | + node: proj.node, dir: proj.dir, | |
| 554 | + model: MODELS.includes(msg.model) ? msg.model : DEFAULT_MODEL, | |
| 555 | + permMode: PERM_MODES.includes(msg.permMode) ? msg.permMode : 'default', | |
| 556 | + claudeSessionId: null, state: 'idle', | |
| 557 | + totalCost: 0, turns: 0, contextTokens: 0, alwaysAllow: [], | |
| 558 | + created: Date.now(), updated: Date.now(), | |
| 501 | 559 | }; |
| 502 | 560 | chats.push(chat); saveChats(); |
| 503 | − ws.send(JSON.stringify({ type: 'chat_created', chat: { id: chat.id, title: chat.title, projectId: chat.projectId, projectName: chat.projectName, mode: chat.mode } })); | |
| 561 | + ws.send(JSON.stringify({ type: 'chat_created', chat: publicChat(chat) })); | |
| 562 | + } else { | |
| 563 | + if (MODELS.includes(msg.model)) chat.model = msg.model; | |
| 564 | + if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; | |
| 565 | + saveChats(); | |
| 504 | 566 | } |
| 505 | − chat.mode = mode; saveChats(); | |
| 506 | 567 | ws.chatId = chat.id; |
| 507 | 568 | emitAndLog(chat.id, { type: 'user_prompt', text: prompt, ts: Date.now() }); |
| 508 | − startClaude(chat, prompt, mode); | |
| 569 | + startClaude(chat, prompt); | |
| 570 | + return; | |
| 571 | + } | |
| 572 | + if (msg.type === 'set_opts') { | |
| 573 | + const chat = getChat(msg.chatId); | |
| 574 | + if (!chat) return; | |
| 575 | + if (MODELS.includes(msg.model)) chat.model = msg.model; | |
| 576 | + if (PERM_MODES.includes(msg.permMode)) chat.permMode = msg.permMode; | |
| 577 | + saveChats(); | |
| 578 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | |
| 579 | + return; | |
| 580 | + } | |
| 581 | + if (msg.type === 'clear') { | |
| 582 | + const chat = getChat(msg.chatId); | |
| 583 | + if (!chat || running.has(chat.id)) return; | |
| 584 | + chat.claudeSessionId = null; | |
| 585 | + chat.contextTokens = 0; | |
| 586 | + saveChats(); | |
| 587 | + emitAndLog(chat.id, { type: 'notice', text: 'Contexte effacé — la prochaine demande démarre une session Claude Code vierge.' }); | |
| 588 | + broadcastAll({ type: 'chat_meta', chat: publicChat(chat) }); | |
| 509 | 589 | return; |
| 510 | 590 | } |
| 511 | 591 | if (msg.type === 'perm') { |
@@ -513,22 +593,41 @@ function handleWsMessage(ws, msg) { | ||
| 513 | 593 | if (!pending) return; |
| 514 | 594 | clearTimeout(pending.timer); |
| 515 | 595 | pendingPerms.delete(msg.requestId); |
| 516 | − audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow }); | |
| 517 | − emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow }); | |
| 596 | + const chat = getChat(pending.chatId); | |
| 597 | + if (msg.allow && msg.always && chat) { | |
| 598 | + if (!chat.alwaysAllow.includes(pending.toolKey)) chat.alwaysAllow.push(pending.toolKey); | |
| 599 | + saveChats(); | |
| 600 | + } | |
| 601 | + audit({ kind: 'permission_response', chatId: pending.chatId, requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); | |
| 602 | + emitAndLog(pending.chatId, { type: 'permission_resolved', requestId: msg.requestId, allow: !!msg.allow, always: !!msg.always }); | |
| 603 | + if (chat && chat.state === 'waiting_perm') setChatState(chat, 'running'); | |
| 518 | 604 | pending.resolve(msg.allow |
| 519 | 605 | ? { behavior: 'allow', updatedInput: pending.input || {} } |
| 520 | 606 | : { behavior: 'deny', message: "Refusé par l'utilisateur depuis administration-ka.com" }); |
| 521 | 607 | return; |
| 522 | 608 | } |
| 523 | 609 | if (msg.type === 'interrupt') { |
| 524 | − if (ws.chatId) interrupt(ws.chatId); | |
| 610 | + if (msg.chatId) interrupt(msg.chatId); | |
| 611 | + else if (ws.chatId) interrupt(ws.chatId); | |
| 525 | 612 | return; |
| 526 | 613 | } |
| 614 | + if (msg.type === 'eco_sub') { ws.eco = true; return; } | |
| 615 | + if (msg.type === 'eco_unsub') { ws.eco = false; return; } | |
| 527 | 616 | } |
| 528 | 617 | |
| 529 | −// '::' (dual-stack v4+v6) : ngrok (localhost) + perm-mcp des nœuds distants via | |
| 530 | −// LAN (.local peut résoudre en IPv6 link-local). L'API est protégée par mot de | |
| 531 | −// passe ; /internal/perm par token de run. | |
| 618 | +// ---------- monitoring Écosystème ---------- | |
| 619 | +startMonitor({ | |
| 620 | + dataDir: DATA_DIR, | |
| 621 | + onEvent: (ev) => { | |
| 622 | + if (ev.type === 'eco_alert') broadcastAll(ev); // alertes → tout le monde | |
| 623 | + else broadcastEco(ev); // ticks → abonnés à l'onglet | |
| 624 | + }, | |
| 625 | +}); | |
| 626 | + | |
| 627 | +// au démarrage, les chats marqués "running" par un ancien processus sont orphelins | |
| 628 | +for (const c of chats) if (c.state !== 'idle') c.state = 'idle'; | |
| 629 | +saveChats(); | |
| 630 | + | |
| 532 | 631 | server.listen(PORT, '::', () => { |
| 533 | − console.log(`admin-ka backend sur port ${PORT} (dual-stack)`); | |
| 632 | + console.log(`admin-ka v2 sur port ${PORT} (dual-stack)`); | |
| 534 | 633 | }); |
| 535 | 634 | |