| 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'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'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'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'intervalle a échoué. Historique |
|
191 |
+ conservé 90 jours. |
| 131 |
192 |
</p> |
| 132 |
193 |
</main> |
| 133 |
194 |
); |