intermesh 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {intermesh-0.4.2 → intermesh-0.4.3}/PKG-INFO +1 -1
  2. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/__init__.py +1 -1
  3. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/cli.py +27 -3
  4. intermesh-0.4.3/intermesh/console/__init__.py +0 -0
  5. intermesh-0.4.3/intermesh/console/app.js +601 -0
  6. intermesh-0.4.3/intermesh/console/index.html +427 -0
  7. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/store.py +7 -1
  8. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/PKG-INFO +1 -1
  9. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/SOURCES.txt +4 -1
  10. {intermesh-0.4.2 → intermesh-0.4.3}/pyproject.toml +8 -1
  11. {intermesh-0.4.2 → intermesh-0.4.3}/README.md +0 -0
  12. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/adapters.py +0 -0
  13. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/admin.py +0 -0
  14. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/agent.py +0 -0
  15. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/apikeys.py +0 -0
  16. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/approval.py +0 -0
  17. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/audit.py +0 -0
  18. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/bridge.py +0 -0
  19. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/config.py +0 -0
  20. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/crypto.py +0 -0
  21. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/egress.py +0 -0
  22. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/escrow.py +0 -0
  23. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/guardrails.py +0 -0
  24. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/hardware.py +0 -0
  25. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/health.py +0 -0
  26. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/hub.py +0 -0
  27. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/identity.py +0 -0
  28. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/logger.py +0 -0
  29. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/message.py +0 -0
  30. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/metrics.py +0 -0
  31. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/peering.py +0 -0
  32. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/pipeline.py +0 -0
  33. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/policy.py +0 -0
  34. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/ratelimit.py +0 -0
  35. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/schema.py +0 -0
  36. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/secret.py +0 -0
  37. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/signing.py +0 -0
  38. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/snapshot.py +0 -0
  39. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/task.py +0 -0
  40. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/dependency_links.txt +0 -0
  41. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/entry_points.txt +0 -0
  42. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/requires.txt +0 -0
  43. {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/top_level.txt +0 -0
  44. {intermesh-0.4.2 → intermesh-0.4.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intermesh
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Universal coordination protocol for AI agents — E2E encrypted, cross-language, federated
5
5
  Author: InterMesh Protocol Authors
6
6
  License: Apache-2.0
@@ -53,4 +53,4 @@ try:
53
53
  except ImportError:
54
54
  pass
55
55
 
56
- __version__ = "0.4.2"
56
+ __version__ = "0.4.3"
@@ -307,17 +307,41 @@ async def run_snapshot(args):
307
307
 
308
308
 
309
309
  def run_dashboard(args):
310
+ """Sert la console d'exploitation, celle qui voyage avec le paquet.
311
+
312
+ Le chemin était codé en dur sur `~/nexus/dashboard`, l'arborescence du
313
+ développeur — et les fichiers n'étaient même pas dans le paquet. Chez
314
+ tout autre utilisateur la commande annonçait « actif » puis répondait
315
+ 404 sur tout. Elle sert désormais le dossier livré avec `intermesh`,
316
+ situé par le module lui-même.
317
+ """
310
318
  port = args.port
311
- dashboard_dir = os.path.expanduser("~/nexus/dashboard")
319
+ console_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "console")
320
+
321
+ if not os.path.isfile(os.path.join(console_dir, "index.html")):
322
+ print("\033[31m✗ Console introuvable dans le paquet installé "
323
+ f"({console_dir}).\033[0m")
324
+ print(" Réinstallez avec : pip install --force-reinstall intermesh")
325
+ return 1
326
+
312
327
  class Handler(http.server.SimpleHTTPRequestHandler):
313
328
  def __init__(self, *args_h, **kwargs_h):
314
- super().__init__(*args_h, directory=dashboard_dir, **kwargs_h)
329
+ super().__init__(*args_h, directory=console_dir, **kwargs_h)
315
330
  def log_message(self, format, *log_args): pass
316
331
 
317
- print(f"\033[32m✓ InterMesh Mission Control Dashboard actif sur http://localhost:{port}\033[0m")
332
+ print(f"\033[32m✓ Console d'exploitation sur http://localhost:{port}\033[0m")
333
+ # Dit ce que c'est, et ce que ce n'est pas : le premier utilisateur a
334
+ # cru voir « le mauvais dashboard » alors qu'il y en a deux, distincts
335
+ # et voulus.
336
+ print(" Interface locale, sans dépendance externe : elle se branche sur")
337
+ print(" le Hub que vous indiquez et n'a besoin d'aucun compte.")
338
+ print(" Le Control Plane hébergé (React, comptes et équipes) est un autre")
339
+ print(" produit, sur https://intermesh.site — il exige Supabase et ne")
340
+ print(" fonctionne pas hors ligne.\n")
318
341
  with socketserver.TCPServer(("", port), Handler) as httpd:
319
342
  try: httpd.serve_forever()
320
343
  except KeyboardInterrupt: pass
344
+ return 0
321
345
 
322
346
 
323
347
  def run_serve(args):
File without changes
@@ -0,0 +1,601 @@
1
+ /**
2
+ * InterMesh — Mission Control.
3
+ *
4
+ * Parle le protocole intermesh/v1 directement en WebSocket : la console est un
5
+ * agent comme un autre, simplement porteur d'une clé d'API admin.
6
+ *
7
+ * La clé n'est jamais écrite dans localStorage ni sessionStorage. Elle vit
8
+ * dans une variable, le temps de l'onglet. Un rechargement la redemande —
9
+ * c'est volontaire : un jeton d'administration qui survit dans le stockage
10
+ * du navigateur survit aussi à l'opérateur qui s'éloigne de son poste.
11
+ */
12
+ 'use strict';
13
+
14
+ const S = {
15
+ ws: null, key: null, url: null, token: null, name: null, org: null,
16
+ view: 'overview', pending: new Map(), reconnect: null,
17
+ logs: [], logsPaused: false, info: null,
18
+ // Séries temporelles échantillonnées côté client : le Hub renvoie un
19
+ // état instantané, pas un historique. Les courbes démarrent donc à
20
+ // l'ouverture de la console, ce qui est honnête et suffisant.
21
+ series: { agents: [], tasks: [], audit: [], keys: [] },
22
+ page: { agents: 0, tasks: 0, audit: 0 },
23
+ PER_PAGE: 14,
24
+ };
25
+
26
+ const $ = (id) => document.getElementById(id);
27
+ const uuid = () => (crypto.randomUUID ? crypto.randomUUID()
28
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
29
+ const r = Math.random() * 16 | 0;
30
+ return (c === 'x' ? r : (r & 3 | 8)).toString(16);
31
+ }));
32
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g,
33
+ c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
34
+
35
+ const hhmmss = (ts) => ts ? new Date(ts * 1000).toLocaleTimeString('fr-FR', { hour12: false }) : '—';
36
+ const ago = (ts) => {
37
+ if (!ts) return '—';
38
+ const d = Date.now() / 1000 - ts;
39
+ if (d < 60) return `${Math.max(0, Math.round(d))}s`;
40
+ if (d < 3600) return `${Math.round(d / 60)}min`;
41
+ if (d < 86400) return `${Math.round(d / 3600)}h`;
42
+ return `${Math.round(d / 86400)}j`;
43
+ };
44
+
45
+ function toast(msg, bad = false) {
46
+ const t = $('toast');
47
+ t.textContent = msg;
48
+ t.className = 'toast on' + (bad ? ' bad' : '');
49
+ clearTimeout(toast._t);
50
+ toast._t = setTimeout(() => { t.className = 'toast'; }, 4200);
51
+ }
52
+
53
+ /** Sparkline SVG, tracée à la main — aucune librairie de graphes. */
54
+ function sparkline(values, color, w = 74, h = 26) {
55
+ if (!values || values.length < 2) return `<svg width="${w}" height="${h}"></svg>`;
56
+ const min = Math.min(...values), max = Math.max(...values);
57
+ const span = (max - min) || 1;
58
+ const step = w / (values.length - 1);
59
+ const pts = values.map((v, i) => [i * step, h - 2 - ((v - min) / span) * (h - 4)]);
60
+ const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
61
+ const area = `${line} L${w},${h} L0,${h} Z`;
62
+ const gid = 'g' + Math.random().toString(36).slice(2, 8);
63
+ return `<svg class="spark" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
64
+ <defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
65
+ <stop offset="0%" stop-color="${color}" stop-opacity=".28"/>
66
+ <stop offset="100%" stop-color="${color}" stop-opacity="0"/>
67
+ </linearGradient></defs>
68
+ <path d="${area}" fill="url(#${gid})"/>
69
+ <path d="${line}" fill="none" stroke="${color}" stroke-width="1.4"
70
+ stroke-linejoin="round" stroke-linecap="round"/>
71
+ <circle cx="${pts[pts.length-1][0].toFixed(1)}" cy="${pts[pts.length-1][1].toFixed(1)}"
72
+ r="1.8" fill="${color}"/>
73
+ </svg>`;
74
+ }
75
+
76
+ const trend = (arr) => {
77
+ if (arr.length < 2) return '';
78
+ const d = arr[arr.length - 1] - arr[0];
79
+ if (d === 0) return '<span style="color:var(--dim)">stable</span>';
80
+ return d > 0 ? `<span class="up">▲ +${d}</span>` : `<span class="down">▼ ${d}</span>`;
81
+ };
82
+
83
+ // ----------------------------------------------------------------- réseau
84
+
85
+ function connect(url, key) {
86
+ return new Promise((resolve, reject) => {
87
+ let ws;
88
+ try { ws = new WebSocket(url); }
89
+ catch (e) { return reject(new Error('Adresse invalide : ' + e.message)); }
90
+
91
+ const guard = setTimeout(() => {
92
+ try { ws.close(); } catch (_) {}
93
+ reject(new Error("Le Hub n'a pas répondu. Est-il démarré à cette adresse ?"));
94
+ }, 8000);
95
+
96
+ ws.onopen = () => ws.send(JSON.stringify({
97
+ id: uuid(), version: 'intermesh/v1', type: 'register', sender: 'admin_console',
98
+ content: {
99
+ name: 'admin_console', api_key: key,
100
+ roles: ['admin', 'observer'], capabilities: ['administration'],
101
+ metadata: { client: 'mission-control' },
102
+ },
103
+ }));
104
+
105
+ ws.onmessage = (raw) => {
106
+ let m; try { m = JSON.parse(raw.data); } catch (_) { return; }
107
+ if (!S.token) {
108
+ clearTimeout(guard);
109
+ if (m.type === 'registered') {
110
+ S.ws = ws; S.token = m.content.token;
111
+ S.name = m.content.qualified_name; S.org = m.content.org_id;
112
+ ws.onmessage = (r) => { try { handle(JSON.parse(r.data)); } catch (_) {} };
113
+ return resolve(m.content);
114
+ }
115
+ if (m.type === 'error') { try { ws.close(); } catch (_) {} return reject(new Error(m.content)); }
116
+ return;
117
+ }
118
+ handle(m);
119
+ };
120
+ ws.onerror = () => { clearTimeout(guard); if (!S.token) reject(new Error('Connexion impossible.')); };
121
+ ws.onclose = () => { clearTimeout(guard); if (S.token) offline(); };
122
+ });
123
+ }
124
+
125
+ function handle(m) {
126
+ const waiting = m.reply_to ? S.pending.get(m.reply_to) : null;
127
+
128
+ // Le Hub répond avec la charge utile directement dans `content` — il n'y a
129
+ // pas d'enveloppe {ok, result}. En la cherchant, la console lisait
130
+ // `content.ok` (absent, donc faux) et rejetait avec `content.error`
131
+ // (absent aussi) : `new Error(undefined)` a un message vide, d'où un
132
+ // encadré d'erreur affiché sans rien dedans.
133
+ if (waiting && m.type === 'admin_result') {
134
+ S.pending.delete(m.reply_to);
135
+ waiting.resolve(m.content);
136
+ return;
137
+ }
138
+
139
+ // Un refus arrive en message `error` portant le même reply_to, et non en
140
+ // admin_result. Non traité, il laissait la promesse pendante jusqu'au
141
+ // délai de 12 s au lieu de dire tout de suite ce qui n'allait pas.
142
+ if (waiting && m.type === 'error') {
143
+ S.pending.delete(m.reply_to);
144
+ waiting.reject(new Error(
145
+ typeof m.content === 'string' ? m.content : JSON.stringify(m.content)));
146
+ return;
147
+ }
148
+
149
+ if (m.type === 'telemetry_event') onTelemetry(m.content);
150
+ }
151
+
152
+ function admin(command, params = {}) {
153
+ return new Promise((resolve, reject) => {
154
+ if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return reject(new Error('Hub déconnecté.'));
155
+ const id = uuid();
156
+ S.pending.set(id, { resolve, reject });
157
+ setTimeout(() => {
158
+ if (S.pending.has(id)) { S.pending.delete(id); reject(new Error('Délai dépassé.')); }
159
+ }, 12000);
160
+ S.ws.send(JSON.stringify({
161
+ id, version: 'intermesh/v1', type: 'admin_request',
162
+ sender: S.name, content: { command, params }, token: S.token,
163
+ }));
164
+ });
165
+ }
166
+
167
+ function offline() {
168
+ $('health').className = 'health bad';
169
+ $('health-txt').textContent = 'Hub déconnecté';
170
+ if (S.reconnect) return;
171
+ toast('Connexion au Hub perdue. Reconnexion…', true);
172
+ S.reconnect = setInterval(async () => {
173
+ try {
174
+ S.token = null;
175
+ await connect(S.url, S.key);
176
+ clearInterval(S.reconnect); S.reconnect = null;
177
+ $('health').className = 'health';
178
+ $('health-txt').textContent = 'Opérationnel';
179
+ toast('Reconnecté au Hub.');
180
+ render();
181
+ } catch (_) {}
182
+ }, 4000);
183
+ }
184
+
185
+ // ------------------------------------------------------------- télémétrie
186
+
187
+ const EV = {
188
+ agent_connected: { lv: 'info', src: 'registry', m: e => `Agent ${e.name} enregistré — rôles ${(e.roles||[]).join(', ')||'standard'} (${e.auth_method === 'api_key' ? 'clé d\'API' : 'auto-déclaré'})` },
189
+ agent_disconnected: { lv: 'warn', src: 'registry', m: e => `Agent ${e.name} déconnecté` },
190
+ task_submitted: { lv: 'task', src: 'scheduler', m: e => `Tâche « ${e.title} » ${e.orchestrator} → ${e.assignee}` },
191
+ task_updated: { lv: 'task', src: 'scheduler', m: e => `Tâche ${(e.title || e.task_id || '').toString().slice(0,28)} → ${String(e.status).toUpperCase()}${e.error_message ? ' : ' + e.error_message : ''}` },
192
+ message_routed: { lv: 'info', src: 'router', m: e => `${e.type} routé ${e.sender} → ${e.to}` },
193
+ admin_action: { lv: 'admin', src: 'admin', m: e => `${e.actor} a exécuté ${e.command}` },
194
+ admin_denied: { lv: 'error', src: 'admin', m: e => `${e.actor} REFUSÉ sur ${e.command}` },
195
+ };
196
+
197
+ function onTelemetry(ev) {
198
+ const spec = EV[ev.event];
199
+ if (spec) {
200
+ S.logs.unshift({ lv: spec.lv, src: spec.src, m: spec.m(ev), at: ev.at || Date.now()/1000 });
201
+ if (S.logs.length > 400) S.logs.length = 400;
202
+ if (S.view === 'overview' && !S.logsPaused) drawLogs();
203
+ }
204
+ // Les vues concernées se rafraîchissent d'elles-mêmes.
205
+ if (ev.event.startsWith('agent_') && (S.view === 'agents' || S.view === 'overview')) loadAgents();
206
+ if (ev.event.startsWith('task_') && S.view === 'tasks') loadTasks();
207
+ }
208
+
209
+ function drawLogs() {
210
+ const box = $('logs');
211
+ const want = $('log-level').value;
212
+ const rows = want ? S.logs.filter(l => l.lv === want) : S.logs;
213
+ if (!rows.length) { box.innerHTML = '<div class="empty">Aucun événement.</div>'; return; }
214
+ box.innerHTML = rows.slice(0, 200).map(l => `
215
+ <div class="log">
216
+ <span class="t">${hhmmss(l.at)}</span>
217
+ <span class="lv ${l.lv}">${l.lv.toUpperCase()}</span>
218
+ <span class="src">${esc(l.src)}</span>
219
+ <span class="m">${esc(l.m)}</span>
220
+ </div>`).join('');
221
+ }
222
+
223
+ // ------------------------------------------------------------------ vues
224
+
225
+ async function loadOverview() {
226
+ try {
227
+ const i = await admin('hub.info');
228
+ S.info = i;
229
+
230
+ const unfinished = (i.tasks_by_status.pending || 0) + (i.tasks_by_status.running || 0);
231
+ const push = (k, v) => {
232
+ // Au tout premier relevé, on double le point : une ligne plate est
233
+ // vraie (aucune variation encore observée) et vaut mieux qu'une
234
+ // carte vide le temps du second échantillon.
235
+ if (!S.series[k].length) S.series[k].push(v);
236
+ S.series[k].push(v);
237
+ if (S.series[k].length > 40) S.series[k].shift();
238
+ };
239
+ push('agents', i.agents_online); push('tasks', i.tasks_total);
240
+ push('audit', i.audit_entries); push('keys', i.api_keys);
241
+
242
+ const cards = [
243
+ ['Agents connectés', i.agents_online, 'cyan', S.series.agents, '#22D3EE', `${i.agents_known} connus`],
244
+ ['Tâches', i.tasks_total, 'teal', S.series.tasks, '#2DD4BF', `${unfinished} inachevée(s)`],
245
+ ['Journal d\'audit', i.audit_entries, i.audit_intact ? 'green' : 'red', S.series.audit,
246
+ i.audit_intact ? '#10B981' : '#EF4444', i.audit_intact ? 'chaîne vérifiée' : '🚨 CHAÎNE ROMPUE'],
247
+ ['Comptes de service', i.api_keys, 'violet', S.series.keys, '#A78BFA',
248
+ i.api_keys_mutable ? 'modifiables' : 'lecture seule'],
249
+ ];
250
+ $('kpis').innerHTML = cards.map(([k, v, c, ser, col, s]) => `
251
+ <div class="kpi">
252
+ <div class="k">${k}</div>
253
+ <div class="row"><div class="v ${c}">${v}</div>${sparkline(ser, col)}</div>
254
+ <div class="s">${trend(ser)} <span style="color:var(--dim)">· ${esc(s)}</span></div>
255
+ </div>`).join('');
256
+
257
+ $('sb-version').textContent = 'v' + i.version;
258
+ $('sb-org').textContent = i.org;
259
+ $('sb-state').textContent = i.state_ephemeral ? 'ÉPHÉMÈRE' : 'PERSISTANT';
260
+ $('sb-audit').textContent = i.audit_intact ? 'INTÈGRE' : 'ROMPU';
261
+ $('sb-peers').textContent = i.federation_peers.length + ' pair(s)';
262
+ $('sb-as').textContent = S.name;
263
+
264
+ if (!i.audit_intact) {
265
+ $('health').className = 'health bad';
266
+ $('health-txt').textContent = 'Audit compromis';
267
+ }
268
+
269
+ const ag = await admin('agents.list');
270
+ $('ov-agent-count').textContent = `${ag.online} / ${ag.known}`;
271
+ const online = ag.agents.filter(a => a.online);
272
+ $('ov-agents').innerHTML = online.length ? online.map(a => `
273
+ <tr>
274
+ <td><span class="name">${esc(a.name)}</span></td>
275
+ <td><span class="st on"><span class="dot up"></span>ONLINE</span></td>
276
+ <td>${(a.roles||[]).map(r => `<span class="tag">${esc(r)}</span>`).join('') || '—'}</td>
277
+ <td style="color:var(--muted)">${esc((a.capabilities||[]).join(', ') || '—')}</td>
278
+ <td class="num" style="color:${a.pending_tasks ? 'var(--amber)' : 'var(--dim)'}">${a.pending_tasks}</td>
279
+ </tr>`).join('') : '<tr><td colspan="5" class="empty">Aucun agent connecté.</td></tr>';
280
+
281
+ drawLogs();
282
+ } catch (e) { toast(e.message, true); }
283
+ }
284
+
285
+ function pager(id, total, key, reload) {
286
+ const pages = Math.max(1, Math.ceil(total / S.PER_PAGE));
287
+ if (S.page[key] >= pages) S.page[key] = pages - 1;
288
+ const from = total ? S.page[key] * S.PER_PAGE + 1 : 0;
289
+ const to = Math.min(total, (S.page[key] + 1) * S.PER_PAGE);
290
+ $(id).innerHTML = `
291
+ <span>${from}–${to} sur ${total}</span>
292
+ <span class="right">
293
+ <button class="btn btn-xs" data-page="${key}:0" ${S.page[key] ? '' : 'disabled'}>«</button>
294
+ <button class="btn btn-xs" data-page="${key}:${S.page[key]-1}" ${S.page[key] ? '' : 'disabled'}>‹</button>
295
+ <span style="padding:0 6px">${S.page[key]+1} / ${pages}</span>
296
+ <button class="btn btn-xs" data-page="${key}:${S.page[key]+1}" ${S.page[key] >= pages-1 ? 'disabled' : ''}>›</button>
297
+ <button class="btn btn-xs" data-page="${key}:${pages-1}" ${S.page[key] >= pages-1 ? 'disabled' : ''}>»</button>
298
+ </span>`;
299
+ }
300
+
301
+ async function loadAgents() {
302
+ try {
303
+ const d = await admin('agents.list');
304
+ if (S.view === 'overview') { $('ov-agent-count').textContent = `${d.online} / ${d.known}`; return; }
305
+
306
+ const q = ($('ag-search').value || '').toLowerCase();
307
+ const st = $('ag-status').value;
308
+ let rows = d.agents.filter(a => {
309
+ if (st === 'online' && !a.online) return false;
310
+ if (st === 'offline' && a.online) return false;
311
+ if (!q) return true;
312
+ return (a.name + (a.capabilities||[]).join(' ') + (a.roles||[]).join(' ')).toLowerCase().includes(q);
313
+ });
314
+
315
+ $('ag-meta').textContent = `${d.online} en ligne · ${d.known} connus`;
316
+ const page = rows.slice(S.page.agents * S.PER_PAGE, (S.page.agents + 1) * S.PER_PAGE);
317
+
318
+ $('ag-rows').innerHTML = page.length ? page.map(a => `
319
+ <tr>
320
+ <td><span class="name">${esc(a.name)}</span><div class="sub-id">${esc((a.agent_id||'').slice(0,8))}</div></td>
321
+ <td><span class="st ${a.online ? 'on' : 'off'}"><span class="dot ${a.online ? 'up' : 'down'}"></span>${a.online ? 'ONLINE' : 'OFFLINE'}</span></td>
322
+ <td class="mono" style="color:var(--muted)">${esc(a.org_id || '—')}</td>
323
+ <td>${(a.roles||[]).map(r => `<span class="tag">${esc(r)}</span>`).join('') || '—'}</td>
324
+ <td style="color:var(--muted)">${esc((a.capabilities||[]).join(', ') || '—')}</td>
325
+ <td>${a.encrypted ? '🔒' : '<span style="color:var(--dim)">🔓</span>'}</td>
326
+ <td class="mono" style="font-size:10.5px;color:var(--dim)">${(a.roles||[]).includes('service_account') ? 'clé' : 'déclaré'}</td>
327
+ <td class="num" style="color:${a.pending_tasks ? 'var(--amber)' : 'var(--dim)'}">${a.pending_tasks}</td>
328
+ <td style="text-align:right">${a.online ? `<button class="btn btn-danger btn-xs" data-kick="${esc(a.name)}">Déconnecter</button>` : ''}</td>
329
+ </tr>`).join('') : '<tr><td colspan="9" class="empty">Aucun agent.</td></tr>';
330
+
331
+ pager('ag-pager', rows.length, 'agents');
332
+ } catch (e) { toast(e.message, true); }
333
+ }
334
+
335
+ const TASK_ST = { pending: ['wait','PENDING'], running: ['run','RUNNING'],
336
+ completed: ['on','COMPLETED'], failed: ['fail','FAILED'] };
337
+
338
+ async function loadTasks() {
339
+ try {
340
+ const status = $('tk-filter').value;
341
+ const d = await admin('tasks.list', Object.assign({ limit: 500 }, status ? { status } : {}));
342
+ const q = ($('tk-search').value || '').toLowerCase();
343
+ let rows = q ? d.tasks.filter(t => (t.title + t.assignee + t.orchestrator).toLowerCase().includes(q)) : d.tasks;
344
+
345
+ $('tk-meta').textContent = `${rows.length} tâche(s)${status ? ' · ' + status : ''}`;
346
+ const page = rows.slice(S.page.tasks * S.PER_PAGE, (S.page.tasks + 1) * S.PER_PAGE);
347
+
348
+ $('tk-rows').innerHTML = page.length ? page.map(t => {
349
+ const [cls, lbl] = TASK_ST[t.status] || ['off', String(t.status).toUpperCase()];
350
+ const done = t.status === 'completed' || t.status === 'failed';
351
+ return `<tr>
352
+ <td>${esc(t.title)} ${t.encrypted_payload ? '<span title="Charge chiffrée E2E">🔒</span>' : ''}
353
+ <div class="sub-id">${esc(t.task_id.slice(0,8))}</div></td>
354
+ <td><span class="st ${cls}">${lbl}</span>${t.error_message
355
+ ? `<div class="sub-id" style="color:var(--red);max-width:190px;white-space:normal">${esc(t.error_message)}</div>` : ''}</td>
356
+ <td class="mono" style="font-size:11px">${esc(t.orchestrator)}</td>
357
+ <td class="mono" style="font-size:11px">${esc(t.assignee)}</td>
358
+ <td class="num" style="color:var(--dim)">${ago(t.created_at)}</td>
359
+ <td class="num" style="color:var(--muted)">${ago(t.updated_at)}</td>
360
+ <td style="text-align:right">
361
+ ${done ? '' : `<button class="btn btn-danger btn-xs" data-cancel="${esc(t.task_id)}">Annuler</button> `}
362
+ <button class="btn btn-xs" data-retry="${esc(t.task_id)}">Relancer</button></td>
363
+ </tr>`;
364
+ }).join('') : '<tr><td colspan="7" class="empty">Aucune tâche.</td></tr>';
365
+
366
+ pager('tk-pager', rows.length, 'tasks');
367
+ } catch (e) { toast(e.message, true); }
368
+ }
369
+
370
+ async function loadAudit() {
371
+ try {
372
+ const d = await admin('audit.list', { limit: 1000 });
373
+ const q = ($('au-search').value || '').toLowerCase();
374
+ let rows = q ? d.entries.filter(e => (e.event_type + e.sender + (e.target||'')).toLowerCase().includes(q)) : d.entries;
375
+
376
+ $('au-meta').textContent = `${d.chain_length} entrée(s) chaînée(s)`;
377
+ $('au-banner').innerHTML = d.intact
378
+ ? `<div class="banner ok">✓ Chaîne intègre — les ${d.chain_length} entrées se vérifient mutuellement.</div>`
379
+ : `<div class="banner bad">🚨 CHAÎNE ROMPUE — le journal a été modifié en dehors du Hub.
380
+ Traitez-le comme compromis et enquêtez avant de poursuivre.</div>`;
381
+
382
+ const page = rows.slice(S.page.audit * S.PER_PAGE, (S.page.audit + 1) * S.PER_PAGE);
383
+ $('au-rows').innerHTML = page.length ? page.map(e => {
384
+ const lv = e.event_type.includes('DENIED') || e.event_type.includes('FAILED') ? 'error'
385
+ : e.event_type.startsWith('ADMIN') ? 'admin'
386
+ : e.event_type.startsWith('TASK') ? 'task'
387
+ : e.event_type.includes('DISCONNECT') ? 'warn' : 'info';
388
+ const meta = JSON.stringify(e.metadata || {});
389
+ return `<tr>
390
+ <td class="num" style="color:var(--dim)">${e.index}</td>
391
+ <td class="num" style="color:var(--muted)">${hhmmss(e.timestamp)}</td>
392
+ <td><span class="lv ${lv}" style="display:inline-block;width:auto;padding:1px 7px">${esc(e.event_type)}</span></td>
393
+ <td class="mono" style="font-size:11px">${esc(e.sender)}</td>
394
+ <td class="mono" style="font-size:11px;color:var(--muted)">${esc(e.target || '—')}</td>
395
+ <td class="mono" style="font-size:10.5px;color:var(--dim);max-width:280px;overflow:hidden;text-overflow:ellipsis"
396
+ title="${esc(meta)}">${esc(meta === '{}' ? '—' : meta)}</td>
397
+ <td class="mono" style="font-size:10px;color:var(--dim)">${esc(e.hash.slice(0,12))}</td>
398
+ </tr>`;
399
+ }).join('') : '<tr><td colspan="7" class="empty">Aucune entrée.</td></tr>';
400
+
401
+ pager('au-pager', rows.length, 'audit');
402
+ } catch (e) { toast(e.message, true); }
403
+ }
404
+
405
+ async function loadKeys() {
406
+ try {
407
+ const d = await admin('apikeys.list');
408
+ $('kt-meta').textContent = `${d.keys.length} clé(s) · ${d.source}`;
409
+ if (!d.mutable) toast("Clés en lecture seule : elles viennent de l'environnement.", true);
410
+
411
+ $('kt-rows').innerHTML = d.keys.length ? d.keys.map(k => `
412
+ <tr>
413
+ <td class="name">${esc(k.fingerprint)}</td>
414
+ <td>${esc(k.label || '—')}</td>
415
+ <td class="mono" style="font-size:11px">${esc(k.org_id)}</td>
416
+ <td>${(k.roles||[]).map(r => `<span class="tag">${esc(r)}</span>`).join('')}</td>
417
+ <td style="color:var(--muted)">${esc((k.permissions||[]).join(', ') || '—')}</td>
418
+ <td class="num" style="color:var(--dim)">${k.created_at ? ago(k.created_at) : '—'}</td>
419
+ <td style="text-align:right">${d.mutable
420
+ ? `<button class="btn btn-danger btn-xs" data-revoke="${esc(k.fingerprint)}">Révoquer</button>` : ''}</td>
421
+ </tr>`).join('') : '<tr><td colspan="7" class="empty">Aucun compte de service.</td></tr>';
422
+ } catch (e) { toast(e.message, true); }
423
+ }
424
+
425
+ const LOADERS = { overview: loadOverview, agents: loadAgents, tasks: loadTasks, audit: loadAudit, keys: loadKeys };
426
+
427
+ function render() {
428
+ for (const v of Object.keys(LOADERS)) $('view-' + v).style.display = v === S.view ? '' : 'none';
429
+ document.querySelectorAll('#tabs a').forEach(a => a.classList.toggle('on', a.dataset.view === S.view));
430
+ LOADERS[S.view]();
431
+ }
432
+
433
+ // --------------------------------------------------------------- modales
434
+
435
+ const openModal = (id) => $(id).classList.add('on');
436
+ const closeModals = () => document.querySelectorAll('.modal').forEach(m => m.classList.remove('on'));
437
+ let confirmFn = null;
438
+ function confirmAction(title, text, fn) {
439
+ $('cf-title').textContent = title; $('cf-text').textContent = text;
440
+ confirmFn = fn; openModal('modal-confirm');
441
+ }
442
+
443
+ // ------------------------------------------------------------ événements
444
+
445
+ $('connect-btn').onclick = async () => {
446
+ const url = $('hub-url').value.trim(), key = $('api-key').value.trim(), err = $('login-error');
447
+ err.style.display = 'none';
448
+ if (!key) { err.textContent = "Une clé d'API est requise."; err.style.display = 'block'; return; }
449
+
450
+ const btn = $('connect-btn');
451
+ btn.disabled = true; btn.textContent = 'Connexion…';
452
+ try {
453
+ await connect(url, key);
454
+ S.url = url; S.key = key;
455
+
456
+ // Sonde immédiate : une clé sans rôle admin passe l'enregistrement mais
457
+ // échoue ici. Mieux vaut le dire tout de suite qu'après trois clics.
458
+ try { await admin('hub.info'); }
459
+ catch (e) {
460
+ try { S.ws.close(); } catch (_) {}
461
+ S.token = null;
462
+ err.textContent = e.message; err.style.display = 'block';
463
+ return;
464
+ }
465
+
466
+ $('login').style.display = 'none';
467
+ $('app').classList.add('on');
468
+ $('avatar').textContent = (S.org || 'NX').slice(0, 2).toUpperCase();
469
+ render();
470
+ } catch (e) {
471
+ err.textContent = e.message; err.style.display = 'block';
472
+ } finally { btn.disabled = false; btn.textContent = 'Se connecter'; }
473
+ };
474
+
475
+ $('api-key').addEventListener('keydown', e => { if (e.key === 'Enter') $('connect-btn').click(); });
476
+
477
+ $('logout-btn').onclick = () => {
478
+ if (S.reconnect) { clearInterval(S.reconnect); S.reconnect = null; }
479
+ try { S.ws.close(); } catch (_) {}
480
+ S.ws = null; S.token = null; S.key = null; S.logs = [];
481
+ S.series = { agents: [], tasks: [], audit: [], keys: [] };
482
+ $('app').classList.remove('on');
483
+ $('login').style.display = 'flex';
484
+ $('api-key').value = '';
485
+ };
486
+
487
+ $('tabs').addEventListener('click', e => {
488
+ const a = e.target.closest('a[data-view]');
489
+ if (a) { S.view = a.dataset.view; render(); }
490
+ });
491
+
492
+ ['ag-search','ag-status','tk-search','tk-filter','au-search'].forEach(id => {
493
+ const el = $(id);
494
+ const key = id.startsWith('ag') ? 'agents' : id.startsWith('tk') ? 'tasks' : 'audit';
495
+ el.addEventListener(el.tagName === 'SELECT' ? 'change' : 'input', () => {
496
+ S.page[key] = 0; LOADERS[S.view]();
497
+ });
498
+ });
499
+
500
+ $('log-level').onchange = drawLogs;
501
+ $('log-pause').onclick = () => {
502
+ S.logsPaused = !S.logsPaused;
503
+ $('log-pause').textContent = S.logsPaused ? '▶' : '❚❚';
504
+ if (!S.logsPaused) drawLogs();
505
+ };
506
+
507
+ document.addEventListener('click', async (e) => {
508
+ const t = e.target;
509
+ if (t.hasAttribute('data-close') || t.classList.contains('modal')) return closeModals();
510
+ if (t.dataset.act === 'refresh') return LOADERS[S.view]();
511
+ if (t.dataset.act === 'clear-logs') { S.logs = []; return drawLogs(); }
512
+ if (t.dataset.act === 'new-key') return openModal('modal-key');
513
+
514
+ if (t.dataset.page) {
515
+ const [key, n] = t.dataset.page.split(':');
516
+ S.page[key] = Math.max(0, parseInt(n, 10));
517
+ return LOADERS[S.view]();
518
+ }
519
+
520
+ if (t.dataset.act === 'verify') {
521
+ try {
522
+ const v = await admin('audit.verify');
523
+ toast(v.intact ? `Chaîne intègre : ${v.chain_length} entrées vérifiées.`
524
+ : `CHAÎNE ROMPUE à l'entrée ${v.broken_at_index}.`, !v.intact);
525
+ loadAudit();
526
+ } catch (err) { toast(err.message, true); }
527
+ return;
528
+ }
529
+
530
+ if (t.dataset.kick) {
531
+ const name = t.dataset.kick;
532
+ return confirmAction('Déconnecter cet agent ?',
533
+ `${name} sera immédiatement déconnecté. Il pourra revenir, et ses tâches inachevées lui seront alors réassignées.`,
534
+ async () => { await admin('agent.disconnect', { name }); toast(`${name} déconnecté.`); loadAgents(); });
535
+ }
536
+ if (t.dataset.cancel) {
537
+ const id = t.dataset.cancel;
538
+ return confirmAction('Annuler cette tâche ?',
539
+ "Elle sera marquée comme échouée. Son exécutant, s'il travaille déjà dessus, ne sera pas interrompu.",
540
+ async () => { await admin('task.cancel', { task_id: id }); toast('Tâche annulée.'); loadTasks(); });
541
+ }
542
+ if (t.dataset.retry) {
543
+ try {
544
+ const r = await admin('task.retry', { task_id: t.dataset.retry });
545
+ toast(r.delivered ? 'Tâche renvoyée à son exécutant.'
546
+ : 'Exécutant hors ligne : la tâche lui sera poussée à sa reconnexion.');
547
+ loadTasks();
548
+ } catch (err) { toast(err.message, true); }
549
+ return;
550
+ }
551
+ if (t.dataset.revoke) {
552
+ const fp = t.dataset.revoke;
553
+ return confirmAction('Révoquer cette clé ?',
554
+ `La clé ${fp}… cessera immédiatement de fonctionner. Irréversible : une clé révoquée ne se restaure pas.`,
555
+ async () => { await admin('apikey.revoke', { fingerprint: fp }); toast('Clé révoquée.'); loadKeys(); });
556
+ }
557
+ });
558
+
559
+ $('cf-ok').onclick = async () => {
560
+ closeModals();
561
+ if (!confirmFn) return;
562
+ try { await confirmFn(); } catch (e) { toast(e.message, true); }
563
+ confirmFn = null;
564
+ };
565
+
566
+ $('nk-create').onclick = async () => {
567
+ const org = $('nk-org').value.trim();
568
+ if (!org) return toast('Organisation requise.', true);
569
+ const split = (id) => $(id).value.split(',').map(s => s.trim()).filter(Boolean);
570
+ try {
571
+ const r = await admin('apikey.create', {
572
+ org_id: org, label: $('nk-label').value.trim(),
573
+ roles: split('nk-roles'), permissions: split('nk-perms'),
574
+ });
575
+ closeModals();
576
+ $('rv-key').textContent = r.key;
577
+ openModal('modal-reveal');
578
+ ['nk-org','nk-label','nk-perms'].forEach(id => { $(id).value = ''; });
579
+ loadKeys();
580
+ } catch (e) { toast(e.message, true); }
581
+ };
582
+
583
+ $('rv-copy').onclick = async () => {
584
+ try { await navigator.clipboard.writeText($('rv-key').textContent); toast('Clé copiée.'); }
585
+ catch (_) { toast('Copie impossible — sélectionnez la clé manuellement.', true); }
586
+ };
587
+
588
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModals(); });
589
+
590
+ setInterval(() => {
591
+ $('sb-clock').textContent = new Date().toLocaleTimeString('fr-FR', { hour12: false }) + ' ' +
592
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
593
+ }, 1000);
594
+
595
+ // Échantillonnage des séries + rattrapage de ce que la télémétrie ne couvre
596
+ // pas (compteurs, expirations). L'audit est exclu : trop coûteux à recharger.
597
+ setInterval(() => {
598
+ if (!S.token) return;
599
+ if (S.view === 'overview') loadOverview();
600
+ else if (S.view !== 'audit') LOADERS[S.view]();
601
+ }, 15000);
@@ -0,0 +1,427 @@
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>InterMesh — Mission Control</title>
7
+ <style>
8
+ /* Aucune dépendance externe — ni CDN, ni police distante, ni librairie de
9
+ graphes. La console doit s'ouvrir sur un réseau fermé, devant un Hub qui
10
+ n'a pas accès à Internet. Les sparklines sont du SVG calculé à la main. */
11
+ :root{
12
+ --bg:#050810; --bg-2:#0A0E17; --card:#0D1220; --card-2:#111827;
13
+ --line:#1A2233; --line-2:#141C2B;
14
+ --cyan:#22D3EE; --teal:#2DD4BF; --emerald:#10B981;
15
+ --amber:#F59E0B; --red:#EF4444; --violet:#A78BFA;
16
+ --fg:#E6EDF6; --muted:#8B97AB; --dim:#5A6579;
17
+ --mono:'JetBrains Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace;
18
+ --sans:Inter,-apple-system,'Segoe UI',Roboto,sans-serif;
19
+ }
20
+ *{box-sizing:border-box;margin:0;padding:0}
21
+ html,body{height:100%}
22
+ body{background:var(--bg);color:var(--fg);font-family:var(--sans);
23
+ font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased}
24
+ button{font-family:inherit;font-size:12px;cursor:pointer;border-radius:5px;
25
+ transition:background .12s,border-color .12s,color .12s}
26
+ input,select{font-family:inherit;font-size:12.5px;background:var(--bg);
27
+ border:1px solid var(--line);color:var(--fg);padding:8px 10px;border-radius:5px;width:100%}
28
+ input:focus,select:focus{outline:none;border-color:var(--cyan)}
29
+ select{cursor:pointer}
30
+ .mono{font-family:var(--mono)}
31
+ .num{font-family:var(--mono);font-variant-numeric:tabular-nums}
32
+
33
+ /* ---------------- Connexion ---------------- */
34
+ #login{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:20px;
35
+ background:radial-gradient(ellipse at 50% 0%,rgba(34,211,238,.06),transparent 60%)}
36
+ .login-card{background:var(--card);border:1px solid var(--line);border-radius:10px;
37
+ padding:34px;width:100%;max-width:410px}
38
+ .login-card h1{font-size:17px;font-weight:600;letter-spacing:.34em;font-family:var(--mono);margin-bottom:5px}
39
+ .login-card .sub{color:var(--muted);font-size:12.5px;margin-bottom:24px}
40
+ .field{margin-bottom:14px}
41
+ .field label{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.1em;
42
+ color:var(--muted);margin-bottom:6px;font-weight:600}
43
+ .btn{background:transparent;border:1px solid var(--line);color:var(--fg);padding:8px 13px}
44
+ .btn:hover{border-color:var(--dim);background:rgba(255,255,255,.03)}
45
+ .btn-primary{border-color:var(--cyan);color:var(--cyan)}
46
+ .btn-primary:hover{background:rgba(34,211,238,.1)}
47
+ .btn-danger{border-color:rgba(239,68,68,.5);color:#FCA5A5}
48
+ .btn-danger:hover{background:rgba(239,68,68,.12)}
49
+ .btn-block{width:100%;padding:10px}
50
+ .btn-xs{padding:4px 8px;font-size:11px}
51
+ .note{font-size:11.5px;color:var(--dim);margin-top:16px;line-height:1.65;
52
+ border-top:1px solid var(--line-2);padding-top:14px}
53
+ .error{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.3);color:#FCA5A5;
54
+ padding:9px 11px;border-radius:5px;font-size:12px;margin-bottom:14px}
55
+
56
+ /* ---------------- Ossature ---------------- */
57
+ #app{display:none;height:100vh;flex-direction:column}
58
+ #app.on{display:flex}
59
+ .topbar{height:52px;flex-shrink:0;background:var(--bg-2);border-bottom:1px solid var(--line);
60
+ display:flex;align-items:center;gap:22px;padding:0 18px}
61
+ .logo{font-family:var(--mono);font-size:14px;font-weight:600;letter-spacing:.3em;
62
+ padding-right:20px;border-right:1px solid var(--line);height:100%;display:flex;align-items:center}
63
+ .tabs{display:flex;gap:2px;height:100%;overflow-x:auto}
64
+ .tabs a{display:flex;align-items:center;padding:0 15px;font-size:11.5px;font-weight:600;
65
+ letter-spacing:.09em;text-transform:uppercase;color:var(--muted);cursor:pointer;
66
+ border-bottom:2px solid transparent;white-space:nowrap}
67
+ .tabs a:hover{color:var(--fg)}
68
+ .tabs a.on{color:var(--cyan);border-bottom-color:var(--cyan)}
69
+ .top-right{margin-left:auto;display:flex;align-items:center;gap:14px}
70
+ .health{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:600;
71
+ letter-spacing:.07em;text-transform:uppercase;color:var(--emerald)}
72
+ .health.bad{color:var(--red)}
73
+ .avatar{width:28px;height:28px;border-radius:5px;border:1px solid var(--line);
74
+ display:flex;align-items:center;justify-content:center;font-size:10.5px;
75
+ font-family:var(--mono);color:var(--muted)}
76
+ .dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0}
77
+ .dot.up{background:var(--emerald);box-shadow:0 0 7px rgba(16,185,129,.65)}
78
+ .dot.down{background:var(--red)}
79
+ .dot.warn{background:var(--amber)}
80
+
81
+ main{flex:1;overflow-y:auto;padding:18px}
82
+ .statusbar{height:34px;flex-shrink:0;background:var(--bg-2);border-top:1px solid var(--line);
83
+ display:flex;align-items:center;gap:0;padding:0 18px;font-family:var(--mono);
84
+ font-size:10.5px;color:var(--dim);overflow-x:auto}
85
+ .statusbar .cell{padding:0 16px;border-right:1px solid var(--line-2);white-space:nowrap}
86
+ .statusbar .cell:first-child{padding-left:0}
87
+ .statusbar .cell:last-child{border-right:none;margin-left:auto;padding-right:0}
88
+ .statusbar b{color:var(--muted);font-weight:500}
89
+
90
+ /* ---------------- KPI ---------------- */
91
+ .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(238px,1fr));gap:12px;margin-bottom:16px}
92
+ .kpi{background:var(--card);border:1px solid var(--line);border-radius:7px;padding:14px 16px}
93
+ .kpi .k{font-size:9.5px;text-transform:uppercase;letter-spacing:.11em;color:var(--muted);
94
+ font-weight:600;margin-bottom:10px}
95
+ .kpi .row{display:flex;align-items:flex-end;justify-content:space-between;gap:10px}
96
+ .kpi .v{font-size:27px;font-weight:600;font-family:var(--mono);line-height:1;
97
+ font-variant-numeric:tabular-nums}
98
+ .kpi .spark{flex-shrink:0;opacity:.85}
99
+ .kpi .s{font-size:10.5px;color:var(--dim);margin-top:8px;font-family:var(--mono)}
100
+ .kpi .s .up{color:var(--emerald)} .kpi .s .down{color:var(--red)}
101
+ .v.cyan{color:var(--cyan)} .v.teal{color:var(--teal)} .v.green{color:var(--emerald)}
102
+ .v.amber{color:var(--amber)} .v.red{color:var(--red)} .v.violet{color:var(--violet)}
103
+
104
+ /* ---------------- Panneaux ---------------- */
105
+ .cols{display:grid;grid-template-columns:1fr 1fr;gap:12px}
106
+ .cols.wide-left{grid-template-columns:1.15fr .85fr}
107
+ @media(max-width:1150px){.cols,.cols.wide-left{grid-template-columns:1fr}}
108
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:7px;
109
+ margin-bottom:12px;display:flex;flex-direction:column;overflow:hidden}
110
+ .panel-head{padding:11px 15px;border-bottom:1px solid var(--line-2);flex-shrink:0;
111
+ display:flex;align-items:center;gap:11px}
112
+ .panel-head h3{font-size:10.5px;text-transform:uppercase;letter-spacing:.11em;
113
+ color:var(--fg);font-weight:600}
114
+ .panel-head .count{font-family:var(--mono);font-size:10.5px;color:var(--dim)}
115
+ .panel-head .right{margin-left:auto;display:flex;align-items:center;gap:7px}
116
+ .live{display:flex;align-items:center;gap:6px;font-size:10px;letter-spacing:.1em;
117
+ color:var(--emerald);font-weight:600}
118
+ .live .dot{animation:pulse 2s ease-in-out infinite}
119
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
120
+
121
+ .scroll{overflow:auto}
122
+ table{width:100%;border-collapse:collapse;font-size:12px}
123
+ th{text-align:left;padding:8px 15px;font-size:9.5px;text-transform:uppercase;
124
+ letter-spacing:.09em;color:var(--dim);font-weight:600;white-space:nowrap;
125
+ border-bottom:1px solid var(--line-2);position:sticky;top:0;background:var(--card);z-index:1}
126
+ td{padding:9px 15px;border-bottom:1px solid var(--line-2);vertical-align:middle;white-space:nowrap}
127
+ tbody tr:last-child td{border-bottom:none}
128
+ tbody tr:hover{background:rgba(255,255,255,.022)}
129
+ .empty{padding:38px 15px;text-align:center;color:var(--dim);font-size:12px}
130
+ .name{color:var(--cyan);font-family:var(--mono);font-size:11.5px}
131
+ .sub-id{font-family:var(--mono);font-size:9.5px;color:var(--dim);margin-top:2px}
132
+
133
+ .tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;
134
+ font-family:var(--mono);background:var(--line);color:var(--muted);margin-right:3px}
135
+ .st{display:inline-flex;align-items:center;gap:6px;font-size:10.5px;font-weight:600;
136
+ font-family:var(--mono);letter-spacing:.05em}
137
+ .st.on{color:var(--emerald)} .st.off{color:var(--dim)}
138
+ .st.run{color:var(--cyan)} .st.wait{color:var(--amber)} .st.fail{color:var(--red)}
139
+
140
+ /* ---------------- Flux de logs ---------------- */
141
+ .logs{font-family:var(--mono);font-size:11px;overflow-y:auto;padding:6px 0;flex:1}
142
+ .log{display:flex;gap:11px;padding:3px 15px;align-items:baseline}
143
+ .log:hover{background:rgba(255,255,255,.025)}
144
+ .log .t{color:var(--dim);flex-shrink:0}
145
+ .log .lv{flex-shrink:0;width:52px;text-align:center;padding:0 5px;border-radius:3px;
146
+ font-size:9.5px;font-weight:600;letter-spacing:.05em;border:1px solid}
147
+ .lv.info{color:var(--cyan);border-color:rgba(34,211,238,.3)}
148
+ .lv.task{color:var(--emerald);border-color:rgba(16,185,129,.3)}
149
+ .lv.warn{color:var(--amber);border-color:rgba(245,158,11,.35)}
150
+ .lv.error{color:var(--red);border-color:rgba(239,68,68,.35)}
151
+ .lv.admin{color:var(--violet);border-color:rgba(167,139,250,.35)}
152
+ .log .src{color:var(--muted);flex-shrink:0;width:96px;overflow:hidden;text-overflow:ellipsis}
153
+ .log .m{color:var(--fg);opacity:.86;word-break:break-word}
154
+
155
+ .bar{display:flex;align-items:center;gap:9px;padding:9px 15px;
156
+ border-bottom:1px solid var(--line-2);flex-wrap:wrap}
157
+ .bar select{width:auto;min-width:132px}
158
+ .pager{display:flex;align-items:center;gap:9px;padding:9px 15px;
159
+ border-top:1px solid var(--line-2);font-size:11px;color:var(--dim);font-family:var(--mono)}
160
+ .pager .right{margin-left:auto;display:flex;gap:5px;align-items:center}
161
+
162
+ .banner{padding:9px 12px;border-radius:5px;font-size:12px;margin-bottom:12px;line-height:1.6}
163
+ .banner.ok{background:rgba(16,185,129,.07);border:1px solid rgba(16,185,129,.28);color:#6EE7B7}
164
+ .banner.bad{background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.32);color:#FCA5A5}
165
+
166
+ /* ---------------- Modales ---------------- */
167
+ .modal{position:fixed;inset:0;background:rgba(0,0,0,.76);display:none;
168
+ align-items:center;justify-content:center;padding:20px;z-index:50}
169
+ .modal.on{display:flex}
170
+ .modal-card{background:var(--card);border:1px solid var(--line);border-radius:9px;
171
+ padding:24px;width:100%;max-width:450px;max-height:88vh;overflow-y:auto}
172
+ .modal-card h3{font-size:14px;margin-bottom:5px}
173
+ .modal-card .sub{font-size:12px;color:var(--muted);margin-bottom:18px}
174
+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
175
+ .reveal{background:var(--bg);border:1px solid var(--cyan);border-radius:5px;padding:13px;
176
+ font-family:var(--mono);font-size:11.5px;word-break:break-all;color:var(--cyan);margin:12px 0}
177
+ .warn{background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.32);color:#FCD34D;
178
+ padding:10px 12px;border-radius:5px;font-size:12px;line-height:1.6}
179
+ .toast{position:fixed;bottom:48px;right:20px;background:var(--card-2);border:1px solid var(--line);
180
+ border-left:3px solid var(--cyan);border-radius:5px;padding:12px 15px;font-size:12.5px;
181
+ max-width:370px;z-index:60;display:none}
182
+ .toast.on{display:block}
183
+ .toast.bad{border-left-color:var(--red)}
184
+ @media(max-width:760px){
185
+ .topbar{gap:12px;padding:0 12px} .logo{padding-right:12px}
186
+ main{padding:12px} .statusbar .cell{padding:0 10px}
187
+ }
188
+ </style>
189
+ </head>
190
+ <body>
191
+
192
+ <!-- ============ CONNEXION ============ -->
193
+ <div id="login">
194
+ <div class="login-card">
195
+ <h1>INTERMESH</h1>
196
+ <div class="sub">Mission Control · Console d'administration</div>
197
+ <div id="login-error" class="error" style="display:none"></div>
198
+
199
+ <div class="field">
200
+ <label for="hub-url">Adresse du Hub</label>
201
+ <input id="hub-url" class="mono" value="ws://localhost:8765" spellcheck="false">
202
+ </div>
203
+ <div class="field">
204
+ <label for="api-key">Clé d'API</label>
205
+ <input id="api-key" type="password" class="mono" placeholder="nx_live_…" spellcheck="false">
206
+ </div>
207
+
208
+ <button id="connect-btn" class="btn btn-primary btn-block">Se connecter</button>
209
+
210
+ <div class="note">
211
+ L'administration exige une clé portant le rôle <span class="mono">admin</span>.
212
+ Un agent qui se déclare lui-même administrateur est refusé.<br><br>
213
+ La clé reste en mémoire pour la durée de l'onglet — jamais écrite dans
214
+ le navigateur. Un rechargement la redemande.
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <!-- ============ APPLICATION ============ -->
220
+ <div id="app">
221
+
222
+ <div class="topbar">
223
+ <div class="logo">INTERMESH</div>
224
+ <div class="tabs" id="tabs">
225
+ <a data-view="overview" class="on">Mission Control</a>
226
+ <a data-view="agents">Agents</a>
227
+ <a data-view="tasks">Tâches</a>
228
+ <a data-view="audit">Audit</a>
229
+ <a data-view="keys">Comptes de service</a>
230
+ </div>
231
+ <div class="top-right">
232
+ <div class="health" id="health"><span class="dot up"></span><span id="health-txt">Opérationnel</span></div>
233
+ <button id="logout-btn" class="btn btn-xs">Quitter</button>
234
+ <div class="avatar" id="avatar">—</div>
235
+ </div>
236
+ </div>
237
+
238
+ <main>
239
+ <!-- Mission Control -->
240
+ <section id="view-overview">
241
+ <div class="kpis" id="kpis"></div>
242
+ <div class="cols wide-left">
243
+ <div class="panel" style="height:430px">
244
+ <div class="panel-head">
245
+ <h3>Agents connectés</h3><span class="count" id="ov-agent-count">—</span>
246
+ <div class="right"><button class="btn btn-xs" data-act="refresh">Rafraîchir</button></div>
247
+ </div>
248
+ <div class="scroll" style="flex:1">
249
+ <table>
250
+ <thead><tr><th>Agent</th><th>Statut</th><th>Rôles</th><th>Capacités</th><th>Tâches</th></tr></thead>
251
+ <tbody id="ov-agents"><tr><td colspan="5" class="empty">Chargement…</td></tr></tbody>
252
+ </table>
253
+ </div>
254
+ </div>
255
+ <div class="panel" style="height:430px">
256
+ <div class="panel-head">
257
+ <h3>Journal système</h3>
258
+ <div class="live"><span class="dot up"></span>LIVE</div>
259
+ <div class="right">
260
+ <select id="log-level" style="min-width:100px">
261
+ <option value="">Tous niveaux</option>
262
+ <option value="info">INFO</option>
263
+ <option value="task">TASK</option>
264
+ <option value="admin">ADMIN</option>
265
+ <option value="warn">WARN</option>
266
+ <option value="error">ERROR</option>
267
+ </select>
268
+ <button id="log-pause" class="btn btn-xs">❚❚</button>
269
+ <button class="btn btn-xs" data-act="clear-logs">🗑</button>
270
+ </div>
271
+ </div>
272
+ <div class="logs" id="logs">
273
+ <div class="empty">En attente d'événements…</div>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ </section>
278
+
279
+ <!-- Agents -->
280
+ <section id="view-agents" style="display:none">
281
+ <div class="panel">
282
+ <div class="panel-head">
283
+ <h3>Registre des agents</h3><span class="count" id="ag-meta">—</span>
284
+ <div class="right"><button class="btn btn-xs" data-act="refresh">Rafraîchir</button></div>
285
+ </div>
286
+ <div class="bar">
287
+ <input id="ag-search" placeholder="Filtrer par nom, capacité ou rôle…" style="max-width:340px">
288
+ <select id="ag-status"><option value="">Tous</option>
289
+ <option value="online">En ligne</option><option value="offline">Hors ligne</option></select>
290
+ </div>
291
+ <div class="scroll">
292
+ <table>
293
+ <thead><tr><th>Agent</th><th>Statut</th><th>Org</th><th>Rôles</th><th>Capacités</th>
294
+ <th>E2E</th><th>Auth</th><th>En attente</th><th></th></tr></thead>
295
+ <tbody id="ag-rows"><tr><td colspan="9" class="empty">Chargement…</td></tr></tbody>
296
+ </table>
297
+ </div>
298
+ <div class="pager" id="ag-pager"></div>
299
+ </div>
300
+ </section>
301
+
302
+ <!-- Tâches -->
303
+ <section id="view-tasks" style="display:none">
304
+ <div class="panel">
305
+ <div class="panel-head">
306
+ <h3>Tâches</h3><span class="count" id="tk-meta">—</span>
307
+ <div class="right"><button class="btn btn-xs" data-act="refresh">Rafraîchir</button></div>
308
+ </div>
309
+ <div class="bar">
310
+ <input id="tk-search" placeholder="Filtrer par titre ou exécutant…" style="max-width:340px">
311
+ <select id="tk-filter">
312
+ <option value="">Tous les statuts</option>
313
+ <option value="pending">En attente</option><option value="running">En cours</option>
314
+ <option value="completed">Terminées</option><option value="failed">Échouées</option>
315
+ </select>
316
+ </div>
317
+ <div class="scroll">
318
+ <table>
319
+ <thead><tr><th>Tâche</th><th>Statut</th><th>Orchestrateur</th><th>Exécutant</th>
320
+ <th>Créée</th><th>Mise à jour</th><th></th></tr></thead>
321
+ <tbody id="tk-rows"><tr><td colspan="7" class="empty">Chargement…</td></tr></tbody>
322
+ </table>
323
+ </div>
324
+ <div class="pager" id="tk-pager"></div>
325
+ </div>
326
+ </section>
327
+
328
+ <!-- Audit -->
329
+ <section id="view-audit" style="display:none">
330
+ <div id="au-banner"></div>
331
+ <div class="panel">
332
+ <div class="panel-head">
333
+ <h3>Journal d'audit</h3><span class="count" id="au-meta">—</span>
334
+ <div class="right">
335
+ <button class="btn btn-xs" data-act="verify">Vérifier l'intégrité</button>
336
+ <button class="btn btn-xs" data-act="refresh">Rafraîchir</button>
337
+ </div>
338
+ </div>
339
+ <div class="bar">
340
+ <input id="au-search" placeholder="Filtrer par événement ou auteur…" style="max-width:340px">
341
+ </div>
342
+ <div class="scroll">
343
+ <table>
344
+ <thead><tr><th>#</th><th>Horodatage</th><th>Événement</th><th>Auteur</th>
345
+ <th>Cible</th><th>Détail</th><th>Empreinte</th></tr></thead>
346
+ <tbody id="au-rows"><tr><td colspan="7" class="empty">Chargement…</td></tr></tbody>
347
+ </table>
348
+ </div>
349
+ <div class="pager" id="au-pager"></div>
350
+ </div>
351
+ </section>
352
+
353
+ <!-- Comptes de service -->
354
+ <section id="view-keys" style="display:none">
355
+ <div class="panel">
356
+ <div class="panel-head">
357
+ <h3>Comptes de service</h3><span class="count" id="kt-meta">—</span>
358
+ <div class="right">
359
+ <button class="btn btn-primary btn-xs" data-act="new-key">+ Nouvelle clé</button>
360
+ <button class="btn btn-xs" data-act="refresh">Rafraîchir</button>
361
+ </div>
362
+ </div>
363
+ <div class="scroll">
364
+ <table>
365
+ <thead><tr><th>Empreinte</th><th>Libellé</th><th>Organisation</th>
366
+ <th>Rôles</th><th>Permissions</th><th>Créée</th><th></th></tr></thead>
367
+ <tbody id="kt-rows"><tr><td colspan="7" class="empty">Chargement…</td></tr></tbody>
368
+ </table>
369
+ </div>
370
+ </div>
371
+ <div class="panel"><div style="padding:14px 15px;font-size:12px;color:var(--muted);line-height:1.7">
372
+ Le Hub ne conserve que l'empreinte SHA-256 de chaque clé. Il peut en vérifier
373
+ une, jamais la révéler — même si sa configuration fuit. Une clé perdue ne se
374
+ récupère pas : elle se révoque et se remplace.
375
+ </div></div>
376
+ </section>
377
+ </main>
378
+
379
+ <div class="statusbar">
380
+ <div class="cell">INTERMESH <b id="sb-version">—</b></div>
381
+ <div class="cell">ORG <b id="sb-org">—</b></div>
382
+ <div class="cell">ÉTAT <b id="sb-state">—</b></div>
383
+ <div class="cell">AUDIT <b id="sb-audit">—</b></div>
384
+ <div class="cell">FÉDÉRATION <b id="sb-peers">—</b></div>
385
+ <div class="cell">SESSION <b id="sb-as">—</b></div>
386
+ <div class="cell"><b id="sb-clock">—</b></div>
387
+ </div>
388
+ </div>
389
+
390
+ <!-- ============ MODALES ============ -->
391
+ <div id="modal-key" class="modal"><div class="modal-card">
392
+ <h3>Créer un compte de service</h3>
393
+ <div class="sub">La clé générée ne sera affichée qu'une seule fois.</div>
394
+ <div class="field"><label>Organisation</label><input id="nk-org" class="mono" placeholder="acme"></div>
395
+ <div class="field"><label>Libellé</label><input id="nk-label" placeholder="Pipeline de facturation"></div>
396
+ <div class="field"><label>Rôles (virgules)</label><input id="nk-roles" class="mono" value="service_account"></div>
397
+ <div class="field"><label>Permissions (virgules)</label><input id="nk-perms" class="mono" placeholder="compute:execute"></div>
398
+ <div class="modal-actions">
399
+ <button class="btn btn-xs" data-close>Annuler</button>
400
+ <button id="nk-create" class="btn btn-primary btn-xs">Créer</button>
401
+ </div>
402
+ </div></div>
403
+
404
+ <div id="modal-reveal" class="modal"><div class="modal-card">
405
+ <h3>Clé créée</h3><div class="sub">Copiez-la maintenant.</div>
406
+ <div class="reveal" id="rv-key"></div>
407
+ <div class="warn">Cette valeur ne sera plus jamais affichée. Le Hub n'en garde que
408
+ l'empreinte. Ne la capturez pas en image : une clé dans une capture d'écran est
409
+ une clé compromise.</div>
410
+ <div class="modal-actions">
411
+ <button id="rv-copy" class="btn btn-primary btn-xs">Copier</button>
412
+ <button class="btn btn-xs" data-close>Fermer</button>
413
+ </div>
414
+ </div></div>
415
+
416
+ <div id="modal-confirm" class="modal"><div class="modal-card">
417
+ <h3 id="cf-title">Confirmer</h3><div class="sub" id="cf-text"></div>
418
+ <div class="modal-actions">
419
+ <button class="btn btn-xs" data-close>Annuler</button>
420
+ <button id="cf-ok" class="btn btn-danger btn-xs">Confirmer</button>
421
+ </div>
422
+ </div></div>
423
+
424
+ <div id="toast" class="toast"></div>
425
+ <script src="app.js"></script>
426
+ </body>
427
+ </html>
@@ -140,7 +140,13 @@ class InterMeshStore:
140
140
  os.close(fd)
