SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 20, 2026) parent c9e420b

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) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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