SPB Git

spb/admin-ka

Public
13.1 KB · 255 lines javascript
1// monitor.js — collecteur Écosystème Groupe KA2// Health checks HTTPS (45 s) + certificats TLS, sweep des nœuds (5 min :3// charge, RAM, disque, PM2, dernier commit), incidents, historique SQLite,4// push temps réel via le WebSocket du backend.5import https from 'node:https';6import path from 'node:path';7import { execFile } from 'node:child_process';8import { DatabaseSync } from 'node:sqlite';910// Sites du Groupe KA (source : cluster-deployments.json 2026-08-19)11export const SITES = [12  { app: 'lou-ka',    label: 'Lou·Ka',    domain: 'www.lou-ka.com',    node: 'M3U96b', pm2: 'lou-ka-web',  dir: '~/apps/lou-ka',    deployed: '2026-08' },13  { app: 'immo-ka',   label: 'Immo·Ka',   domain: 'www.immo-ka.com',   node: 'M4M64a', pm2: 'immo-ka-web', dir: '~/apps/immo-ka',   deployed: '2026-08' },14  { app: 'vrai-prix', label: 'Vrai-Prix', domain: 'www.vrai-prix.com', node: 'M3U96a', pm2: 'vrai-prix',   dir: '~/apps/vrai-prix', deployed: '2026-08' },15  { app: 'valoplex',  label: 'ValoPlex',  domain: 'www.valoplex.com',  node: 'M3U96a', pm2: 'valoplex',    dir: '~/apps/valoplex',  deployed: '2026-08' },16  { app: 'auto-ka',   label: 'Auto·Ka',   domain: 'www.auto-ka.com',   node: 'M4M64b', pm2: 'auto-ka-web', dir: '~/auto-ka',        deployed: '2026-08' },17  { app: 'fabri-ka',  label: 'Fabri·Ka',  domain: 'www.fabri-ka.com',  node: 'M4M64a', pm2: 'fabri-ka-web', dir: '~/fabri-ka',      deployed: '2026-08' },18  { app: 'food-ka',   label: 'Food·Ka',   domain: 'www.food-ka.com',   node: 'M4M64b', pm2: 'food-ka-web', dir: '~/apps/food-ka',   deployed: '2026-08' },19  { app: 'resto-ka',  label: 'Resto·Ka',  domain: 'www.resto-ka.com',  node: 'M3U96b', pm2: 'resto-ka',    dir: '~/apps/resto-ka',  deployed: '2026-08' },20  { app: 'sorti-ka',  label: 'Sorti·Ka',  domain: 'www.sorti-ka.com',  node: 'M3U96a', pm2: 'sorti-ka-web', dir: '~/apps/sorti-ka', deployed: '2026-08' },21  { app: 'crea-ka',   label: 'Créa·Ka',   domain: 'www.crea-ka.com',   node: 'M3U96b', pm2: 'crea-ka-web', dir: '~/apps/crea-ka',   deployed: '2026-08' },22  { app: 'job-ka',    label: 'Job·Ka',    domain: 'www.job-ka.com',    node: 'M3U96a', pm2: 'job-ka-web',  dir: '~/apps/job-ka',    deployed: '2026-08' },23  { app: 'trouve-ka', label: 'Trouve·Ka', domain: 'www.trouve-ka.com', node: 'M2M32',  pm2: 'tk-web',      dir: '~/trouve-ka',      deployed: '2026-08' },24  { app: 'groupe-ka', label: 'Groupe·Ka', domain: 'www.groupe-ka.com', node: 'M3U96b', pm2: 'groupe-ka',   dir: '~/apps/groupe-ka', deployed: '2026-08' },25];2627const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32'];28const SELF_NODE = 'M3U96a';29const CHECK_MS = 45_000;30const SWEEP_MS = 5 * 60_000;31const SLOW_MS = 4000;          // seuil "lent" (▲)32const FAILS_FOR_INCIDENT = 2;  // échecs consécutifs avant incident3334let db, emit;35const consecFails = {};3637export function startMonitor({ dataDir, onEvent }) {38  emit = onEvent || (() => {});39  db = new DatabaseSync(path.join(dataDir, 'eco.sqlite'));40  db.exec(`41    CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT);42    CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts);43    CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT);44    CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER);45    CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER);46    CREATE INDEX IF NOT EXISTS idx_nodestats ON nodestats(node, ts);47    CREATE TABLE IF NOT EXISTS appstats(app TEXT, ts INTEGER, cpu REAL, mem INTEGER, status TEXT, restarts INTEGER, last_commit INTEGER);48    CREATE INDEX IF NOT EXISTS idx_appstats ON appstats(app, ts);49  `);50  checkAll();51  sweepNodes();52  setInterval(checkAll, CHECK_MS);53  setInterval(sweepNodes, SWEEP_MS);54  setInterval(cleanup, 6 * 3600_000);55}5657// ---------- health checks ----------58function checkOne(site) {59  return new Promise((resolve) => {60    const t0 = Date.now();61    const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {62      const ms = Date.now() - t0;63      const cert = res.socket.getPeerCertificate?.();64      if (cert && cert.valid_to) {65        db.prepare('INSERT INTO cert(site,expires,issuer,checked) VALUES(?,?,?,?) ON CONFLICT(site) DO UPDATE SET expires=excluded.expires,issuer=excluded.issuer,checked=excluded.checked')66          .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || '', Date.now());67      }68      res.resume();69      resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null });70    });71    req.on('timeout', () => { req.destroy(new Error('timeout')); });72    req.on('error', (e) => resolve({ site: site.app, ts: Date.now(), ms: Date.now() - t0, code: 0, ok: 0, err: e.message.slice(0, 120) }));73  });74}7576async function checkAll() {77  const results = await Promise.all(SITES.map(checkOne));78  const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err) VALUES(?,?,?,?,?,?)');79  for (const r of results) {80    ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err);81    handleIncident(r);82  }83  emit({ type: 'eco', kind: 'checks', items: results });84}8586function handleIncident(r) {87  const open = db.prepare('SELECT id FROM incidents WHERE site=? AND ended IS NULL').get(r.site);88  if (!r.ok) {89    consecFails[r.site] = (consecFails[r.site] || 0) + 1;90    if (consecFails[r.site] >= FAILS_FOR_INCIDENT && !open) {91      db.prepare('INSERT INTO incidents(site,started,reason) VALUES(?,?,?)')92        .run(r.site, r.ts, r.err || `HTTP ${r.code}`);93      emit({ type: 'eco_alert', kind: 'down', site: r.site, reason: r.err || `HTTP ${r.code}`, ts: r.ts });94    }95  } else {96    consecFails[r.site] = 0;97    if (open) {98      db.prepare('UPDATE incidents SET ended=? WHERE id=?').run(r.ts, open.id);99      emit({ type: 'eco_alert', kind: 'up', site: r.site, ts: r.ts });100    }101  }102}103104// ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ----------105function sweepNode(node) {106  return new Promise((resolve) => {107    const gitCmds = SITES.filter(s => s.node === node)108      .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');109    const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH110echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)"111echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)"112echo "PAGESIZE $(sysctl -n hw.pagesize 2>/dev/null)"113vm_stat 2>/dev/null | egrep "active|wired|occupied" | tr -d '.'114echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')"115echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)"116${gitCmds}`;117    const args = node === SELF_NODE118      ? ['-c', script]119      : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script];120    const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';121    execFile(bin, args, { timeout: 45_000, maxBuffer: 8 * 1024 * 1024, env: { ...process.env, PATH: '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' } }, (err, stdout) => {122      if (err && !stdout) return resolve(null);123      try { parseSweep(node, stdout); } catch {}124      resolve(null);125    });126  });127}128129function parseSweep(node, out) {130  const ts = Date.now();131  const load = out.match(/^LOAD \{?\s*([\d.]+)/m);132  const memTotal = Number(out.match(/^MEMTOTAL (\d+)/m)?.[1] || 0);133  const pageSize = Number(out.match(/^PAGESIZE (\d+)/m)?.[1] || 16384);134  let pages = 0;135  for (const m of out.matchAll(/Pages (?:active|wired down|occupied by compressor):\s+(\d+)/g)) pages += Number(m[1]);136  const memUsed = pages * pageSize;137  const disk = Number(out.match(/^DISK (\d+)/m)?.[1] || 0);138  db.prepare('INSERT INTO nodestats(node,ts,load1,mem_used,mem_total,disk_pct) VALUES(?,?,?,?,?,?)')139    .run(node, ts, load ? Number(load[1]) : 0, memUsed, memTotal, disk);140141  const commits = {};142  for (const m of out.matchAll(/^GIT (\S+) (\d+)/gm)) commits[m[1]] = Number(m[2]) * 1000;143144  const pm2raw = out.match(/^PM2JSON (\[.*\])\s*$/m);145  const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)');146  if (pm2raw) {147    let list = [];148    try { list = JSON.parse(pm2raw[1]); } catch {}149    for (const site of SITES.filter(s => s.node === node)) {150      const p = list.find(x => x.name === site.pm2) || list.find(x => (x.name || '').startsWith(site.app));151      ins.run(site.app, ts,152        p ? (p.monit?.cpu ?? 0) : 0,153        p ? (p.monit?.memory ?? 0) : 0,154        p ? (p.pm2_env?.status || 'inconnu') : 'introuvable',155        p ? (p.pm2_env?.restart_time ?? 0) : 0,156        commits[site.app] || 0);157    }158  } else {159    for (const site of SITES.filter(s => s.node === node)) {160      ins.run(site.app, ts, 0, 0, 'inconnu', 0, commits[site.app] || 0);161    }162  }163}164165async function sweepNodes() {166  await Promise.all(NODES.map(sweepNode));167  emit({ type: 'eco', kind: 'sweep', ts: Date.now() });168}169170function cleanup() {171  const cut = Date.now() - 35 * 24 * 3600_000;172  db.prepare('DELETE FROM checks WHERE ts < ?').run(cut);173  db.prepare('DELETE FROM nodestats WHERE ts < ?').run(cut);174  db.prepare('DELETE FROM appstats WHERE ts < ?').run(cut);175}176177// ---------- requêtes ----------178function uptime(site, sinceMs) {179  const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);180  return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null;181}182function p95(site, sinceMs) {183  const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs);184  if (!rows.length) return null;185  return rows[Math.min(rows.length - 1, Math.floor(rows.length * 0.95))].ms;186}187188export function ecoSummary() {189  const out = [];190  for (const s of SITES) {191    const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app);192    const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse();193    const avg = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(s.app, Date.now() - 24 * 3600_000);194    const cert = db.prepare('SELECT expires FROM cert WHERE site=?').get(s.app);195    const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app);196    const stat = db.prepare('SELECT cpu,mem,status,restarts,last_commit,ts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);197    const nodeStat = db.prepare('SELECT load1,mem_used,mem_total,disk_pct,ts FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(s.node);198    out.push({199      app: s.app, label: s.label, domain: s.domain, node: s.node,200      last, spark,201      uptime24: uptime(s.app, 24 * 3600_000),202      uptime7: uptime(s.app, 7 * 24 * 3600_000),203      uptime30: uptime(s.app, 30 * 24 * 3600_000),204      avg24: avg?.a ? Math.round(avg.a) : null,205      p95_24: p95(s.app, 24 * 3600_000),206      certExpires: cert?.expires || null,207      incident: inc || null,208      proc: stat || null,209      nodeStat: nodeStat || null,210    });211  }212  return { sites: out, ts: Date.now() };213}214215export function ecoSite(app) {216  const s = SITES.find(x => x.app === app);217  if (!s) return null;218  const since = Date.now() - 24 * 3600_000;219  // ~200 seaux sur 24 h pour le graphique220  const bucket = Math.ceil((24 * 3600_000) / 200);221  const series = db.prepare(`222    SELECT (ts/${bucket})*${bucket} AS t, AVG(ms) ms, MIN(ok) ok, COUNT(*) n223    FROM checks WHERE site=? AND ts>? GROUP BY t ORDER BY t`).all(app, since);224  const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30')225    .all(app, Date.now() - 30 * 24 * 3600_000);226  const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app);227  // uptime quotidien 14 jours pour la barre228  const days = [];229  for (let i = 13; i >= 0; i--) {230    const d0 = new Date(); d0.setHours(0, 0, 0, 0);231    const start = d0.getTime() - i * 24 * 3600_000;232    const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000);233    days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null });234  }235  return { app, label: s.label, domain: s.domain, node: s.node, dir: s.dir, series, incidents, errors, days };236}237238export function ecoIncidents() {239  return db.prepare(`SELECT site,started,ended,reason FROM incidents ORDER BY started DESC LIMIT 50`).all();240}241242export function ecoNodes() {243  const out = [];244  for (const node of NODES) {245    const last = db.prepare('SELECT ts,load1,mem_used,mem_total,disk_pct FROM nodestats WHERE node=? ORDER BY ts DESC LIMIT 1').get(node);246    const hist = db.prepare('SELECT ts,load1,mem_used,mem_total FROM nodestats WHERE node=? AND ts>? ORDER BY ts').all(node, Date.now() - 24 * 3600_000);247    const apps = SITES.filter(s => s.node === node).map(s => {248      const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);249      return { app: s.app, label: s.label, ...p };250    });251    out.push({ node, last: last || null, hist, apps });252  }253  return { nodes: out, ts: Date.now() };254}255
Files · 27fcf70e4d95f96917c03d667658fae374d7bec3
Menu

Theme