141
141
  else:
142
142
  os.chmod(self.path, 0o600)
143
- self._conn = sqlite3.connect(str(self.path))
143
+ # `timeout` laisse SQLite attendre le verrou d'écriture au lieu
144
+ # d'échouer aussitôt sur « database is locked ». Deux Hubs qui
145
+ # partagent un fichier se croisent au démarrage ; sans cela, la
146
+ # création du schéma se réglait par des réessais côté Python,
147
+ # bien plus lents que l'attente native — assez pour qu'un Hub ne
148
+ # soit pas encore à l'écoute quand on l'attend.
149
+ self._conn = sqlite3.connect(str(self.path), timeout=15.0)
144
150
  self.description = f"sqlite:{self.path}"
145
151
 
146
152
  self._create_schema()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intermesh
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Universal coordination protocol for AI agents — E2E encrypted, cross-language, federated
5
5
  Author: InterMesh Protocol Authors
6
6
  License: Apache-2.0
@@ -36,4 +36,7 @@ intermesh.egg-info/SOURCES.txt
36
36
  intermesh.egg-info/dependency_links.txt
37
37
  intermesh.egg-info/entry_points.txt
38
38
  intermesh.egg-info/requires.txt
39
- intermesh.egg-info/top_level.txt
39
+ intermesh.egg-info/top_level.txt
40
+ intermesh/console/__init__.py
41
+ intermesh/console/app.js
42
+ intermesh/console/index.html
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "intermesh"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Universal coordination protocol for AI agents — E2E encrypted, cross-language, federated"
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -59,3 +59,10 @@ intermesh = "intermesh.cli:main"
59
59
  [tool.setuptools.packages.find]
60
60
  where = ["."]
61
61
  include = ["intermesh*"]
62
+
63
+ # La console d'exploitation voyage avec le paquet. Sans cela, `intermesh
64
+ # console` servait un chemin codé en dur vers l'arborescence du
65
+ # développeur : la commande annonçait « actif » puis répondait 404 chez
66
+ # tout le monde sauf lui.
67
+ [tool.setuptools.package-data]
68
+ "intermesh.console" = ["*.html", "*.js", "*.css"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes