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.
- {intermesh-0.4.2 → intermesh-0.4.3}/PKG-INFO +1 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/__init__.py +1 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/cli.py +27 -3
- intermesh-0.4.3/intermesh/console/__init__.py +0 -0
- intermesh-0.4.3/intermesh/console/app.js +601 -0
- intermesh-0.4.3/intermesh/console/index.html +427 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/store.py +7 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/PKG-INFO +1 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/SOURCES.txt +4 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/pyproject.toml +8 -1
- {intermesh-0.4.2 → intermesh-0.4.3}/README.md +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/adapters.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/admin.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/agent.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/apikeys.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/approval.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/audit.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/bridge.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/config.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/crypto.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/egress.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/escrow.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/guardrails.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/hardware.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/health.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/hub.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/identity.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/logger.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/message.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/metrics.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/peering.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/pipeline.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/policy.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/ratelimit.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/schema.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/secret.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/signing.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/snapshot.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh/task.py +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/dependency_links.txt +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/entry_points.txt +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/requires.txt +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/intermesh.egg-info/top_level.txt +0 -0
- {intermesh-0.4.2 → intermesh-0.4.3}/setup.cfg +0 -0
|
@@ -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
|
-
|
|
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=
|
|
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✓
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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()
|
|
@@ -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.
|
|
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
|
|
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
|