SPB Git

spb/admin-ka

Public
21.8 KB · 398 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: 'house-ka',  label: 'House·Ka',  domain: 'www.house-ka.com',  node: 'M4M64b', pm2: 'house-ka-web', dir: '~/apps/house-ka', deployed: '2026-08' },15  { app: 'rent-ka',   label: 'Rent·Ka',   domain: 'www.rent-ka.com',   node: 'M4M36',  pm2: 'rent-ka-web', dir: '~/apps/rent-ka',  deployed: '2026-08' },16  { app: 'vrai-prix', label: 'Vrai-Prix', domain: 'www.vrai-prix.com', node: 'M3U96a', pm2: 'vrai-prix',   dir: '~/apps/vrai-prix', deployed: '2026-08' },17  { app: 'valoplex',  label: 'ValoPlex',  domain: 'www.valoplex.com',  node: 'M3U96a', pm2: 'valoplex',    dir: '~/apps/valoplex',  deployed: '2026-08' },18  { app: 'auto-ka',   label: 'Auto·Ka',   domain: 'www.auto-ka.com',   node: 'M4M64b', pm2: 'auto-ka-web', dir: '~/auto-ka',        deployed: '2026-08' },19  { app: 'fabri-ka',  label: 'Fabri·Ka',  domain: 'www.fabri-ka.com',  node: 'M4M64a', pm2: 'fabri-ka-web', dir: '~/fabri-ka',      deployed: '2026-08' },20  { app: 'food-ka',   label: 'Food·Ka',   domain: 'www.food-ka.com',   node: 'M4M64b', pm2: 'food-ka-web', dir: '~/apps/food-ka',   deployed: '2026-08' },21  { app: 'resto-ka',  label: 'Resto·Ka',  domain: 'www.resto-ka.com',  node: 'M3U96b', pm2: 'resto-ka',    dir: '~/apps/resto-ka',  deployed: '2026-08' },22  { app: 'sorti-ka',  label: 'Sorti·Ka',  domain: 'www.sorti-ka.com',  node: 'M3U96a', pm2: 'sorti-ka-web', dir: '~/apps/sorti-ka', deployed: '2026-08' },23  { 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' },24  { app: 'job-ka',    label: 'Job·Ka',    domain: 'www.job-ka.com',    node: 'M3U96a', pm2: 'job-ka-web',  dir: '~/apps/job-ka',    deployed: '2026-08' },25  { app: 'trouve-ka', label: 'Trouve·Ka', domain: 'www.trouve-ka.com', node: 'M2M32',  pm2: 'tk-web',      dir: '~/trouve-ka',      deployed: '2026-08' },26  { app: 'groupe-ka', label: 'Groupe·Ka', domain: 'www.groupe-ka.com', node: 'M3U96b', pm2: 'groupe-ka',   dir: '~/apps/groupe-ka', deployed: '2026-08' },27  { app: 'ka-stats',  label: 'Ka·Stats',  domain: 'www.ka-stats.com',  node: 'M1M32',  pm2: 'ka-stats',    dir: '~/apps/ka-stats',  deployed: '2026-08' },28  // Agents gardiens KA Guardian — un seul repo sur M4M36, process launchd (unit), pas de pm2 sur ce nœud29  { 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' },30  { 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' },31  { 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' },32];3334const NODES = ['M3U96a', 'M3U96b', 'M4M64a', 'M4M64b', 'M2M32', 'M4M36', 'M1M32'];35const SELF_NODE = 'M3U96a';36const CHECK_MS = 45_000;37const SWEEP_MS = 5 * 60_000;38const SLOW_MS = 4000;          // seuil "lent" (▲)39const FAILS_FOR_INCIDENT = 2;  // échecs consécutifs avant incident4041let db, emit;42const consecFails = {};4344// ---------- logos des sites (favicon/apple-touch-icon, mis en cache) ----------45const ICONS = {}; // app -> { buf: Buffer, type: string }46const ICON_CANDIDATES = ['/apple-touch-icon.png', '/apple-touch-icon-precomposed.png', '/favicon.svg', '/icon.svg', '/favicon.png', '/favicon.ico'];47function fetchUrl(host, pathname) {48  return new Promise((resolve) => {49    const req = https.get({ host, path: pathname, timeout: 10_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {50      if (res.statusCode !== 200) { res.resume(); return resolve(null); }51      const ct = res.headers['content-type'] || '';52      if (!/image|svg/.test(ct)) { res.resume(); return resolve(null); }53      const chunks = [];54      let size = 0;55      res.on('data', (c) => { chunks.push(c); size += c.length; if (size > 500_000) { req.destroy(); resolve(null); } });56      res.on('end', () => resolve({ buf: Buffer.concat(chunks), type: ct.split(';')[0] }));57    });58    req.on('timeout', () => req.destroy());59    req.on('error', () => resolve(null));60  });61}62async function fetchIconFor(site) {63  // 1) essaie les chemins standards ; 2) sinon parse le HTML pour <link rel=icon>64  for (const cand of ICON_CANDIDATES) {65    const r = await fetchUrl(site.domain, cand);66    if (r) { ICONS[site.app] = r; return; }67  }68  const html = await new Promise((resolve) => {69    https.get({ host: site.domain, path: '/', timeout: 10_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {70      let d = ''; res.on('data', (c) => { d += c; if (d.length > 200_000) res.destroy(); }); res.on('end', () => resolve(d));71    }).on('error', () => resolve('')).on('timeout', function () { this.destroy(); resolve(''); });72  });73  const m = html.match(/<link[^>]+rel=["'][^"']*(?:apple-touch-icon|icon)[^"']*["'][^>]*href=["']([^"']+)["']/i)74         || html.match(/<link[^>]+href=["']([^"']+)["'][^>]*rel=["'][^"']*icon[^"']*["']/i);75  if (m) {76    let href = m[1];77    let pathname = href.startsWith('http') ? new URL(href).pathname : (href.startsWith('/') ? href : '/' + href);78    const r = await fetchUrl(site.domain, pathname);79    if (r) ICONS[site.app] = r;80  }81}82async function fetchAllIcons() {83  await Promise.all(SITES.map(s => fetchIconFor(s).catch(() => {})));84  emit({ type: 'eco', kind: 'icons', ts: Date.now() });85}86export function getIcon(app) { return ICONS[app] || null; }8788export function startMonitor({ dataDir, onEvent }) {89  emit = onEvent || (() => {});90  db = new DatabaseSync(path.join(dataDir, 'eco.sqlite'));91  db.exec(`92    CREATE TABLE IF NOT EXISTS checks(site TEXT, ts INTEGER, ms INTEGER, code INTEGER, ok INTEGER, err TEXT);93    CREATE INDEX IF NOT EXISTS idx_checks ON checks(site, ts);`);94  try { db.exec('ALTER TABLE checks ADD COLUMN bytes INTEGER DEFAULT 0'); } catch {}95  db.exec(`96    CREATE TABLE IF NOT EXISTS incidents(id INTEGER PRIMARY KEY AUTOINCREMENT, site TEXT, started INTEGER, ended INTEGER, reason TEXT);97    CREATE TABLE IF NOT EXISTS cert(site TEXT PRIMARY KEY, expires INTEGER, issuer TEXT, checked INTEGER);98    CREATE TABLE IF NOT EXISTS nodestats(node TEXT, ts INTEGER, load1 REAL, mem_used INTEGER, mem_total INTEGER, disk_pct INTEGER);99    CREATE INDEX IF NOT EXISTS idx_nodestats ON nodestats(node, ts);100    CREATE TABLE IF NOT EXISTS appstats(app TEXT, ts INTEGER, cpu REAL, mem INTEGER, status TEXT, restarts INTEGER, last_commit INTEGER);101    CREATE INDEX IF NOT EXISTS idx_appstats ON appstats(app, ts);102  `);103  checkAll();104  sweepNodes();105  fetchAllIcons();106  setInterval(checkAll, CHECK_MS);107  setInterval(sweepNodes, SWEEP_MS);108  setInterval(fetchAllIcons, 7 * 24 * 3600_000);109  setInterval(cleanup, 6 * 3600_000);110}111112// ---------- health checks ----------113function checkOne(site) {114  return new Promise((resolve) => {115    const t0 = Date.now();116    const req = https.get({ host: site.domain, path: '/', timeout: 12_000, headers: { 'User-Agent': 'admin-ka-monitor/2.0' } }, (res) => {117      const ms = Date.now() - t0; // ≈ TTFB (réponse reçue avant le corps)118      const cert = res.socket.getPeerCertificate?.();119      if (cert && cert.valid_to) {120        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')121          .run(site.app, Date.parse(cert.valid_to), cert.issuer?.O || cert.issuer?.CN || '', Date.now());122      }123      const bytes = Number(res.headers['content-length'] || 0);124      res.resume();125      resolve({ site: site.app, ts: Date.now(), ms, code: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 400 ? 1 : 0, err: null, bytes });126    });127    req.on('timeout', () => { req.destroy(new Error('timeout')); });128    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), bytes: 0 }));129  });130}131132async function checkAll() {133  const results = await Promise.all(SITES.map(checkOne));134  const ins = db.prepare('INSERT INTO checks(site,ts,ms,code,ok,err,bytes) VALUES(?,?,?,?,?,?,?)');135  for (const r of results) {136    ins.run(r.site, r.ts, r.ms, r.code, r.ok, r.err, r.bytes || 0);137    handleIncident(r);138  }139  emit({ type: 'eco', kind: 'checks', items: results });140}141142function handleIncident(r) {143  const open = db.prepare('SELECT id FROM incidents WHERE site=? AND ended IS NULL').get(r.site);144  if (!r.ok) {145    consecFails[r.site] = (consecFails[r.site] || 0) + 1;146    if (consecFails[r.site] >= FAILS_FOR_INCIDENT && !open) {147      db.prepare('INSERT INTO incidents(site,started,reason) VALUES(?,?,?)')148        .run(r.site, r.ts, r.err || `HTTP ${r.code}`);149      emit({ type: 'eco_alert', kind: 'down', site: r.site, reason: r.err || `HTTP ${r.code}`, ts: r.ts });150    }151  } else {152    consecFails[r.site] = 0;153    if (open) {154      db.prepare('UPDATE incidents SET ended=? WHERE id=?').run(r.ts, open.id);155      emit({ type: 'eco_alert', kind: 'up', site: r.site, ts: r.ts });156    }157  }158}159160// ---------- sweep des nœuds (charge, RAM, disque, PM2, dernier commit) ----------161function sweepNode(node) {162  return new Promise((resolve) => {163    const gitCmds = SITES.filter(s => s.node === node)164      .map(s => `echo "GIT ${s.app} $(git -C ${s.dir} log -1 --format=%ct 2>/dev/null || echo 0)"`).join('; ');165    // sites sous launchd (KA Guardian) : PID + cpu/rss via ps166    const lchCmds = SITES.filter(s => s.node === node && s.unit)167      .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('; ');168    const script = `export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH169echo "LOAD $(sysctl -n vm.loadavg 2>/dev/null)"170echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)"171echo "PAGESIZE $(sysctl -n hw.pagesize 2>/dev/null)"172vm_stat 2>/dev/null | egrep "active|wired|occupied" | tr -d '.'173echo "DISK $(df -k / | tail -1 | awk '{print $5}' | tr -d '%')"174echo "PM2JSON $(pm2 jlist 2>/dev/null | tail -1)"175${lchCmds}176${gitCmds}`;177    const args = node === SELF_NODE178      ? ['-c', script]179      : ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', node, script];180    const bin = node === SELF_NODE ? '/bin/bash' : 'ssh';181    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) => {182      if (err && !stdout) return resolve(null);183      try { parseSweep(node, stdout); } catch {}184      resolve(null);185    });186  });187}188189function parseSweep(node, out) {190  const ts = Date.now();191  const load = out.match(/^LOAD \{?\s*([\d.]+)/m);192  const memTotal = Number(out.match(/^MEMTOTAL (\d+)/m)?.[1] || 0);193  const pageSize = Number(out.match(/^PAGESIZE (\d+)/m)?.[1] || 16384);194  let pages = 0;195  for (const m of out.matchAll(/Pages (?:active|wired down|occupied by compressor):\s+(\d+)/g)) pages += Number(m[1]);196  const memUsed = pages * pageSize;197  const disk = Number(out.match(/^DISK (\d+)/m)?.[1] || 0);198  db.prepare('INSERT INTO nodestats(node,ts,load1,mem_used,mem_total,disk_pct) VALUES(?,?,?,?,?,?)')199    .run(node, ts, load ? Number(load[1]) : 0, memUsed, memTotal, disk);200201  const commits = {};202  for (const m of out.matchAll(/^GIT (\S+) (\d+)/gm)) commits[m[1]] = Number(m[2]) * 1000;203204  const pm2raw = out.match(/^PM2JSON (\[.*\])\s*$/m);205  let list = [];206  if (pm2raw) { try { list = JSON.parse(pm2raw[1]); } catch {} }207  // sites launchd : "LCH <app> <pid|-> [<%cpu> <rss Ko>]"208  const lch = {};209  for (const m of out.matchAll(/^LCH (\S+) (\S+) ?([\d.]+)? ?(\d+)?/gm)) {210    lch[m[1]] = { pid: m[2], cpu: Number(m[3] || 0), rss: Number(m[4] || 0) };211  }212  const ins = db.prepare('INSERT INTO appstats(app,ts,cpu,mem,status,restarts,last_commit) VALUES(?,?,?,?,?,?,?)');213  for (const site of SITES.filter(s => s.node === node)) {214    if (site.unit) {215      const l = lch[site.app];216      const up = l && l.pid !== '-' && /^\d+$/.test(l.pid);217      ins.run(site.app, ts, l ? l.cpu : 0, l ? l.rss * 1024 : 0,218        l ? (up ? 'online' : 'arrêté') : 'inconnu', 0, commits[site.app] || 0);219    } else if (pm2raw) {220      const p = list.find(x => x.name === site.pm2) || list.find(x => (x.name || '').startsWith(site.app));221      ins.run(site.app, ts,222        p ? (p.monit?.cpu ?? 0) : 0,223        p ? (p.monit?.memory ?? 0) : 0,224        p ? (p.pm2_env?.status || 'inconnu') : 'introuvable',225        p ? (p.pm2_env?.restart_time ?? 0) : 0,226        commits[site.app] || 0);227    } else {228      ins.run(site.app, ts, 0, 0, 'inconnu', 0, commits[site.app] || 0);229    }230  }231}232233async function sweepNodes() {234  await Promise.all(NODES.map(sweepNode));235  emit({ type: 'eco', kind: 'sweep', ts: Date.now() });236}237238function cleanup() {239  const cut = Date.now() - 35 * 24 * 3600_000;240  db.prepare('DELETE FROM checks WHERE ts < ?').run(cut);241  db.prepare('DELETE FROM nodestats WHERE ts < ?').run(cut);242  db.prepare('DELETE FROM appstats WHERE ts < ?').run(cut);243}244245// ---------- requêtes ----------246function uptime(site, sinceMs) {247  const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);248  return r.n ? Math.round((r.up / r.n) * 1000) / 10 : null;249}250function pct(sorted, p) {251  if (!sorted.length) return null;252  return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))];253}254function p95(site, sinceMs) {255  const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs);256  return pct(rows.map(r => r.ms), 0.95);257}258// paquet de latence : min/p50/p90/p95/p99/max/avg sur une fenêtre259function latencyStats(site, sinceMs) {260  const rows = db.prepare('SELECT ms FROM checks WHERE site=? AND ts>? AND ok=1 ORDER BY ms').all(site, Date.now() - sinceMs).map(r => r.ms);261  if (!rows.length) return { min: null, p50: null, p90: null, p95: null, p99: null, max: null, avg: null, n: 0 };262  const sum = rows.reduce((a, b) => a + b, 0);263  return { min: rows[0], p50: pct(rows, 0.5), p90: pct(rows, 0.9), p95: pct(rows, 0.95), p99: pct(rows, 0.99), max: rows[rows.length - 1], avg: Math.round(sum / rows.length), n: rows.length };264}265// distribution des codes HTTP sur 24h266function statusDist(site, sinceMs) {267  const rows = db.prepare('SELECT code, COUNT(*) n FROM checks WHERE site=? AND ts>? GROUP BY code').all(site, Date.now() - sinceMs);268  const d = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, err: 0 };269  for (const r of rows) {270    if (!r.code) d.err += r.n;271    else if (r.code < 300) d['2xx'] += r.n;272    else if (r.code < 400) d['3xx'] += r.n;273    else if (r.code < 500) d['4xx'] += r.n;274    else d['5xx'] += r.n;275  }276  return d;277}278// tendance latence : moyenne dernière heure vs 24h (pour flèche ↑/↓)279function latencyTrend(site) {280  const now = Date.now();281  const h = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(site, now - 3600_000)?.a;282  const d = db.prepare('SELECT AVG(ms) a FROM checks WHERE site=? AND ok=1 AND ts>?').get(site, now - 24 * 3600_000)?.a;283  if (!h || !d) return null;284  return Math.round((h - d) / d * 100); // % (négatif = plus rapide)285}286function checkCounts(site, sinceMs) {287  const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up, AVG(bytes) b FROM checks WHERE site=? AND ts>?').get(site, Date.now() - sinceMs);288  return { total: r.n || 0, ok: r.up || 0, fail: (r.n || 0) - (r.up || 0), avgBytes: r.b ? Math.round(r.b) : 0 };289}290// incidents + MTTR (temps moyen de rétablissement) sur 30 j291function incidentStats(site) {292  const rows = db.prepare('SELECT started, ended FROM incidents WHERE site=? AND started>?').all(site, Date.now() - 30 * 24 * 3600_000);293  const closed = rows.filter(r => r.ended);294  const mttr = closed.length ? Math.round(closed.reduce((a, r) => a + (r.ended - r.started), 0) / closed.length / 60000) : null;295  const longest = rows.length ? Math.round(Math.max(...rows.map(r => (r.ended || Date.now()) - r.started)) / 60000) : 0;296  return { count30: rows.length, open: rows.some(r => !r.ended), mttrMin: mttr, longestMin: longest };297}298299export function ecoSummary() {300  const out = [];301  for (const s of SITES) {302    const last = db.prepare('SELECT ts,ms,code,ok,err FROM checks WHERE site=? ORDER BY ts DESC LIMIT 1').get(s.app);303    const spark = db.prepare('SELECT ts,ms,ok FROM checks WHERE site=? ORDER BY ts DESC LIMIT 40').all(s.app).reverse();304    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);305    const cert = db.prepare('SELECT expires FROM cert WHERE site=?').get(s.app);306    const inc = db.prepare('SELECT started,reason FROM incidents WHERE site=? AND ended IS NULL').get(s.app);307    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);308    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);309    const lat = latencyStats(s.app, 24 * 3600_000);310    const cnt = checkCounts(s.app, 24 * 3600_000);311    out.push({312      app: s.app, label: s.label, domain: s.domain, node: s.node,313      last, spark,314      uptime24: uptime(s.app, 24 * 3600_000),315      uptime7: uptime(s.app, 7 * 24 * 3600_000),316      uptime30: uptime(s.app, 30 * 24 * 3600_000),317      avg24: lat.avg,318      p50_24: lat.p50, p95_24: lat.p95, p99_24: lat.p99, min24: lat.min, max24: lat.max,319      checks24: cnt.total, fails24: cnt.fail, avgBytes24: cnt.avgBytes,320      trend: latencyTrend(s.app),321      status24: statusDist(s.app, 24 * 3600_000),322      incidents30: incidentStats(s.app),323      certExpires: cert?.expires || null,324      incident: inc || null,325      proc: stat || null,326      nodeStat: nodeStat || null,327    });328  }329  // agrégats globaux pour le command center330  const upNow = out.filter(x => x.last && x.last.ok).length;331  const okLat = out.filter(x => x.last?.ok).map(x => x.last.ms);332  const totalChecks = out.reduce((a, x) => a + (x.checks24 || 0), 0);333  const totalFails = out.reduce((a, x) => a + (x.fails24 || 0), 0);334  const upts = out.map(x => x.uptime24).filter(v => v != null);335  return {336    sites: out, ts: Date.now(),337    global: {338      up: upNow, total: out.length, down: out.length - upNow,339      avgLatency: okLat.length ? Math.round(okLat.reduce((a, b) => a + b, 0) / okLat.length) : 0,340      worstLatency: okLat.length ? Math.max(...okLat) : 0,341      uptime24: upts.length ? Math.round(upts.reduce((a, b) => a + b, 0) / upts.length * 10) / 10 : null,342      checks24: totalChecks, fails24: totalFails,343      openIncidents: out.filter(x => x.incident).length,344    },345  };346}347348export function ecoSite(app) {349  const s = SITES.find(x => x.app === app);350  if (!s) return null;351  const since = Date.now() - 24 * 3600_000;352  // ~200 seaux sur 24 h pour le graphique353  const bucket = Math.ceil((24 * 3600_000) / 200);354  const series = db.prepare(`355    SELECT (ts/${bucket})*${bucket} AS t, AVG(ms) ms, MIN(ok) ok, COUNT(*) n356    FROM checks WHERE site=? AND ts>? GROUP BY t ORDER BY t`).all(app, since);357  const incidents = db.prepare('SELECT started,ended,reason FROM incidents WHERE site=? AND started>? ORDER BY started DESC LIMIT 30')358    .all(app, Date.now() - 30 * 24 * 3600_000);359  const errors = db.prepare('SELECT ts,code,err,ms FROM checks WHERE site=? AND ok=0 ORDER BY ts DESC LIMIT 20').all(app);360  // uptime + latence quotidiens 14 jours361  const days = [];362  for (let i = 13; i >= 0; i--) {363    const d0 = new Date(); d0.setHours(0, 0, 0, 0);364    const start = d0.getTime() - i * 24 * 3600_000;365    const r = db.prepare('SELECT COUNT(*) n, SUM(ok) up, AVG(CASE WHEN ok=1 THEN ms END) a FROM checks WHERE site=? AND ts>=? AND ts<?').get(app, start, start + 24 * 3600_000);366    days.push({ day: start, uptime: r.n ? Math.round((r.up / r.n) * 1000) / 10 : null, avgMs: r.a ? Math.round(r.a) : null });367  }368  return {369    app, label: s.label, domain: s.domain, node: s.node, dir: s.dir,370    series, incidents, errors, days,371    lat24: latencyStats(app, 24 * 3600_000),372    lat7: latencyStats(app, 7 * 24 * 3600_000),373    status24: statusDist(app, 24 * 3600_000),374    counts24: checkCounts(app, 24 * 3600_000),375    counts7: checkCounts(app, 7 * 24 * 3600_000),376    incidentStats: incidentStats(app),377    trend: latencyTrend(app),378  };379}380381export function ecoIncidents() {382  return db.prepare(`SELECT site,started,ended,reason FROM incidents ORDER BY started DESC LIMIT 50`).all();383}384385export function ecoNodes() {386  const out = [];387  for (const node of NODES) {388    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);389    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);390    const apps = SITES.filter(s => s.node === node).map(s => {391      const p = db.prepare('SELECT cpu,mem,status,restarts FROM appstats WHERE app=? ORDER BY ts DESC LIMIT 1').get(s.app);392      return { app: s.app, label: s.label, ...p };393    });394    out.push({ node, last: last || null, hist, apps });395  }396  return { nodes: out, ts: Date.now() };397}398
Files · af81ba22ed344a656eec6ad8750033d9da3cf173
Menu

Theme