Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.
TypeScript 70.4%
HTML 18.4%
JavaScript 4%
Python 3.8%
CSS 3.4%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// src/lib/status.ts — vérification d'état des plateformes + historique3// persistant (data/status/history.jsonl, alimenté par /api/status/tick via4// pm2 cron toutes les 5 min). Sert la page /status (barres d'uptime).5import { promises as fs } from "fs";6import path from "path";7import eco from "@/ka/ecosystem.json";89export type SiteCheck = { up: boolean; code: number | null; ms: number | null };10export type Tick = { ts: number; checks: Record<string, SiteCheck> };1112// Les agents ·Ka (Guardian) — surveillés comme les plateformes, mais listés13// à part : ce ne sont pas des sites d'ecosystem.json (source canonique ka-ui),14// ce sont les agents autonomes qui construisent et réparent l'écosystème.15export type Agent = {16 id: string;17 wordmark: string;18 domain: string;19 accent: string;20 tagline: string;21};2223export const AGENTS: Agent[] = [24 {25 id: "ka2",26 wordmark: "Ka2",27 domain: "www.ka2.bot",28 accent: "#1c5c41",29 tagline: "Agent autonome — Guardian · cartographie du web québécois",30 },31 {32 id: "ka4",33 wordmark: "Ka4",34 domain: "www.ka4.bot",35 accent: "#1c5c41",36 tagline: "Agent autonome — Guardian · cartographie du web québécois",37 },38 {39 id: "ka6",40 wordmark: "Ka6",41 domain: "www.ka6.bot",42 accent: "#1c5c41",43 tagline: "Agent autonome — Guardian · missions connecteurs",44 },45];4647const HISTORY_DIR = path.join(process.cwd(), "data", "status");48const HISTORY_FILE = path.join(HISTORY_DIR, "history.jsonl");49const RETENTION_MS = 90 * 24 * 3600 * 1000;5051export async function checkSite(domain: string): Promise<SiteCheck> {52 const started = Date.now();53 try {54 const res = await fetch(`https://${domain}/`, {55 cache: "no-store",56 redirect: "follow",57 signal: AbortSignal.timeout(9000),58 headers: {59 "user-agent": "GroupeKA-Status/1.0 (+https://www.groupe-ka.com/status)",60 },61 });62 return { up: res.ok, code: res.status, ms: Date.now() - started };63 } catch {64 return { up: false, code: null, ms: null };65 }66}6768export async function checkAll(): Promise<Tick> {69 const targets = [70 ...eco.sites.map((s) => ({ id: s.id, domain: s.domain })),71 ...AGENTS.map((a) => ({ id: a.id, domain: a.domain })),72 ];73 const entries = await Promise.all(74 targets.map(async (t) => [t.id, await checkSite(t.domain)] as const),75 );76 return { ts: Date.now(), checks: Object.fromEntries(entries) };77}7879/** Ajoute un tick à l'historique et purge au-delà de 90 jours. */80export async function appendTick(tick: Tick): Promise<void> {81 await fs.mkdir(HISTORY_DIR, { recursive: true });82 await fs.appendFile(HISTORY_FILE, JSON.stringify(tick) + "\n", "utf8");83 // purge occasionnelle (1 fois sur ~50) pour rester O(1) en régime normal84 if (Math.random() < 0.02) {85 const cutoff = Date.now() - RETENTION_MS;86 const ticks = await readHistory();87 const kept = ticks.filter((t) => t.ts >= cutoff);88 if (kept.length < ticks.length) {89 await fs.writeFile(90 HISTORY_FILE,91 kept.map((t) => JSON.stringify(t)).join("\n") + "\n",92 "utf8",93 );94 }95 }96}9798export async function readHistory(): Promise<Tick[]> {99 try {100 const raw = await fs.readFile(HISTORY_FILE, "utf8");101 return raw102 .split("\n")103 .filter(Boolean)104 .map((l) => {105 try {106 return JSON.parse(l) as Tick;107 } catch {108 return null;109 }110 })111 .filter((t): t is Tick => t !== null);112 } catch {113 return [];114 }115}116117export type Bucket = { state: "up" | "degraded" | "down" | "empty"; label: string };118119/** Découpe l'historique d'un site en `count` intervalles de `stepMs` (le plus récent à droite). */120export function bucketize(121 ticks: Tick[],122 siteId: string,123 count: number,124 stepMs: number,125 now: number,126): Bucket[] {127 const buckets: Bucket[] = [];128 for (let i = count - 1; i >= 0; i--) {129 const end = now - i * stepMs;130 const start = end - stepMs;131 const inRange = ticks.filter((t) => t.ts > start && t.ts <= end);132 const d = new Date(end);133 const label = d.toLocaleString("fr-CA", {134 timeZone: "America/Toronto",135 month: "short",136 day: "numeric",137 hour: "2-digit",138 minute: "2-digit",139 });140 if (!inRange.length) {141 buckets.push({ state: "empty", label: `${label} — aucune mesure` });142 continue;143 }144 const ups = inRange.filter((t) => t.checks[siteId]?.up).length;145 const state = ups === inRange.length ? "up" : ups === 0 ? "down" : "degraded";146 buckets.push({147 state,148 label: `${label} — ${ups}/${inRange.length} vérifications réussies`,149 });150 }151 return buckets;152}153154/** % de disponibilité d'un site sur une fenêtre donnée (null si aucune mesure). */155export function uptimePct(ticks: Tick[], siteId: string, windowMs: number, now: number): number | null {156 const inRange = ticks.filter((t) => t.ts > now - windowMs);157 const measured = inRange.filter((t) => t.checks[siteId] !== undefined);158 if (!measured.length) return null;159 const ups = measured.filter((t) => t.checks[siteId].up).length;160 return (ups / measured.length) * 100;161}162