SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

Page /status v2 — historique de disponibilité : ticks aux 5 min (pm2 cron groupe-ka-status-tick → /api/status/tick, data/status/history.jsonl 90 j), barres 24 h (15 min) + 90 jours, uptime 24 h/7 j/30 j ; favicon + og.png « par Groupe KA » (partage social)

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

8 changed files +316 −94

added public/apple-touch-icon.png +0 −0

Binary file not shown.

added public/favicon.svg +5 −0
@@ -0,0 +1,5 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
2 +<rect width="64" height="64" rx="14" fill="#141814"/>
3 +<rect x="4.5" y="4.5" width="55" height="55" rx="11" fill="none" stroke="#d9f26b" stroke-opacity="0.35" stroke-width="2"/>
4 +<text x="32" y="43" text-anchor="middle" font-family="'Space Grotesk','Arial Black',sans-serif" font-size="30" font-weight="700" letter-spacing="-1" fill="#d9f26b" transform="rotate(-4 32 32)">KA</text>
5 +</svg>
\ No newline at end of file
added public/og.png +0 −0

Binary file not shown.

added scripts/status-tick.sh +3 −0
@@ -0,0 +1,3 @@
1 +#!/bin/bash
2 +# Auteur : Simon-Pierre Boucher — tick de statut (pm2 cron toutes les 5 min)
3 +curl -s -m 60 http://127.0.0.1:8110/api/status/tick > /dev/null
added src/app/api/status/tick/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/status/tick — effectue une vérification des 12 plateformes et
3 +// l'enregistre dans l'historique. Appelé toutes les 5 min par le processus
4 +// pm2 « groupe-ka-status-tick » (cron-restart) sur le nœud.
5 +import { NextResponse } from "next/server";
6 +import { checkAll, appendTick } from "@/lib/status";
7 +
8 +export const dynamic = "force-dynamic";
9 +
10 +export async function GET() {
11 + const tick = await checkAll();
12 + await appendTick(tick);
13 + const up = Object.values(tick.checks).filter((c) => c.up).length;
14 + return NextResponse.json({
15 + ok: true,
16 + ts: tick.ts,
17 + up,
18 + total: Object.keys(tick.checks).length,
19 + });
20 +}
modified src/app/layout.tsx +11 −0
@@ -28,6 +28,17 @@ export const metadata: Metadata = {
28 28 siteName: "Groupe KA",
29 29 locale: "fr_CA",
30 30 type: "website",
31 + images: [
32 + { url: "https://www.groupe-ka.com/og.png", width: 1200, height: 630 },
33 + ],
34 + },
35 + twitter: {
36 + card: "summary_large_image",
37 + images: ["https://www.groupe-ka.com/og.png"],
38 + },
39 + icons: {
40 + icon: "/favicon.svg",
41 + apple: "/apple-touch-icon.png",
31 42 },
32 43 };
33 44
modified src/app/status/page.tsx +155 −94
@@ -1,133 +1,194 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 −// /status — état des services de l'écosystème Groupe KA, vérifié EN DIRECT au
3 −// chargement de la page (aucune valeur figée) : disponibilité + latence de
4 −// chacune des 12 plateformes, mesurées depuis le serveur du hub.
2 +// /status — état des services de l'écosystème Groupe KA : vérification EN
3 +// DIRECT au chargement + HISTORIQUE persistant (ticks toutes les 5 min via
4 +// /api/status/tick) : barres 24 h (15 min) et 90 jours, uptime 24 h/7 j/30 j.
5 5 import type { Metadata } from "next";
6 6 import eco from "@/ka/ecosystem.json";
7 +import {
8 + checkAll,
9 + readHistory,
10 + bucketize,
11 + uptimePct,
12 + type Tick,
13 + type SiteCheck,
14 +} from "@/lib/status";
7 15
8 16 export const dynamic = "force-dynamic";
9 17
10 18 export const metadata: Metadata = {
11 19 title: "Statut des services — Groupe KA",
12 20 description:
13 − "État en direct des 12 plateformes de l'écosystème Groupe KA : disponibilité et latence, vérifiées à chaque chargement.",
21 + "État en direct et historique de disponibilité des 12 plateformes de l'écosystème Groupe KA : vérifications aux 5 minutes, barres 24 h et 90 jours, latences.",
14 22 };
15 23
16 −type Check = {
17 − id: string;
18 − wordmark: string;
19 − domain: string;
20 − accent: string;
21 − tagline: string;
22 − up: boolean;
23 − code: number | null;
24 − ms: number | null;
24 +const BAR_COLORS: Record<string, string> = {
25 + up: "#1c5c41",
26 + degraded: "#e8a33d",
27 + down: "#b3423a",
28 + empty: "rgba(20,24,20,0.12)",
25 29 };
26 30
27 −async function checkSite(s: (typeof eco.sites)[number]): Promise<Check> {
28 − const started = Date.now();
29 − try {
30 − const res = await fetch(`https://${s.domain}/`, {
31 − cache: "no-store",
32 − redirect: "follow",
33 − signal: AbortSignal.timeout(9000),
34 − headers: { "user-agent": "GroupeKA-Status/1.0 (+https://www.groupe-ka.com/status)" },
35 − });
36 − return {
37 − id: s.id,
38 − wordmark: s.wordmark,
39 − domain: s.domain,
40 − accent: s.accent,
41 − tagline: s.tagline,
42 − up: res.ok,
43 − code: res.status,
44 − ms: Date.now() - started,
45 − };
46 − } catch {
47 − return {
48 − id: s.id,
49 − wordmark: s.wordmark,
50 − domain: s.domain,
51 − accent: s.accent,
52 − tagline: s.tagline,
53 − up: false,
54 − code: null,
55 − ms: null,
56 − };
57 − }
31 +function Strip({
32 + ticks,
33 + siteId,
34 + count,
35 + stepMs,
36 + now,
37 +}: {
38 + ticks: Tick[];
39 + siteId: string;
40 + count: number;
41 + stepMs: number;
42 + now: number;
43 +}) {
44 + const buckets = bucketize(ticks, siteId, count, stepMs, now);
45 + return (
46 + <div className="flex h-[26px] items-stretch gap-[2px]">
47 + {buckets.map((b, i) => (
48 + <span
49 + key={i}
50 + title={b.label}
51 + className="min-w-0 flex-1 rounded-[2px]"
52 + style={{ background: BAR_COLORS[b.state] }}
53 + />
54 + ))}
55 + </div>
56 + );
57 +}
58 +
59 +function Pct({ value }: { value: number | null }) {
60 + if (value === null)
61 + return <span className="text-ink-3">—</span>;
62 + const cls =
63 + value >= 99.5 ? "text-green" : value >= 97 ? "text-[#b96a00]" : "text-[#b3423a]";
64 + return <span className={`font-bold ${cls}`}>{value.toFixed(value === 100 ? 0 : 2)} %</span>;
58 65 }
59 66
60 67 export default async function StatusPage() {
61 − const checks = await Promise.all(eco.sites.map(checkSite));
62 − const upCount = checks.filter((c) => c.up).length;
63 − const allUp = upCount === checks.length;
64 − const when = new Date().toLocaleString("fr-CA", {
68 + const now = Date.now();
69 + const [live, history] = await Promise.all([checkAll(), readHistory()]);
70 + const upCount = Object.values(live.checks).filter((c) => c.up).length;
71 + const allUp = upCount === eco.sites.length;
72 + const firstTick = history.length ? history[0].ts : null;
73 + const when = new Date(now).toLocaleString("fr-CA", {
65 74 timeZone: "America/Toronto",
66 75 dateStyle: "long",
67 76 timeStyle: "medium",
68 77 });
78 + const DAY = 24 * 3600 * 1000;
69 79
70 80 return (
71 81 <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
72 − <p className="kicker">État des services · vérifié en direct</p>
82 + <p className="kicker">État des services · direct + historique</p>
73 83 <h1 className="gk-display mt-3 text-[clamp(28px,4.5vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase">
74 84 Les 12 plateformes,{" "}
75 85 <span className="hl">{allUp ? "toutes en ligne" : `${upCount}/12 en ligne`}</span>
76 86 </h1>
77 87 <p className="mt-4 max-w-xl text-[14.5px] text-ink-2">
78 − Chaque ligne est une vraie requête envoyée à la plateforme au moment où
79 − cette page se charge — disponibilité et latence mesurées depuis le
80 − serveur du hub, rien de mis en cache.
88 + Chaque plateforme est vérifiée automatiquement aux 5 minutes, plus une
89 + vérification en direct au chargement de cette page. Les barres se
90 + lisent de gauche (le plus ancien) à droite (maintenant) — survolez-les
91 + pour le détail.
92 + </p>
93 + <p className="gk-mono mt-2 text-[11px] text-ink-3">
94 + Vérifié le {when} (heure de l&apos;Est)
95 + {firstTick
96 + ? ` · historique depuis le ${new Date(firstTick).toLocaleDateString("fr-CA", { timeZone: "America/Toronto", dateStyle: "long" })}`
97 + : " · l'historique démarre — les barres se rempliront aux 5 minutes"}
81 98 </p>
82 − <p className="gk-mono mt-2 text-[11px] text-ink-3">Vérifié le {when} (heure de l&apos;Est)</p>
83 99
84 − <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
85 − {checks.map((c) => (
86 − <article key={c.id} className="gk-card gk-card-hover p-5">
87 − <div className="flex items-center justify-between gap-3">
88 − <span className="flex items-center gap-2">
89 − <span
90 − aria-hidden="true"
91 − className="inline-block h-[10px] w-[10px] rounded-[3px] border border-ink"
92 − style={{ background: c.accent }}
93 − />
94 − <span className="gk-display text-[17px] font-bold tracking-[-0.02em]">
95 − {c.wordmark}
96 − </span>
97 − </span>
98 − {c.up ? (
99 − <span className="gk-mono inline-flex items-center gap-[6px] rounded-full border-[1.5px] border-ink bg-lime px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-ink uppercase">
100 − <span className="pulse-dot" aria-hidden="true" />
101 − En ligne
100 + <div className="gk-mono mt-6 flex flex-wrap gap-x-5 gap-y-2 text-[10.5px] font-bold tracking-[0.06em] text-ink-2 uppercase">
101 + {(
102 + [
103 + ["up", "En ligne"],
104 + ["degraded", "Dégradé"],
105 + ["down", "Hors ligne"],
106 + ["empty", "Pas de mesure"],
107 + ] as const
108 + ).map(([k, label]) => (
109 + <span key={k} className="inline-flex items-center gap-2">
110 + <span
111 + className="inline-block h-[10px] w-[14px] rounded-[2px]"
112 + style={{ background: BAR_COLORS[k] }}
113 + />
114 + {label}
115 + </span>
116 + ))}
117 + </div>
118 +
119 + <div className="mt-6 space-y-5">
120 + {eco.sites.map((s) => {
121 + const c: SiteCheck | undefined = live.checks[s.id];
122 + const up24 = uptimePct(history, s.id, DAY, now);
123 + const up7 = uptimePct(history, s.id, 7 * DAY, now);
124 + const up30 = uptimePct(history, s.id, 30 * DAY, now);
125 + return (
126 + <article key={s.id} className="gk-card p-5 sm:p-6">
127 + <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
128 + <span className="flex min-w-0 items-center gap-2">
129 + <span
130 + aria-hidden="true"
131 + className="inline-block h-[10px] w-[10px] flex-none rounded-[3px] border border-ink"
132 + style={{ background: s.accent }}
133 + />
134 + <span className="gk-display truncate text-[17px] font-bold tracking-[-0.02em]">
135 + {s.wordmark}
136 + </span>
137 + <a
138 + href={`https://${s.domain}`}
139 + target="_blank"
140 + rel="noopener noreferrer"
141 + className="gk-mono hidden text-[11px] font-bold text-ink-3 underline-offset-4 hover:text-ink hover:underline sm:inline"
142 + >
143 + {s.domain}
144 + </a>
102 145 </span>
103 − ) : (
104 − <span className="gk-mono inline-flex items-center rounded-full border-[1.5px] border-ink bg-[#fbe9e7] px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-[#b3423a] uppercase">
105 − Hors ligne
146 + <span className="flex items-center gap-3">
147 + <span className="gk-mono text-[11px] text-ink-3">
148 + {c?.up ? `HTTP ${c.code} · ${c.ms} ms` : c?.code ? `HTTP ${c.code}` : "délai dépassé"}
149 + </span>
150 + {c?.up ? (
151 + <span className="gk-mono inline-flex items-center gap-[6px] rounded-full border-[1.5px] border-ink bg-lime px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-ink uppercase">
152 + <span className="pulse-dot" aria-hidden="true" />
153 + En ligne
154 + </span>
155 + ) : (
156 + <span className="gk-mono inline-flex items-center rounded-full border-[1.5px] border-ink bg-[#fbe9e7] px-3 py-[3px] text-[10px] font-bold tracking-[0.08em] text-[#b3423a] uppercase">
157 + Hors ligne
158 + </span>
159 + )}
106 160 </span>
107 − )}
108 − </div>
109 − <p className="mt-2 text-[12.5px] text-ink-2">{c.tagline}</p>
110 − <div className="gk-mono mt-4 flex items-center justify-between border-t border-dashed border-[rgba(20,24,20,0.25)] pt-3 text-[11px] text-ink-3">
111 − <a
112 − href={`https://${c.domain}`}
113 − target="_blank"
114 − rel="noopener noreferrer"
115 − className="font-bold text-ink-2 underline-offset-4 hover:text-ink hover:underline"
116 − >
117 − {c.domain}
118 − </a>
119 − <span>
120 − {c.up ? `HTTP ${c.code} · ${c.ms} ms` : c.code ? `HTTP ${c.code}` : "délai dépassé"}
121 − </span>
122 − </div>
123 − </article>
124 − ))}
161 + </div>
162 +
163 + <div className="mt-4">
164 + <p className="klabel">Dernières 24 heures · 1 barre = 15 min</p>
165 + <div className="mt-2">
166 + <Strip ticks={history} siteId={s.id} count={96} stepMs={15 * 60 * 1000} now={now} />
167 + </div>
168 + </div>
169 + <div className="mt-4">
170 + <p className="klabel">90 derniers jours · 1 barre = 1 jour</p>
171 + <div className="mt-2">
172 + <Strip ticks={history} siteId={s.id} count={90} stepMs={DAY} now={now} />
173 + </div>
174 + </div>
175 +
176 + <div className="gk-mono mt-4 flex flex-wrap gap-x-7 gap-y-2 border-t border-dashed border-[rgba(20,24,20,0.25)] pt-3 text-[11.5px] text-ink-2">
177 + <span>Uptime 24 h : <Pct value={up24} /></span>
178 + <span>7 jours : <Pct value={up7} /></span>
179 + <span>30 jours : <Pct value={up30} /></span>
180 + </div>
181 + </article>
182 + );
183 + })}
125 184 </div>
126 185
127 186 <p className="gk-mono mt-8 text-[11px] leading-relaxed text-ink-3">
128 187 Méthode : requête GET sur la page d&apos;accueil de chaque domaine
129 − (délai maximal 9 s), latence = temps de réponse complet vu du serveur
130 − du hub. Recharger la page relance les 12 vérifications.
188 + (délai maximal 9 s) toutes les 5 minutes depuis le serveur du hub, plus
189 + une vérification en direct au chargement. Une barre est « dégradée » si
190 + une partie des vérifications de l&apos;intervalle a échoué. Historique
191 + conservé 90 jours.
131 192 </p>
132 193 </main>
133 194 );
added src/lib/status.ts +122 −0
@@ -0,0 +1,122 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// src/lib/status.ts — vérification d'état des plateformes + historique
3 +// persistant (data/status/history.jsonl, alimenté par /api/status/tick via
4 +// pm2 cron toutes les 5 min). Sert la page /status (barres d'uptime).
5 +import { promises as fs } from "fs";
6 +import path from "path";
7 +import eco from "@/ka/ecosystem.json";
8 +
9 +export type SiteCheck = { up: boolean; code: number | null; ms: number | null };
10 +export type Tick = { ts: number; checks: Record<string, SiteCheck> };
11 +
12 +const HISTORY_DIR = path.join(process.cwd(), "data", "status");
13 +const HISTORY_FILE = path.join(HISTORY_DIR, "history.jsonl");
14 +const RETENTION_MS = 90 * 24 * 3600 * 1000;
15 +
16 +export async function checkSite(domain: string): Promise<SiteCheck> {
17 + const started = Date.now();
18 + try {
19 + const res = await fetch(`https://${domain}/`, {
20 + cache: "no-store",
21 + redirect: "follow",
22 + signal: AbortSignal.timeout(9000),
23 + headers: {
24 + "user-agent": "GroupeKA-Status/1.0 (+https://www.groupe-ka.com/status)",
25 + },
26 + });
27 + return { up: res.ok, code: res.status, ms: Date.now() - started };
28 + } catch {
29 + return { up: false, code: null, ms: null };
30 + }
31 +}
32 +
33 +export async function checkAll(): Promise<Tick> {
34 + const entries = await Promise.all(
35 + eco.sites.map(async (s) => [s.id, await checkSite(s.domain)] as const),
36 + );
37 + return { ts: Date.now(), checks: Object.fromEntries(entries) };
38 +}
39 +
40 +/** Ajoute un tick à l'historique et purge au-delà de 90 jours. */
41 +export async function appendTick(tick: Tick): Promise<void> {
42 + await fs.mkdir(HISTORY_DIR, { recursive: true });
43 + await fs.appendFile(HISTORY_FILE, JSON.stringify(tick) + "\n", "utf8");
44 + // purge occasionnelle (1 fois sur ~50) pour rester O(1) en régime normal
45 + if (Math.random() < 0.02) {
46 + const cutoff = Date.now() - RETENTION_MS;
47 + const ticks = await readHistory();
48 + const kept = ticks.filter((t) => t.ts >= cutoff);
49 + if (kept.length < ticks.length) {
50 + await fs.writeFile(
51 + HISTORY_FILE,
52 + kept.map((t) => JSON.stringify(t)).join("\n") + "\n",
53 + "utf8",
54 + );
55 + }
56 + }
57 +}
58 +
59 +export async function readHistory(): Promise<Tick[]> {
60 + try {
61 + const raw = await fs.readFile(HISTORY_FILE, "utf8");
62 + return raw
63 + .split("\n")
64 + .filter(Boolean)
65 + .map((l) => {
66 + try {
67 + return JSON.parse(l) as Tick;
68 + } catch {
69 + return null;
70 + }
71 + })
72 + .filter((t): t is Tick => t !== null);
73 + } catch {
74 + return [];
75 + }
76 +}
77 +
78 +export type Bucket = { state: "up" | "degraded" | "down" | "empty"; label: string };
79 +
80 +/** Découpe l'historique d'un site en `count` intervalles de `stepMs` (le plus récent à droite). */
81 +export function bucketize(
82 + ticks: Tick[],
83 + siteId: string,
84 + count: number,
85 + stepMs: number,
86 + now: number,
87 +): Bucket[] {
88 + const buckets: Bucket[] = [];
89 + for (let i = count - 1; i >= 0; i--) {
90 + const end = now - i * stepMs;
91 + const start = end - stepMs;
92 + const inRange = ticks.filter((t) => t.ts > start && t.ts <= end);
93 + const d = new Date(end);
94 + const label = d.toLocaleString("fr-CA", {
95 + timeZone: "America/Toronto",
96 + month: "short",
97 + day: "numeric",
98 + hour: "2-digit",
99 + minute: "2-digit",
100 + });
101 + if (!inRange.length) {
102 + buckets.push({ state: "empty", label: `${label} — aucune mesure` });
103 + continue;
104 + }
105 + const ups = inRange.filter((t) => t.checks[siteId]?.up).length;
106 + const state = ups === inRange.length ? "up" : ups === 0 ? "down" : "degraded";
107 + buckets.push({
108 + state,
109 + label: `${label} — ${ups}/${inRange.length} vérifications réussies`,
110 + });
111 + }
112 + return buckets;
113 +}
114 +
115 +/** % de disponibilité d'un site sur une fenêtre donnée (null si aucune mesure). */
116 +export function uptimePct(ticks: Tick[], siteId: string, windowMs: number, now: number): number | null {
117 + const inRange = ticks.filter((t) => t.ts > now - windowMs);
118 + const measured = inRange.filter((t) => t.checks[siteId] !== undefined);
119 + if (!measured.length) return null;
120 + const ups = measured.filter((t) => t.checks[siteId].up).length;
121 + return (ups / measured.length) * 100;
122 +}
123