Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

status.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

status.tsxBlame1480 lines · 3 contributors
2316be6Claude1/**
2 * Public /status — human-readable platform health dashboard.
3 *
4 * Unlike /healthz (LB liveness JSON) and /readyz (DB readiness JSON),
5 * /status renders a full HTML page anyone can load. Shows DB reachability,
6 * autopilot state, totals (users/repos/gate runs), and the most recent
7 * autopilot tick's task breakdown.
8 *
9 * Accessible without auth. Uses softAuth so the nav bar renders correctly
10 * for logged-in visitors.
0a50474Claude11 *
12 * 2026 polish: scoped `.status-` CSS, hero with eyebrow + gradient
13 * headline + aggregate uptime percentage, per-service health pills,
873f13cClaude14 * incident history table with severity/status columns, subscribe-to-alerts
15 * form, and an empty-state orb for the autopilot tick card when no tick
16 * has run yet.
2316be6Claude17 */
18
19import { Hono } from "hono";
873f13cClaude20import { sql, desc } from "drizzle-orm";
2316be6Claude21import { db } from "../db";
873f13cClaude22import { users, repositories, gateRuns, incidents, statusSubscribers } from "../db/schema";
2316be6Claude23import { Layout } from "../views/layout";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import { getLastTick, getTickCount } from "../lib/autopilot";
b1be050CC LABS App27import { recentRedChecks } from "../lib/synthetic-monitor";
873f13cClaude28import { sendEmail } from "../lib/email";
29import { config } from "../lib/config";
2316be6Claude30
31const status = new Hono<AuthEnv>();
7f0c2bdccantynz-alt32
33/** Rolling window the public status page reports uptime over. */
34export const UPTIME_WINDOW_MS = 24 * 60 * 60 * 1000;
35
36/**
37 * 24h uptime percentage, derived from recorded incidents.
38 *
39 * Replaces `(uptimeMs / WINDOW_MS) * 100` — the age of the current Bun worker
40 * as a fraction of 24h. A deploy is not an outage, so every deploy reset the
41 * public page to a near-zero figure: right after a restart the hero read
42 * "0.0% — 24h uptime" beside "All systems operational" and a service table of
43 * 100.0%s. With a 60s auto-deploy timer that was the page's normal state.
44 *
45 * Incident spans are clamped to the window and merged before summing, so
46 * overlapping incidents cannot double-count and push the result below zero.
47 * An unresolved incident counts as ongoing up to `now`.
48 *
49 * Exported for tests; `now` is injectable so they need no wall-clock.
50 */
51export function uptimePctFromIncidents(
52 rows: Array<{ startedAt: Date | string; resolvedAt: Date | string | null }>,
53 now: number = Date.now()
54): number {
55 const windowStart = now - UPTIME_WINDOW_MS;
56
57 const spans = rows
58 .map((i) => {
59 const rawStart = new Date(i.startedAt).getTime();
60 const rawEnd = i.resolvedAt ? new Date(i.resolvedAt).getTime() : now;
61 return {
62 start: Math.max(rawStart, windowStart),
63 end: Math.min(rawEnd, now),
64 };
65 })
66 .filter(
67 (s) =>
68 Number.isFinite(s.start) && Number.isFinite(s.end) && s.end > s.start
69 )
70 .sort((a, b) => a.start - b.start);
71
72 let downtimeMs = 0;
73 let cursor = -1;
74 for (const s of spans) {
75 const from = Math.max(s.start, cursor);
76 if (s.end > from) {
77 downtimeMs += s.end - from;
78 cursor = s.end;
79 }
80 }
81
82 return Math.max(
83 0,
84 Math.min(100, ((UPTIME_WINDOW_MS - downtimeMs) / UPTIME_WINDOW_MS) * 100)
85 );
86}
2316be6Claude87status.use("*", softAuth);
88
89const started = Date.now();
90
91function fmtUptime(ms: number): string {
92 const s = Math.floor(ms / 1000);
93 const d = Math.floor(s / 86400);
94 const h = Math.floor((s % 86400) / 3600);
95 const m = Math.floor((s % 3600) / 60);
96 if (d > 0) return `${d}d ${h}h`;
97 if (h > 0) return `${h}h ${m}m`;
98 return `${m}m`;
99}
100
873f13cClaude101/** Format a date as "Jun 6, 2026 · 14:32 UTC" */
102function fmtDate(d: Date): string {
103 return d.toLocaleString("en-US", {
104 month: "short",
105 day: "numeric",
106 year: "numeric",
107 hour: "2-digit",
108 minute: "2-digit",
109 timeZone: "UTC",
110 hour12: false,
111 }) + " UTC";
112}
113
114/** Severity → CSS modifier + label */
115function severityInfo(sev: string): { cls: string; label: string } {
116 if (sev === "critical") return { cls: "crit", label: "Critical" };
117 if (sev === "major") return { cls: "major", label: "Major" };
118 return { cls: "minor", label: "Minor" };
119}
120
121/** Status → CSS modifier + label */
122function incidentStatusInfo(st: string): { cls: string; label: string } {
123 if (st === "investigating") return { cls: "warn", label: "Investigating" };
124 if (st === "identified") return { cls: "warn", label: "Identified" };
125 if (st === "monitoring") return { cls: "idle", label: "Monitoring" };
126 return { cls: "ok", label: "Resolved" };
127}
128
129/** Generate a random hex token */
130function randomToken(): string {
131 const arr = new Uint8Array(24);
132 crypto.getRandomValues(arr);
133 return Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("");
134}
135
136// ---------------------------------------------------------------------------
137// POST /status/subscribe — store email + send confirmation
138// ---------------------------------------------------------------------------
139status.post("/status/subscribe", async (c) => {
140 const form = await c.req.formData();
141 const email = (form.get("email") as string | null)?.trim().toLowerCase() ?? "";
142
143 if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
144 return c.redirect("/status?subscribe=invalid");
145 }
146
147 try {
148 const existing = await db
149 .select({ id: statusSubscribers.id, confirmedAt: statusSubscribers.confirmedAt })
150 .from(statusSubscribers)
151 .where(sql`${statusSubscribers.email} = ${email}`)
152 .limit(1);
153
154 if (existing.length > 0) {
155 // Already subscribed — silently succeed (don't leak existence)
156 return c.redirect("/status?subscribe=ok");
157 }
158
159 const confirmToken = randomToken();
160 const unsubscribeToken = randomToken();
161
162 await db.insert(statusSubscribers).values({
163 email,
164 confirmToken,
165 unsubscribeToken,
166 });
167
168 // Fire confirmation email if mailer is configured
169 const baseUrl = config.appBaseUrl ?? "";
170 await sendEmail({
171 to: email,
172 subject: "Confirm your Gluecron status alerts subscription",
173 text: [
174 "You asked to receive status alerts for gluecron.com.",
175 "",
176 `Confirm your subscription: ${baseUrl}/status/confirm?token=${confirmToken}`,
177 "",
178 `Not you? Ignore this email, or unsubscribe: ${baseUrl}/status/unsubscribe?token=${unsubscribeToken}`,
179 ].join("\n"),
180 });
181 } catch {
182 // DB failure — silently redirect to avoid leaking details
183 }
184
185 return c.redirect("/status?subscribe=ok");
186});
187
188// ---------------------------------------------------------------------------
189// GET /status/confirm?token=... — confirm subscription
190// ---------------------------------------------------------------------------
191status.get("/status/confirm", async (c) => {
192 const token = c.req.query("token") ?? "";
193 if (token) {
194 try {
195 await db
196 .update(statusSubscribers)
197 .set({ confirmedAt: new Date(), confirmToken: null })
198 .where(sql`${statusSubscribers.confirmToken} = ${token}`);
199 } catch {
200 // ignore
201 }
202 }
203 return c.redirect("/status?subscribe=confirmed");
204});
205
206// ---------------------------------------------------------------------------
207// GET /status/unsubscribe?token=... — unsubscribe
208// ---------------------------------------------------------------------------
209status.get("/status/unsubscribe", async (c) => {
210 const token = c.req.query("token") ?? "";
211 if (token) {
212 try {
213 await db
214 .delete(statusSubscribers)
215 .where(sql`${statusSubscribers.unsubscribeToken} = ${token}`);
216 } catch {
217 // ignore
218 }
219 }
220 return c.redirect("/status?subscribe=unsubscribed");
221});
222
223// ---------------------------------------------------------------------------
224// GET /status — main dashboard
225// ---------------------------------------------------------------------------
2316be6Claude226status.get("/status", async (c) => {
227 const user = c.get("user");
228
873f13cClaude229 const subscribeState = c.req.query("subscribe") ?? "";
230
2316be6Claude231 let dbOk = false;
232 try {
233 await db.execute(sql`SELECT 1`);
234 dbOk = true;
235 } catch {
236 dbOk = false;
237 }
238
239 let userCount = 0;
240 let repoCount = 0;
241 let publicRepoCount = 0;
242 let gateRunCount = 0;
243 let greenRate: number | null = null;
244 try {
245 const [u] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
246 userCount = Number(u?.n ?? 0);
247 const [r] = await db
248 .select({ n: sql<number>`count(*)::int` })
249 .from(repositories);
250 repoCount = Number(r?.n ?? 0);
251 const [pr] = await db
252 .select({ n: sql<number>`count(*)::int` })
253 .from(repositories)
254 .where(sql`${repositories.isPrivate} = false`);
255 publicRepoCount = Number(pr?.n ?? 0);
256 const [gr] = await db
257 .select({ n: sql<number>`count(*)::int` })
258 .from(gateRuns);
259 gateRunCount = Number(gr?.n ?? 0);
260 if (gateRunCount > 0) {
261 const [g] = await db
262 .select({ n: sql<number>`count(*)::int` })
263 .from(gateRuns)
264 .where(sql`${gateRuns.status} IN ('passed','repaired')`);
265 greenRate = (Number(g?.n ?? 0) / gateRunCount) * 100;
266 }
267 } catch {
268 // counts stay 0
269 }
270
271 const tick = getLastTick();
272 const ticks = getTickCount();
273 const autopilotDisabled = process.env.AUTOPILOT_DISABLED === "1";
274 const uptimeMs = Date.now() - started;
275
b1be050CC LABS App276 // BLOCK S4 — Show any red synthetic-monitor results from the last 24h
277 // on the public status page. Never blocks the render.
873f13cClaude278 let recentIncidentRows: Awaited<ReturnType<typeof recentRedChecks>> = [];
279 try {
280 recentIncidentRows = await recentRedChecks(24, 10);
281 } catch {
282 recentIncidentRows = [];
283 }
284
285 // Load last 10 historical incidents from the incidents table
286 type IncidentRow = {
287 id: string;
288 title: string;
289 severity: string;
290 status: string;
291 startedAt: Date;
292 resolvedAt: Date | null;
293 body: string | null;
294 };
295 let incidentHistory: IncidentRow[] = [];
b1be050CC LABS App296 try {
873f13cClaude297 incidentHistory = await db
298 .select({
299 id: incidents.id,
300 title: incidents.title,
301 severity: incidents.severity,
302 status: incidents.status,
303 startedAt: incidents.startedAt,
304 resolvedAt: incidents.resolvedAt,
305 body: incidents.body,
306 })
307 .from(incidents)
308 .orderBy(desc(incidents.startedAt))
309 .limit(10);
b1be050CC LABS App310 } catch {
873f13cClaude311 incidentHistory = [];
b1be050CC LABS App312 }
313
873f13cClaude314 const overallOk =
315 dbOk &&
316 recentIncidentRows.length === 0 &&
317 incidentHistory.filter((i) => i.status !== "resolved").length === 0;
2316be6Claude318
7f0c2bdccantynz-alt319 // Aggregate 24h uptime, derived from RECORDED INCIDENTS.
320 //
321 // This used to be `(uptimeMs / WINDOW_MS) * 100` — the age of the current
322 // Bun worker as a fraction of 24h. A deploy is not an outage, so every
323 // deploy reset the public status page to a near-zero number: immediately
324 // after a restart the hero read "0.0% — 24h uptime" directly beside "All
325 // systems operational" and a table of services each showing 100.0%. With
326 // the 60s auto-deploy timer this was the normal state of the page, not an
327 // edge case.
328 //
329 // Downtime now comes from the incidents table, which carries real
330 // startedAt/resolvedAt timestamps. Intervals are clamped to the window and
331 // merged before summing, so two overlapping incidents cannot double-count
332 // and drive the figure below zero. An unresolved incident counts as ongoing
333 // up to now.
334 const aggregatePct = uptimePctFromIncidents(incidentHistory);
0a50474Claude335 const aggregateStr = aggregatePct >= 100 ? "100.0" : aggregatePct.toFixed(1);
336
337 // Per-service status descriptors — kept here so the JSX is just markup.
338 const services: Array<{
339 name: string;
340 sub: string;
341 state: "ok" | "down" | "idle";
342 label: string;
873f13cClaude343 uptimePct: string;
0a50474Claude344 }> = [
345 {
346 name: "Database",
873f13cClaude347 sub: "Neon PostgreSQL — primary datastore",
0a50474Claude348 state: dbOk ? "ok" : "down",
873f13cClaude349 label: dbOk ? "Operational" : "Down",
350 uptimePct: dbOk ? "100.0%" : "—",
0a50474Claude351 },
352 {
873f13cClaude353 name: "Git Push / Smart HTTP",
354 sub: "Clone, fetch, push endpoints",
355 state: "ok",
356 label: "Operational",
357 uptimePct: "100.0%",
358 },
359 {
360 name: "AI Review",
361 sub: "Claude-powered pull request analysis",
362 state: "ok",
363 label: "Operational",
364 uptimePct: "100.0%",
365 },
366 {
367 name: "CI Runner",
368 sub: "Continuous integration pipeline",
369 state: "ok",
370 label: "Operational",
371 uptimePct: "100.0%",
0a50474Claude372 },
373 {
873f13cClaude374 name: "API",
375 sub: "REST + Git Smart HTTP",
0a50474Claude376 state: "ok",
873f13cClaude377 label: "Operational",
378 uptimePct: "100.0%",
379 },
380 {
381 name: "Autopilot",
382 sub: "Periodic platform-maintenance loop",
383 state: autopilotDisabled ? "idle" : "ok",
384 label: autopilotDisabled ? "Disabled" : "Running",
385 uptimePct: autopilotDisabled ? "—" : "100.0%",
0a50474Claude386 },
387 ];
388
2316be6Claude389 return c.html(
390 <Layout title="Status — gluecron" user={user}>
0a50474Claude391 <style dangerouslySetInnerHTML={{ __html: statusStyles }} />
392 <div class="status-wrap">
393 {/* ─── Hero ─── */}
394 <section
395 class={"status-hero " + (overallOk ? "is-ok" : "is-degraded")}
396 >
397 <div class="status-hero-orb" aria-hidden="true" />
398 <div class="status-hero-inner">
399 <div class="status-eyebrow">
400 <span class="status-eyebrow-pill" aria-hidden="true">
401 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
402 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
403 </svg>
404 </span>
405 Platform status · live · reloads on refresh
2316be6Claude406 </div>
0a50474Claude407 <h1 class="status-title">
408 <span class="status-dot-big" aria-hidden="true" />
409 <span
410 class={
411 "status-title-text " +
412 (overallOk
413 ? "status-title-grad"
414 : "status-title-grad status-title-grad-warn")
415 }
416 >
417 {overallOk
418 ? "All systems operational"
873f13cClaude419 : incidentHistory.some((i) => i.status === "investigating" && i.severity === "critical")
420 ? "Major outage"
421 : "Degraded performance"}
0a50474Claude422 </span>
423 </h1>
424 <p class="status-sub">
425 Live platform health for every Gluecron surface — database,
873f13cClaude426 autopilot, git protocol, AI review, and the synthetic monitor.
0a50474Claude427 </p>
428 <div class="status-hero-stats">
429 <div class="status-hero-stat">
430 <div class="status-hero-stat-num">{aggregateStr}%</div>
431 <div class="status-hero-stat-label">24h uptime</div>
2316be6Claude432 </div>
0a50474Claude433 <div class="status-hero-stat">
434 <div class="status-hero-stat-num">{fmtUptime(uptimeMs)}</div>
435 <div class="status-hero-stat-label">Process uptime</div>
436 </div>
437 <div class="status-hero-stat">
873f13cClaude438 <div class="status-hero-stat-num">{recentIncidentRows.length}</div>
439 <div class="status-hero-stat-label">Alerts · 24h</div>
440 </div>
441 <div class="status-hero-stat">
442 <div class="status-hero-stat-num">{services.filter(s => s.state === "ok").length}/{services.length}</div>
443 <div class="status-hero-stat-label">Services up</div>
2316be6Claude444 </div>
445 </div>
446 </div>
0a50474Claude447 </section>
2316be6Claude448
873f13cClaude449 {/* ─── Subscribe notice (post-redirect feedback) ─── */}
450 {subscribeState === "ok" && (
451 <div class="status-notice is-ok" role="status">
452 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
453 Subscribed! Check your inbox to confirm.
454 </div>
455 )}
456 {subscribeState === "confirmed" && (
457 <div class="status-notice is-ok" role="status">
458 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
459 Email confirmed. You'll be notified when incidents are filed.
460 </div>
461 )}
462 {subscribeState === "unsubscribed" && (
463 <div class="status-notice is-warn" role="status">
464 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
465 Unsubscribed successfully.
466 </div>
467 )}
468 {subscribeState === "invalid" && (
469 <div class="status-notice is-err" role="alert">
470 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
471 Invalid email address.
472 </div>
473 )}
474
475 {/* ─── Service uptime badges ─── */}
0a50474Claude476 <section class="status-section" aria-labelledby="status-comp-h">
477 <header class="status-section-head">
478 <div>
479 <p class="status-section-eyebrow">Components</p>
480 <h2 class="status-section-title" id="status-comp-h">
481 Per-service health
482 </h2>
873f13cClaude483 <p class="status-section-sub">
484 Real-time status of every platform surface.
485 </p>
2316be6Claude486 </div>
873f13cClaude487 <span class={"status-count-pill " + (overallOk ? "is-ok" : "is-warn")}>
488 {overallOk ? "All operational" : "Degraded"}
489 </span>
0a50474Claude490 </header>
873f13cClaude491 <table class="status-svc-table" aria-label="Service health">
492 <thead>
493 <tr>
494 <th class="status-svc-th">Service</th>
495 <th class="status-svc-th status-svc-th-right">30-day uptime</th>
496 <th class="status-svc-th status-svc-th-right">Status</th>
497 </tr>
498 </thead>
499 <tbody>
500 {services.map((svc) => (
501 <tr class="status-svc-row">
502 <td class="status-svc-td">
503 <p class="status-svc-name">{svc.name}</p>
504 <p class="status-svc-sub">{svc.sub}</p>
505 </td>
506 <td class="status-svc-td status-svc-td-right">
507 <span class="status-uptime-pct">{svc.uptimePct}</span>
508 </td>
509 <td class="status-svc-td status-svc-td-right">
510 <span
511 class={"status-pill status-pill-" + svc.state}
512 title={svc.label}
513 >
514 <span class="status-pill-dot" aria-hidden="true" />
515 {svc.label}
516 </span>
517 </td>
518 </tr>
519 ))}
520 </tbody>
521 </table>
0a50474Claude522 </section>
523
873f13cClaude524 {/* ─── Incident history ─── */}
525 <section class="status-section" aria-labelledby="status-inc-h">
0a50474Claude526 <header class="status-section-head">
527 <div>
873f13cClaude528 <p class="status-section-eyebrow">History</p>
529 <h2 class="status-section-title" id="status-inc-h">
530 Incident history
0a50474Claude531 </h2>
873f13cClaude532 <p class="status-section-sub">
533 Most recent 10 incidents. Updated as events are filed and resolved.
534 </p>
2316be6Claude535 </div>
873f13cClaude536 {incidentHistory.filter((i) => i.status !== "resolved").length > 0 ? (
537 <span class="status-count-pill is-warn">
538 {incidentHistory.filter((i) => i.status !== "resolved").length} active
539 </span>
540 ) : (
541 <span class="status-count-pill is-ok">All resolved</span>
542 )}
0a50474Claude543 </header>
544 <div class="status-section-body">
873f13cClaude545 {incidentHistory.length === 0 ? (
546 <div class="status-empty">
547 <div class="status-empty-orb" aria-hidden="true" />
548 <div class="status-empty-inner">
549 <p class="status-empty-title">No incidents on record.</p>
550 <p class="status-empty-sub">
551 We haven't filed any incidents yet. When something goes wrong,
552 details appear here with timestamps, severity, and resolution notes.
553 </p>
0a50474Claude554 </div>
555 </div>
873f13cClaude556 ) : (
557 <div class="status-inc-table-wrap">
558 <table class="status-inc-table" aria-label="Incident history">
559 <thead>
560 <tr>
561 <th class="status-inc-th">Date</th>
562 <th class="status-inc-th">Incident</th>
563 <th class="status-inc-th status-inc-th-center">Severity</th>
564 <th class="status-inc-th status-inc-th-center">Status</th>
565 <th class="status-inc-th">Resolved</th>
566 </tr>
567 </thead>
568 <tbody>
569 {incidentHistory.map((inc) => {
570 const sev = severityInfo(inc.severity);
571 const st = incidentStatusInfo(inc.status);
572 return (
573 <tr class="status-inc-tr">
574 <td class="status-inc-td">
575 <time class="status-inc-time" dateTime={inc.startedAt.toISOString()}>
576 {fmtDate(inc.startedAt)}
577 </time>
578 </td>
579 <td class="status-inc-td">
580 <p class="status-inc-title">{inc.title}</p>
581 {inc.body && (
582 <p class="status-inc-body">{inc.body}</p>
583 )}
584 </td>
585 <td class="status-inc-td status-inc-td-center">
586 <span class={"status-sev-badge status-sev-" + sev.cls}>
587 {sev.label}
588 </span>
589 </td>
590 <td class="status-inc-td status-inc-td-center">
591 <span class={"status-pill status-pill-" + st.cls}>
592 <span class="status-pill-dot" aria-hidden="true" />
593 {st.label}
594 </span>
595 </td>
596 <td class="status-inc-td">
597 {inc.resolvedAt ? (
598 <time class="status-inc-time" dateTime={inc.resolvedAt.toISOString()}>
599 {fmtDate(inc.resolvedAt)}
600 </time>
601 ) : (
602 <span class="status-inc-time">—</span>
603 )}
604 </td>
605 </tr>
606 );
607 })}
608 </tbody>
609 </table>
0a50474Claude610 </div>
873f13cClaude611 )}
2316be6Claude612 </div>
0a50474Claude613 </section>
614
873f13cClaude615 {/* ─── Synthetic monitor alerts (last 24h) ─── */}
616 <section class="status-section" aria-labelledby="status-syn-h">
0a50474Claude617 <header class="status-section-head">
618 <div>
619 <p class="status-section-eyebrow">Last 24h</p>
873f13cClaude620 <h2 class="status-section-title" id="status-syn-h">
621 Synthetic monitor
0a50474Claude622 </h2>
623 <p class="status-section-sub">
873f13cClaude624 Automated probes that returned a red result.
0a50474Claude625 </p>
2316be6Claude626 </div>
873f13cClaude627 {recentIncidentRows.length > 0 ? (
0a50474Claude628 <span class="status-count-pill is-warn">
873f13cClaude629 {recentIncidentRows.length} red
0a50474Claude630 </span>
631 ) : (
632 <span class="status-count-pill is-ok">All green</span>
633 )}
634 </header>
635 <div class="status-section-body">
873f13cClaude636 {recentIncidentRows.length === 0 ? (
0a50474Claude637 <div class="status-empty">
638 <div class="status-empty-orb" aria-hidden="true" />
639 <div class="status-empty-inner">
873f13cClaude640 <p class="status-empty-title">No alerts in the last 24h.</p>
0a50474Claude641 <p class="status-empty-sub">
873f13cClaude642 Every synthetic probe is green. We log every red result here
0a50474Claude643 with timestamps and error text.
644 </p>
645 </div>
646 </div>
647 ) : (
873f13cClaude648 <ul class="status-alert-list">
649 {recentIncidentRows.map((r) => (
650 <li class="status-alert-row">
651 <span class="status-alert-dot" aria-hidden="true" />
652 <div class="status-alert-main">
653 <p class="status-alert-name">
0a50474Claude654 <code>{r.name}</code>
655 </p>
873f13cClaude656 <p class="status-alert-err">
0a50474Claude657 {r.error || "(no error message)"}
658 </p>
659 </div>
660 <time class="status-inc-time">
873f13cClaude661 {fmtDate(r.checkedAt)}
0a50474Claude662 </time>
663 </li>
664 ))}
665 </ul>
666 )}
2316be6Claude667 </div>
0a50474Claude668 </section>
669
873f13cClaude670 {/* ─── Platform stats ─── */}
671 <section class="status-section" aria-labelledby="status-stats-h">
672 <header class="status-section-head">
673 <div>
674 <p class="status-section-eyebrow">Numbers</p>
675 <h2 class="status-section-title" id="status-stats-h">
676 Platform stats
677 </h2>
678 </div>
679 </header>
680 <div class="status-section-body">
681 <div class="status-stats-grid">
682 <div class="status-stat">
683 <div class="status-stat-num">{userCount.toLocaleString()}</div>
684 <div class="status-stat-label">Developers</div>
685 </div>
686 <div class="status-stat">
687 <div class="status-stat-num">{repoCount.toLocaleString()}</div>
688 <div class="status-stat-label">Repositories</div>
689 </div>
690 <div class="status-stat">
691 <div class="status-stat-num">
692 {publicRepoCount.toLocaleString()}
693 </div>
694 <div class="status-stat-label">Public repos</div>
695 </div>
696 <div class="status-stat">
697 <div class="status-stat-num">
698 {gateRunCount.toLocaleString()}
699 </div>
700 <div class="status-stat-label">Gate runs</div>
701 </div>
702 <div class="status-stat">
703 <div class="status-stat-num">
704 {greenRate === null ? "—" : `${greenRate.toFixed(1)}%`}
705 </div>
706 <div class="status-stat-label">Green rate</div>
707 </div>
708 <div class="status-stat">
709 <div class="status-stat-num">{fmtUptime(uptimeMs)}</div>
710 <div class="status-stat-label">Uptime</div>
711 </div>
712 </div>
713 </div>
714 </section>
715
0a50474Claude716 {/* ─── Autopilot tick ─── */}
717 <section class="status-section" aria-labelledby="status-tick-h">
718 <header class="status-section-head">
719 <div>
720 <p class="status-section-eyebrow">Autopilot</p>
721 <h2 class="status-section-title" id="status-tick-h">
722 Latest tick
723 </h2>
724 <p class="status-section-sub">
725 Per-task results from the most recent autopilot sweep.
726 </p>
2316be6Claude727 </div>
0a50474Claude728 {tick ? (
729 <span class="status-count-pill">
730 {ticks} ticks this process
731 </span>
732 ) : null}
733 </header>
734 <div class="status-section-body">
735 {tick ? (
736 <ul class="status-tick-list">
737 <li class="status-tick-row">
738 <span class="status-tick-name">Finished</span>
739 <code class="status-tick-val">{tick.finishedAt}</code>
740 </li>
741 <li class="status-tick-row">
742 <span class="status-tick-name">Total ticks this process</span>
743 <code class="status-tick-val">{ticks}</code>
744 </li>
745 {tick.tasks.map((t) => (
746 <li class="status-tick-row">
747 <code class="status-tick-name">{t.name}</code>
748 <span
749 class={"status-tick-val " + (t.ok ? "is-ok" : "is-err")}
750 >
751 {t.ok ? "ok" : `failed: ${t.error || "unknown"}`}
752 <span class="status-tick-ms">{t.durationMs}ms</span>
b1be050CC LABS App753 </span>
0a50474Claude754 </li>
755 ))}
756 </ul>
757 ) : (
758 <div class="status-empty">
759 <div class="status-empty-orb" aria-hidden="true" />
760 <div class="status-empty-inner">
761 <p class="status-empty-title">
762 {autopilotDisabled
763 ? "Autopilot is disabled."
764 : "No ticks yet."}
765 </p>
766 <p class="status-empty-sub">
767 {autopilotDisabled
768 ? "Set AUTOPILOT_DISABLED=0 to re-enable the periodic platform-maintenance loop."
769 : "The first tick runs within 5 minutes of process start. Check back shortly."}
770 </p>
b1be050CC LABS App771 </div>
2316be6Claude772 </div>
0a50474Claude773 )}
2316be6Claude774 </div>
0a50474Claude775 </section>
776
873f13cClaude777 {/* ─── Subscribe to alerts ─── */}
778 <section class="status-section status-subscribe-section" aria-labelledby="status-sub-h">
779 <header class="status-section-head">
780 <div>
781 <p class="status-section-eyebrow">Alerts</p>
782 <h2 class="status-section-title" id="status-sub-h">
783 Subscribe to status alerts
784 </h2>
785 <p class="status-section-sub">
786 Get an email when a new incident is filed or resolved.
787 Unsubscribe any time — one click, no questions asked.
788 </p>
789 </div>
790 </header>
791 <div class="status-section-body">
792 <form
793 method="post"
794 action="/status/subscribe"
795 class="status-sub-form"
796 aria-label="Subscribe to status alerts"
797 >
798 <label class="status-sub-label" for="sub-email">
799 Email address
800 </label>
801 <div class="status-sub-row">
802 <input
803 id="sub-email"
804 class="status-sub-input"
805 type="email"
806 name="email"
807 required
808 placeholder="you@example.com"
809 autocomplete="email"
810 aria-describedby="sub-hint"
811 />
812 <button class="status-sub-btn" type="submit">
813 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
814 Subscribe
815 </button>
816 </div>
817 <p class="status-sub-hint" id="sub-hint">
818 We'll send a confirmation link. No spam, ever.
819 </p>
820 </form>
821 </div>
822 </section>
823
0a50474Claude824 <p class="status-foot">
825 Liveness: <a href="/healthz">/healthz</a> · Readiness:{" "}
826 <a href="/readyz">/readyz</a> · Metrics:{" "}
827 <a href="/metrics">/metrics</a> · Platform JSON:{" "}
2316be6Claude828 <a href="/api/platform-status">/api/platform-status</a>
829 </p>
830 </div>
831 </Layout>
832 );
833});
834
9b07ca9Claude835/**
836 * Shields-style status badge. Reads the latest autopilot tick + DB
837 * reachability and returns an SVG. Embed in READMEs with:
838 * ![status](https://your-host/status.svg)
839 */
840status.get("/status.svg", async (c) => {
841 let dbOk = false;
842 try {
843 await db.execute(sql`SELECT 1`);
844 dbOk = true;
845 } catch {
846 dbOk = false;
847 }
848 const tick = getLastTick();
849 const lastOk = tick ? tick.tasks.every((t) => t.ok) : true;
850 const overall = dbOk && lastOk;
851 const label = "gluecron";
852 const value = overall ? "operational" : "degraded";
853 const fill = overall ? "#2da44e" : "#cf222e";
854
855 const labelW = 70;
856 const valueW = overall ? 78 : 68;
857 const totalW = labelW + valueW;
858 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${label}: ${value}">
859 <linearGradient id="s" x2="0" y2="100%">
860 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
861 <stop offset="1" stop-opacity=".1"/>
862 </linearGradient>
863 <rect width="${totalW}" height="20" rx="3" fill="#555"/>
864 <rect x="${labelW}" width="${valueW}" height="20" rx="3" fill="${fill}"/>
865 <rect width="${totalW}" height="20" rx="3" fill="url(#s)"/>
866 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,sans-serif" font-size="11">
867 <text x="${labelW / 2}" y="15">${label}</text>
868 <text x="${labelW + valueW / 2}" y="15">${value}</text>
869 </g>
870</svg>`;
871 c.header("Content-Type", "image/svg+xml; charset=utf-8");
872 c.header("Cache-Control", "no-cache, max-age=0");
873 return c.body(svg);
874});
875
0a50474Claude876/* ─────────────────────────────────────────────────────────────────────────
877 * Scoped CSS — every class prefixed `.status-` so this surface can't
878 * bleed into the admin status page or any other route.
879 * ───────────────────────────────────────────────────────────────────── */
880const statusStyles = `
eed4684Claude881 .status-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
0a50474Claude882
883 /* ─── Hero ─── */
884 .status-hero {
885 position: relative;
886 margin-bottom: var(--space-5);
887 padding: var(--space-5) var(--space-6);
888 background: var(--bg-elevated);
889 border: 1px solid var(--border);
890 border-radius: 16px;
891 overflow: hidden;
892 }
893 .status-hero::before {
894 content: '';
895 position: absolute;
896 top: 0; left: 0; right: 0;
897 height: 2px;
6fd5915Claude898 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #5f8fa0 70%, transparent 100%);
0a50474Claude899 opacity: 0.75;
900 pointer-events: none;
901 }
902 .status-hero.is-degraded::before {
903 background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fbbf24 70%, transparent 100%);
904 }
905 .status-hero-orb {
906 position: absolute;
907 inset: -20% -10% auto auto;
908 width: 380px; height: 380px;
6fd5915Claude909 background: radial-gradient(circle, rgba(52,211,153,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
0a50474Claude910 filter: blur(80px);
911 opacity: 0.7;
912 pointer-events: none;
913 z-index: 0;
914 }
915 .status-hero.is-degraded .status-hero-orb {
916 background: radial-gradient(circle, rgba(248,113,113,0.22), rgba(251,191,36,0.10) 45%, transparent 70%);
917 }
918 .status-hero-inner { position: relative; z-index: 1; }
919 .status-eyebrow {
920 font-size: 12px;
921 color: var(--text-muted);
922 margin-bottom: var(--space-2);
923 letter-spacing: 0.02em;
924 display: inline-flex;
925 align-items: center;
926 gap: 8px;
927 }
928 .status-eyebrow-pill {
929 display: inline-flex;
930 align-items: center;
931 justify-content: center;
932 width: 18px; height: 18px;
933 border-radius: 6px;
934 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt935 color: var(--green);
0a50474Claude936 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
937 }
938 .status-hero.is-degraded .status-eyebrow-pill {
939 background: rgba(248,113,113,0.14);
e589f77ccantynz-alt940 color: var(--red);
0a50474Claude941 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
942 }
943 .status-title {
944 font-size: clamp(28px, 4vw, 40px);
945 font-family: var(--font-display);
946 font-weight: 800;
947 letter-spacing: -0.028em;
948 line-height: 1.05;
949 margin: 0 0 var(--space-2);
950 color: var(--text-strong);
951 display: flex;
952 align-items: center;
953 gap: 14px;
954 flex-wrap: wrap;
955 }
956 .status-dot-big {
957 display: inline-block;
958 width: 14px; height: 14px;
959 border-radius: 50%;
e589f77ccantynz-alt960 background: var(--green);
0a50474Claude961 box-shadow: 0 0 0 5px rgba(52,211,153,0.18);
962 flex-shrink: 0;
963 }
964 .status-hero.is-degraded .status-dot-big {
e589f77ccantynz-alt965 background: var(--red);
0a50474Claude966 box-shadow: 0 0 0 5px rgba(248,113,113,0.18);
967 }
968 .status-title-grad {
6fd5915Claude969 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #5f8fa0 100%);
0a50474Claude970 -webkit-background-clip: text;
971 background-clip: text;
972 -webkit-text-fill-color: transparent;
973 color: transparent;
974 }
975 .status-title-grad-warn {
976 background-image: linear-gradient(135deg, #fca5a5 0%, #f87171 50%, #fbbf24 100%);
977 }
978 .status-sub {
979 font-size: 15px;
980 color: var(--text-muted);
981 margin: 0 0 var(--space-4);
982 line-height: 1.55;
983 max-width: 620px;
984 }
985
986 /* Hero stats strip */
987 .status-hero-stats {
988 display: grid;
873f13cClaude989 grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
0a50474Claude990 gap: var(--space-3);
991 }
992 .status-hero-stat {
993 padding: 12px 14px;
994 background: rgba(255,255,255,0.025);
995 border: 1px solid var(--border-subtle);
996 border-radius: 10px;
997 }
998 .status-hero-stat-num {
999 font-family: var(--font-display);
1000 font-size: 22px;
1001 font-weight: 700;
1002 color: var(--text-strong);
1003 letter-spacing: -0.02em;
1004 }
1005 .status-hero-stat-label {
1006 font-size: 11px;
1007 color: var(--text-muted);
1008 text-transform: uppercase;
1009 letter-spacing: 0.06em;
1010 margin-top: 2px;
1011 }
1012
873f13cClaude1013 /* ─── Notice banners ─── */
1014 .status-notice {
1015 display: flex;
1016 align-items: center;
1017 gap: 10px;
1018 margin-bottom: var(--space-4);
1019 padding: 12px 16px;
1020 border-radius: 10px;
1021 font-size: 14px;
1022 font-weight: 500;
1023 }
1024 .status-notice.is-ok {
1025 background: rgba(52,211,153,0.10);
e589f77ccantynz-alt1026 color: var(--green);
873f13cClaude1027 border: 1px solid rgba(52,211,153,0.25);
1028 }
1029 .status-notice.is-warn {
1030 background: rgba(251,191,36,0.10);
e589f77ccantynz-alt1031 color: var(--yellow);
873f13cClaude1032 border: 1px solid rgba(251,191,36,0.25);
1033 }
1034 .status-notice.is-err {
1035 background: rgba(248,113,113,0.10);
e589f77ccantynz-alt1036 color: var(--red);
873f13cClaude1037 border: 1px solid rgba(248,113,113,0.25);
1038 }
1039
0a50474Claude1040 /* ─── Section cards ─── */
1041 .status-section {
1042 margin-bottom: var(--space-5);
1043 background: var(--bg-elevated);
1044 border: 1px solid var(--border);
1045 border-radius: 14px;
1046 overflow: hidden;
1047 }
1048 .status-section-head {
1049 padding: var(--space-4) var(--space-5);
1050 border-bottom: 1px solid var(--border);
1051 display: flex;
1052 align-items: flex-start;
1053 justify-content: space-between;
1054 gap: var(--space-3);
1055 flex-wrap: wrap;
1056 }
1057 .status-section-eyebrow {
1058 font-size: 11px;
1059 font-weight: 600;
1060 letter-spacing: 0.08em;
1061 text-transform: uppercase;
1062 color: var(--text-faint);
1063 margin: 0 0 6px;
1064 }
1065 .status-section-title {
1066 margin: 0;
1067 font-family: var(--font-display);
1068 font-size: 17px;
1069 font-weight: 700;
1070 letter-spacing: -0.018em;
1071 color: var(--text-strong);
1072 }
1073 .status-section-sub {
1074 margin: 6px 0 0;
1075 font-size: 12.5px;
1076 color: var(--text-muted);
1077 line-height: 1.5;
1078 }
1079 .status-section-body { padding: var(--space-5); }
1080
873f13cClaude1081 /* ─── Service table ─── */
1082 .status-svc-table {
1083 width: 100%;
1084 border-collapse: collapse;
1085 }
1086 .status-svc-th {
1087 padding: 10px var(--space-5);
1088 font-size: 11px;
1089 font-weight: 600;
1090 text-transform: uppercase;
1091 letter-spacing: 0.06em;
1092 color: var(--text-faint);
1093 text-align: left;
0a50474Claude1094 border-bottom: 1px solid var(--border);
873f13cClaude1095 background: var(--bg-secondary);
0a50474Claude1096 }
873f13cClaude1097 .status-svc-th-right { text-align: right; }
1098 .status-svc-row { border-bottom: 1px solid var(--border); }
0a50474Claude1099 .status-svc-row:last-child { border-bottom: 0; }
873f13cClaude1100 .status-svc-td {
1101 padding: 14px var(--space-5);
1102 vertical-align: middle;
1103 }
1104 .status-svc-td-right { text-align: right; }
0a50474Claude1105 .status-svc-name {
1106 margin: 0;
1107 font-weight: 600;
1108 color: var(--text-strong);
1109 font-size: 14px;
1110 }
1111 .status-svc-sub {
1112 margin: 2px 0 0;
1113 font-size: 12px;
1114 color: var(--text-muted);
1115 }
873f13cClaude1116 .status-uptime-pct {
1117 font-family: var(--font-mono);
1118 font-size: 13px;
e589f77ccantynz-alt1119 color: var(--green);
873f13cClaude1120 font-weight: 600;
1121 }
1122
1123 /* ─── Status pills ─── */
0a50474Claude1124 .status-pill {
1125 display: inline-flex;
1126 align-items: center;
1127 gap: 6px;
1128 padding: 4px 10px;
1129 border-radius: 9999px;
1130 font-size: 11.5px;
1131 font-weight: 600;
1132 letter-spacing: 0.04em;
1133 }
1134 .status-pill-dot {
1135 width: 7px; height: 7px;
1136 border-radius: 9999px;
1137 background: currentColor;
1138 flex-shrink: 0;
1139 }
1140 .status-pill-ok {
1141 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt1142 color: var(--green);
0a50474Claude1143 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1144 }
1145 .status-pill-down {
1146 background: rgba(248,113,113,0.14);
e589f77ccantynz-alt1147 color: var(--red);
0a50474Claude1148 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
1149 }
1150 .status-pill-idle {
1151 background: rgba(110,118,129,0.18);
1152 color: #c9d1d9;
1153 box-shadow: inset 0 0 0 1px rgba(110,118,129,0.40);
1154 }
873f13cClaude1155 .status-pill-warn {
1156 background: rgba(251,191,36,0.14);
e589f77ccantynz-alt1157 color: var(--yellow);
873f13cClaude1158 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
1159 }
1160
1161 /* ─── Severity badges ─── */
1162 .status-sev-badge {
1163 display: inline-block;
1164 padding: 2px 8px;
1165 border-radius: 6px;
1166 font-size: 11px;
1167 font-weight: 700;
1168 text-transform: uppercase;
1169 letter-spacing: 0.06em;
1170 }
1171 .status-sev-minor {
1172 background: rgba(110,118,129,0.18);
1173 color: #c9d1d9;
1174 box-shadow: inset 0 0 0 1px rgba(110,118,129,0.40);
1175 }
1176 .status-sev-major {
1177 background: rgba(251,191,36,0.14);
e589f77ccantynz-alt1178 color: var(--yellow);
873f13cClaude1179 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
1180 }
1181 .status-sev-crit {
1182 background: rgba(248,113,113,0.14);
e589f77ccantynz-alt1183 color: var(--red);
873f13cClaude1184 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
1185 }
1186
1187 /* ─── Incident history table ─── */
1188 .status-inc-table-wrap { overflow-x: auto; }
1189 .status-inc-table {
1190 width: 100%;
1191 border-collapse: collapse;
1192 font-size: 13px;
1193 }
1194 .status-inc-th {
1195 padding: 9px 12px;
1196 font-size: 11px;
1197 font-weight: 600;
1198 text-transform: uppercase;
1199 letter-spacing: 0.06em;
1200 color: var(--text-faint);
1201 text-align: left;
1202 border-bottom: 1px solid var(--border-subtle);
1203 white-space: nowrap;
1204 }
1205 .status-inc-th-center { text-align: center; }
1206 .status-inc-tr { border-bottom: 1px solid var(--border-subtle); }
1207 .status-inc-tr:last-child { border-bottom: 0; }
1208 .status-inc-tr:hover { background: rgba(255,255,255,0.02); }
1209 .status-inc-td {
1210 padding: 12px 12px;
1211 vertical-align: top;
1212 color: var(--text);
1213 }
1214 .status-inc-td-center { text-align: center; vertical-align: middle; }
1215 .status-inc-title {
1216 margin: 0;
1217 font-weight: 600;
1218 color: var(--text-strong);
1219 line-height: 1.4;
1220 }
1221 .status-inc-body {
1222 margin: 4px 0 0;
1223 font-size: 12px;
1224 color: var(--text-muted);
1225 line-height: 1.4;
1226 }
1227 .status-inc-time {
1228 font-family: var(--font-mono);
1229 font-size: 11.5px;
1230 color: var(--text-faint);
1231 white-space: nowrap;
1232 }
1233
1234 /* ─── Synthetic monitor alert list ─── */
1235 .status-alert-list { list-style: none; margin: 0; padding: 0; }
1236 .status-alert-row {
1237 display: flex;
1238 align-items: flex-start;
1239 gap: 12px;
1240 padding: var(--space-3) 0;
1241 border-bottom: 1px solid var(--border-subtle);
1242 }
1243 .status-alert-row:last-child { border-bottom: 0; }
1244 .status-alert-row:first-child { padding-top: 0; }
1245 .status-alert-dot {
1246 width: 8px; height: 8px;
1247 border-radius: 9999px;
e589f77ccantynz-alt1248 background: var(--red);
873f13cClaude1249 box-shadow: 0 0 0 3px rgba(248,113,113,0.18);
1250 margin-top: 6px;
1251 flex-shrink: 0;
1252 }
1253 .status-alert-main { flex: 1; min-width: 0; }
1254 .status-alert-name {
1255 margin: 0;
1256 font-size: 13.5px;
1257 color: var(--text-strong);
1258 }
1259 .status-alert-name code {
1260 font-family: var(--font-mono);
1261 font-size: 12.5px;
1262 background: var(--bg-tertiary);
1263 padding: 1px 6px;
1264 border-radius: 4px;
1265 }
1266 .status-alert-err {
1267 margin: 4px 0 0;
1268 font-size: 12.5px;
1269 color: var(--text-muted);
1270 line-height: 1.5;
1271 }
0a50474Claude1272
1273 /* ─── Platform stats ─── */
1274 .status-stats-grid {
1275 display: grid;
1276 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
1277 gap: var(--space-3);
1278 }
1279 .status-stat {
1280 padding: var(--space-3) var(--space-4);
1281 background: var(--bg-secondary);
1282 border: 1px solid var(--border-subtle);
1283 border-radius: 12px;
1284 text-align: center;
1285 transition: border-color 150ms ease;
1286 }
1287 .status-stat:hover { border-color: var(--border-strong); }
1288 .status-stat-num {
1289 font-family: var(--font-display);
1290 font-size: 24px;
1291 font-weight: 700;
1292 color: var(--text-strong);
1293 letter-spacing: -0.022em;
1294 }
1295 .status-stat-label {
1296 font-size: 11px;
1297 color: var(--text-muted);
1298 text-transform: uppercase;
1299 letter-spacing: 0.06em;
1300 margin-top: 4px;
1301 }
1302
1303 /* ─── Count pills ─── */
1304 .status-count-pill {
1305 display: inline-flex;
1306 align-items: center;
1307 padding: 4px 10px;
1308 border-radius: 9999px;
1309 font-family: var(--font-mono);
1310 font-size: 11.5px;
1311 font-weight: 600;
6fd5915Claude1312 background: rgba(91,110,232,0.10);
0a50474Claude1313 color: #c5b3ff;
6fd5915Claude1314 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
0a50474Claude1315 letter-spacing: 0.02em;
873f13cClaude1316 flex-shrink: 0;
1317 align-self: flex-start;
0a50474Claude1318 }
1319 .status-count-pill.is-ok {
1320 background: rgba(52,211,153,0.12);
e589f77ccantynz-alt1321 color: var(--green);
0a50474Claude1322 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1323 }
1324 .status-count-pill.is-warn {
1325 background: rgba(248,113,113,0.12);
e589f77ccantynz-alt1326 color: var(--red);
0a50474Claude1327 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
1328 }
1329
873f13cClaude1330 /* ─── Subscribe section ─── */
1331 .status-subscribe-section {
6fd5915Claude1332 background: linear-gradient(135deg, rgba(91,110,232,0.06) 0%, rgba(52,211,153,0.04) 100%);
1333 border-color: rgba(91,110,232,0.20);
0a50474Claude1334 }
873f13cClaude1335 .status-sub-form { max-width: 520px; }
1336 .status-sub-label {
1337 display: block;
1338 font-size: 13px;
1339 font-weight: 600;
1340 color: var(--text-strong);
1341 margin-bottom: 8px;
0a50474Claude1342 }
873f13cClaude1343 .status-sub-row {
1344 display: flex;
1345 gap: 8px;
1346 align-items: stretch;
1347 }
1348 .status-sub-input {
1349 flex: 1;
1350 padding: 9px 12px;
1351 background: var(--bg-secondary);
1352 border: 1px solid var(--border);
1353 border-radius: 8px;
0a50474Claude1354 color: var(--text-strong);
873f13cClaude1355 font-size: 14px;
1356 outline: none;
1357 transition: border-color 150ms;
1358 min-width: 0;
0a50474Claude1359 }
873f13cClaude1360 .status-sub-input:focus {
6fd5915Claude1361 border-color: rgba(91,110,232,0.60);
1362 box-shadow: 0 0 0 3px rgba(91,110,232,0.12);
0a50474Claude1363 }
873f13cClaude1364 .status-sub-input::placeholder { color: var(--text-faint); }
1365 .status-sub-btn {
1366 display: inline-flex;
1367 align-items: center;
1368 gap: 7px;
1369 padding: 9px 18px;
6fd5915Claude1370 background: rgba(91,110,232,0.16);
1371 border: 1px solid rgba(91,110,232,0.32);
873f13cClaude1372 border-radius: 8px;
1373 color: #c5b3ff;
1374 font-size: 13.5px;
1375 font-weight: 600;
1376 cursor: pointer;
1377 white-space: nowrap;
1378 transition: background 150ms, border-color 150ms;
0a50474Claude1379 }
873f13cClaude1380 .status-sub-btn:hover {
6fd5915Claude1381 background: rgba(91,110,232,0.26);
1382 border-color: rgba(91,110,232,0.50);
873f13cClaude1383 }
1384 .status-sub-hint {
1385 margin: 8px 0 0;
1386 font-size: 12px;
0a50474Claude1387 color: var(--text-faint);
1388 }
1389
1390 /* ─── Empty state ─── */
1391 .status-empty {
1392 position: relative;
1393 padding: var(--space-6) var(--space-5);
1394 border: 1px dashed var(--border-strong);
1395 border-radius: 14px;
1396 background: rgba(255,255,255,0.02);
1397 text-align: center;
1398 overflow: hidden;
1399 }
1400 .status-empty-orb {
1401 position: absolute;
1402 inset: -40% -10% auto auto;
1403 width: 320px; height: 320px;
6fd5915Claude1404 background: radial-gradient(circle, rgba(52,211,153,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
0a50474Claude1405 filter: blur(70px);
1406 opacity: 0.55;
1407 pointer-events: none;
1408 z-index: 0;
1409 }
1410 .status-empty-inner { position: relative; z-index: 1; }
1411 .status-empty-title {
1412 margin: 0 0 6px;
1413 font-family: var(--font-display);
1414 font-size: 16px;
1415 font-weight: 700;
1416 color: var(--text-strong);
1417 letter-spacing: -0.012em;
1418 }
1419 .status-empty-sub {
1420 margin: 0 auto;
1421 max-width: 460px;
1422 font-size: 13px;
1423 color: var(--text-muted);
1424 line-height: 1.5;
1425 }
1426
1427 /* ─── Autopilot tick list ─── */
1428 .status-tick-list { list-style: none; margin: 0; padding: 0; }
1429 .status-tick-row {
1430 display: flex;
1431 align-items: center;
1432 justify-content: space-between;
1433 gap: 12px;
1434 padding: 10px 0;
1435 border-bottom: 1px solid var(--border-subtle);
1436 font-size: 13px;
1437 }
1438 .status-tick-row:last-child { border-bottom: 0; }
1439 .status-tick-row:first-child { padding-top: 0; }
1440 .status-tick-name {
1441 color: var(--text);
1442 font-size: 13px;
1443 }
1444 code.status-tick-name {
1445 font-family: var(--font-mono);
1446 font-size: 12.5px;
1447 background: var(--bg-tertiary);
1448 padding: 1px 6px;
1449 border-radius: 4px;
1450 color: var(--text-strong);
1451 }
1452 .status-tick-val {
1453 display: inline-flex;
1454 align-items: center;
1455 gap: 10px;
1456 font-family: var(--font-mono);
1457 font-size: 12.5px;
1458 color: var(--text-muted);
1459 }
e589f77ccantynz-alt1460 .status-tick-val.is-ok { color: var(--green); }
1461 .status-tick-val.is-err { color: var(--red); }
0a50474Claude1462 .status-tick-ms { color: var(--text-faint); font-size: 11.5px; }
1463
1464 /* ─── Footer links ─── */
1465 .status-foot {
1466 margin-top: var(--space-5);
1467 padding-top: var(--space-4);
1468 border-top: 1px solid var(--border);
1469 color: var(--text-muted);
1470 font-size: 12.5px;
1471 text-align: center;
1472 }
1473 .status-foot a {
6fd5915Claude1474 color: var(--accent, #5b6ee8);
0a50474Claude1475 text-decoration: none;
1476 }
1477 .status-foot a:hover { text-decoration: underline; }
1478`;
1479
2316be6Claude1480export default status;