SPB Git forge

spb/admin-ka

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

Écosystème + chat : intégration des 3 agents gardiens KA Guardian (ka2/ka4/ka6)

- PROJECTS: ka2/ka4/ka6@M4M36 (repo commun ~/cluster-projects/ka-guardian) → modifiables via le chat
- monitor.js: 3 sites www.kaX.bot + nœud M4M36 au sweep + statut/cpu/ram via launchd (champ unit)
- actions admin: restart/stop/start launchctl + logs tail pour les sites launchd (M4M36 sans pm2)
- app.js: libellés pm2 → génériques

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent bfc9c8e

3 changed files +48 −15

modified public/app.js +4 −4
@@ -1214,7 +1214,7 @@
1214 1214 </div>
1215 1215 <div class="site-foot">
1216 1216 <span>${esc(s.node)}</span>
1217 ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">pm2 <b>${esc(s.proc.status)}</b></span>` : ''}
1217 + ${s.proc?.status ? `<span class="${s.proc.status === 'online' ? '' : 'foot-bad'}">proc <b>${esc(s.proc.status)}</b></span>` : ''}
1218 1218 ${s.proc?.last_commit ? `<span>commit <b>${timeAgo(s.proc.last_commit)}</b></span>` : ''}
1219 1219 ${s.certExpires ? `<span>SSL <b>${Math.max(0, Math.round((s.certExpires - Date.now()) / 86400000))} j</b></span>` : ''}
1220 1220 </div>`;
@@ -1286,7 +1286,7 @@
1286 1286 <h4>Service</h4>
1287 1287 <div class="kv"><span>Domaine</span><b>${esc(d.domain)}</b></div>
1288 1288 <div class="kv"><span>Nœud</span><b>${esc(d.node)}</b></div>
1289 <div class="kv"><span>PM2</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>
1289 + <div class="kv"><span>Processus</span><b>${esc(s.proc?.status || '—')}${s.proc?.restarts ? ' · ' + s.proc.restarts + ' restarts' : ''}</b></div>
1290 1290 <div class="kv"><span>CPU / RAM process</span><b>${s.proc ? s.proc.cpu + '% / ' + fmtMB(s.proc.mem) : '—'}</b></div>
1291 1291 <div class="kv"><span>Charge nœud</span><b>${s.nodeStat ? s.nodeStat.load1 + ' · RAM ' + Math.round(s.nodeStat.mem_used / s.nodeStat.mem_total * 100) + '% · disque ' + s.nodeStat.disk_pct + '%' : '—'}</b></div>
1292 1292 <div class="kv"><span>Dernier commit</span><b>${s.proc?.last_commit ? timeAgo(s.proc.last_commit) : '—'}</b></div>
@@ -1301,7 +1301,7 @@
1301 1301 <div class="sheet-sec">
1302 1302 <h4>Actions d'administration</h4>
1303 1303 <div class="admin-actions">
1304 <button class="btn-ghost" id="aRestart">↻ Redémarrer PM2</button>
1304 + <button class="btn-ghost" id="aRestart">↻ Redémarrer</button>
1305 1305 <button class="btn-ghost" id="aLogs">📜 Logs récents</button>
1306 1306 <button class="btn-ghost" id="aCommits">⌥ Git & commits</button>
1307 1307 <a class="btn-ghost" href="https://${esc(d.domain)}" target="_blank" rel="noopener">↗ Ouvrir le site</a>
@@ -1318,7 +1318,7 @@
1318 1318 const out = () => $('aOut');
1319 1319 const showOut = (html) => { out().classList.remove('hidden'); out().innerHTML = html; };
1320 1320 $('aRestart').onclick = async () => {
1321 if (!confirm(`Redémarrer ${d.label} (pm2 restart) sur ${d.node} ?`)) return;
1321 + if (!confirm(`Redémarrer ${d.label} sur ${d.node} ?`)) return;
1322 1322 showOut('<div class="admin-load">redémarrage…</div>');
1323 1323 const r = await fetch('/api/admin/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app: d.app, action: 'restart' }) });
1324 1324 const j = await r.json();
modified server/monitor.js +24 −8
@@ -22,9 +22,13 @@ export const SITES = [
22 22 { app: 'job-ka', label: 'Job·Ka', domain: 'www.job-ka.com', node: 'M3U96a', pm2: 'job-ka-web', dir: '~/apps/job-ka', deployed: '2026-08' },
23 23 { app: 'trouve-ka', label: 'Trouve·Ka', domain: 'www.trouve-ka.com', node: 'M2M32', pm2: 'tk-web', dir: '~/trouve-ka', deployed: '2026-08' },
24 24 { app: 'groupe-ka', label: 'Groupe·Ka', domain: 'www.groupe-ka.com', node: 'M3U96b', pm2: 'groupe-ka', dir: '~/apps/groupe-ka', deployed: '2026-08' },
25 + // Agents gardiens KA Guardian — un seul repo sur M4M36, process launchd (unit), pas de pm2 sur ce nœud
26 + { app: 'ka2', label: 'KA·2 Guardian', domain: 'www.ka2.bot', node: 'M4M36', unit: 'com.ka2.guardian', log: '~/cluster-projects/ka-guardian/logs/ka2.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' },
27 + { app: 'ka4', label: 'KA·4 Guardian', domain: 'www.ka4.bot', node: 'M4M36', unit: 'com.ka4.guardian', log: '~/cluster-projects/ka-guardian/logs/ka4.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' },
28 + { app: 'ka6', label: 'KA·6 Guardian', domain: 'www.ka6.bot', node: 'M4M36', unit: 'com.ka6.guardian', log: '~/cluster-projects/ka-guardian/logs/ka6.log', dir: '~/cluster-projects/ka-guardian', deployed: '2026-08' },
25 29 ];
26 30
27 const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32'];
31 +const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32', 'M4M36'];
28 32 const SELF_NODE = 'M3U96a';
29 33 const CHECK_MS = 45_000;
30 34 const SWEEP_MS = 5 * 60_000;
@@ -155,6 +159,9 @@ function sweepNode(node) {
155 159 return new Promise((resolve) => {
156 160 const gitCmds = SITES.filter(s => s.node === node)
157 161 .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');
162 + // sites sous launchd (KA Guardian) : PID + cpu/rss via ps
163 + const lchCmds = SITES.filter(s => s.node === node && s.unit)
164 + .map(s => `pid=$(launchctl list | awk '$3=="${s.unit}"{print $1}'); [ -z "$pid" ] && pid=-; echo "LCH ${s.app} $pid $(ps -o %cpu=,rss= -p $pid 2>/dev/null | tr -s ' ')"`).join('; ');
158 165 const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
159 166 echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)"
160 167 echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)"
@@ -162,6 +169,7 @@ echo "PAGESIZE $(sysctl -n hw.pagesize 2>/dev/null)"
162 169 vm_stat 2>/dev/null | egrep "active|wired|occupied" | tr -d '.'
163 170 echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')"
164 171 echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)"
172 +${lchCmds}
165 173 ${gitCmds}`;
166 174 const args = node === SELF_NODE
167 175 ? ['-c', script]
@@ -191,11 +199,21 @@ function parseSweep(node, out) {
191 199 for (const m of out.matchAll(/^GIT (\S+) (\d+)/gm)) commits[m[1]] = Number(m[2]) * 1000;
192 200
193 201 const pm2raw = out.match(/^PM2JSON (\[.*\])\s*$/m);
202 + let list = [];
203 + if (pm2raw) { try { list = JSON.parse(pm2raw[1]); } catch {} }
204 + // sites launchd : "LCH <app> <pid|-> [<%cpu> <rss Ko>]"
205 + const lch = {};
206 + for (const m of out.matchAll(/^LCH (\S+) (\S+) ?([\d.]+)? ?(\d+)?/gm)) {
207 + lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) };
208 + }
194 209 const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)');
195 if (pm2raw) {
196 let list = [];
197 try { list = JSON.parse(pm2raw[1]); } catch {}
198 for (const site of SITES.filter(s => s.node === node)) {
210 + for (const site of SITES.filter(s => s.node === node)) {
211 + if (site.unit) {
212 + const l = lch[site.app];
213 + const up = l && l.pid !== '-' && /^\d+$/.test(l.pid);
214 + ins.run(site.app, ts, l ? l.cpu : 0, l ? l.rss * 1024 : 0,
215 + l ? (up ? 'online' : 'arrêté') : 'inconnu', 0, commits[site.app] || 0);
216 + } else if (pm2raw) {
199 217 const p = list.find(x => x.name === site.pm2) || list.find(x => (x.name || '').startsWith(site.app));
200 218 ins.run(site.app, ts,
201 219 p ? (p.monit?.cpu ?? 0) : 0,
@@ -203,9 +221,7 @@ function parseSweep(node, out) {
203 221 p ? (p.pm2_env?.status || 'inconnu') : 'introuvable',
204 222 p ? (p.pm2_env?.restart_time ?? 0) : 0,
205 223 commits[site.app] || 0);
206 }
207 } else {
208 for (const site of SITES.filter(s => s.node === node)) {
224 + } else {
209 225 ins.run(site.app, ts, 0, 0, 'inconnu', 0, commits[site.app] || 0);
210 226 }
211 227 }
modified server/server.js +20 −3
@@ -60,7 +60,7 @@ const UPGRADER_MODEL = config.upgraderModel || 'claude-opus-4-8';
60 60 const KA_KNOWLEDGE = `Tu es l'"upgradeur de prompt" de la console administration-ka.com. Ta seule tâche : réécrire la demande brute de l'utilisateur en un prompt EXCELLENT destiné à Claude Code, qui s'exécute sur le nœud d'une app du Groupe KA. Tu ne réponds jamais à la demande toi-même, tu ne codes pas : tu produis UNIQUEMENT le prompt amélioré, prêt à être envoyé.
61 61
62 62 # Écosystème Groupe KA (12 apps web, stack Node/React, cluster MacLustr)
63 - lou-ka (lou-ka.com) : location immobilière/logements. immo-ka (immo-ka.com) : achat/vente immobilier. vrai-prix (vrai-prix.com) : comparateur de prix. valoplex : immobilier/valorisation. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. groupe-ka.com : hub + KA ID (SSO). Marché cible : Québec (fr-CA).
63 +- lou-ka (lou-ka.com) : location immobilière/logements. immo-ka (immo-ka.com) : achat/vente immobilier. vrai-prix (vrai-prix.com) : comparateur de prix. valoplex : immobilier/valorisation. auto-ka (auto-ka.com) : véhicules. fabri-ka (fabri-ka.com) : fabricants/artisans/friperies. food-ka (food-ka.com) : alimentation/épicerie. resto-ka (resto-ka.com) : restaurants. sorti-ka (sorti-ka.com) : sorties/événements. crea-ka (crea-ka.com) : créateurs de contenu QC (multi-plateforme). job-ka (job-ka.com) : emplois. trouve-ka (trouve-ka.com) : recherche/annuaire. groupe-ka.com : hub + KA ID (SSO). ka2/ka4/ka6 (ka2.bot/ka4.bot/ka6.bot) : agents gardiens autonomes des connecteurs (KA Guardian — un seul repo Python/FastAPI sur M4M36, process launchd com.kaX.guardian, PAS pm2). Marché cible : Québec (fr-CA).
64 64 - Architecture commune : back Node (souvent Express/Fastify), front React/Next ou Vite, données via connecteurs (scrapers) + SQLite/Postgres, PM2 pour les process, ngrok pour les domaines. Beaucoup d'apps ont un backend "sync"/crawler et une couche qualité (published/quarantaine).
65 65
66 66 # Conventions NON négociables (rappelle-les dans le prompt quand c'est pertinent)
@@ -220,6 +220,10 @@ const PROJECTS = [
220 220 { id: 'crea-ka@M3U96b', name: 'Créa·Ka', node: 'M3U96b', dir: '~/apps/crea-ka' },
221 221 { id: 'job-ka@M3U96a', name: 'Job·Ka', node: null, dir: path.join(HOME, 'apps', 'job-ka') },
222 222 { id: 'trouve-ka@M2M32', name: 'Trouve·Ka', node: 'M2M32', dir: '~/trouve-ka' },
223 + // Agents gardiens KA Guardian — 3 sites, 1 seul repo sur M4M36 (launchd, pas pm2)
224 + { id: 'ka2@M4M36', name: 'KA·2 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
225 + { id: 'ka4@M4M36', name: 'KA·4 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
226 + { id: 'ka6@M4M36', name: 'KA·6 Guardian', node: 'M4M36', dir: '~/cluster-projects/ka-guardian' },
223 227 ];
224 228 // Orchestrateur : une seule session sur M3U96a qui pilote les 13 apps via SSH,
225 229 // en gardant le contexte complet de la tâche.
@@ -661,13 +665,26 @@ const server = http.createServer(async (req, res) => {
661 665 const action = ['restart', 'stop', 'start'].includes(body.action) ? body.action : null;
662 666 if (!site || !action) return json(res, 400, { error: 'app ou action invalide' });
663 667 audit({ kind: 'admin_action', app: site.app, node: site.node, action, ip: clientIp(req) });
664 const out = await runOnNode(site.node, `pm2 ${action} ${site.pm2} && pm2 jlist | python3 -c "import json,sys; l=json.load(sys.stdin); p=[x for x in l if x['name']=='${site.pm2}']; print(p[0]['pm2_env']['status'] if p else 'introuvable')"`);
668 + let out;
669 + if (site.unit) {
670 + // sites sous launchd (KA Guardian sur M4M36) — pas de pm2 sur ce nœud
671 + const cmds = {
672 + restart: `launchctl kickstart -k gui/$(id -u)/${site.unit}`,
673 + stop: `launchctl unload ~/Library/LaunchAgents/${site.unit}.plist`,
674 + start: `launchctl load ~/Library/LaunchAgents/${site.unit}.plist`,
675 + };
676 + out = await runOnNode(site.node, `${cmds[action]} && sleep 1 && (launchctl list | awk '$3=="${site.unit}"{print ($1=="-")?"arrêté":"online"; f=1} END{if(!f)print "déchargé"}')`);
677 + } else {
678 + out = await runOnNode(site.node, `pm2 ${action} ${site.pm2} && pm2 jlist | python3 -c "import json,sys; l=json.load(sys.stdin); p=[x for x in l if x['name']=='${site.pm2}']; print(p[0]['pm2_env']['status'] if p else 'introuvable')"`);
679 + }
665 680 return json(res, 200, { ok: !out.error, output: (out.stdout || out.error || '').trim().split('\n').pop(), full: (out.stdout || '').slice(-1500) });
666 681 }
667 682 if (p.startsWith('/api/admin/logs/')) {
668 683 const site = SITES.find(s => s.app === p.split('/')[4]);
669 684 if (!site) return json(res, 404, { error: 'site inconnu' });
670 const out = await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`);
685 + const out = site.log
686 + ? await runOnNode(site.node, `tail -70 ${site.log} 2>&1`)
687 + : await runOnNode(site.node, `pm2 logs ${site.pm2} --nostream --lines 60 2>&1 | tail -70`);
671 688 return json(res, 200, { logs: out.stdout || out.error || '(vide)' });
672 689 }
673 690 if (p.startsWith('/api/admin/commits/')) {
674 691