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

admin-diagnose.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.

admin-diagnose.tsxBlame875 lines · 2 contributors
826eccfTest User1/**
2 * /admin/diagnose — comprehensive AI health scan.
3 *
4 * Single page the site admin opens to see, at a glance, every config knob
5 * the platform depends on and whether it is wired up. Each row is one
6 * check; status is green / yellow / red with a one-line "what to do".
7 *
8 * Categories covered:
9 * - Email delivery (EMAIL_PROVIDER, RESEND_API_KEY)
10 * - AI (ANTHROPIC_API_KEY presence)
11 * - GateTest integration (URL + API key)
12 * - Service worker SHA (BUILD_SHA pinned vs dev-stable fallback)
13 * - Database (DATABASE_URL well-formed, latest migration applied)
14 * - Canonical URL (APP_BASE_URL matches request host)
15 * - Self-host (SELF_HOST_REPO declared + post-receive hook present)
16 * - Auto-merge (branch_protection.enable_auto_merge for main)
17 * - Synthetic monitor (any RED checks in last hour)
18 * - Email smoke (POST /admin/diagnose/test-email fires a test)
19 *
20 * Gating: requireAuth + isSiteAdmin via the same gate() pattern as
21 * /admin/ops + /admin/status. No data leaks to non-admins.
22 */
23
24import { Hono } from "hono";
115c66bClaude25import { eq, and, desc, gt, sql } from "drizzle-orm";
826eccfTest User26import { readdir } from "fs/promises";
27import { join } from "path";
28import {
29 branchProtection,
30 repositories,
31 syntheticChecks,
32 users,
115c66bClaude33 workflowRuns,
826eccfTest User34} from "../db/schema";
115c66bClaude35import { platformDeploys } from "../db/schema-deploys";
826eccfTest User36import { db } from "../db";
37import { Layout } from "../views/layout";
38import { softAuth } from "../middleware/auth";
39import type { AuthEnv } from "../middleware/auth";
40import { isSiteAdmin } from "../lib/admin";
41import { config } from "../lib/config";
42import { sendEmail } from "../lib/email";
43import { latestMigration } from "../lib/post-deploy-smoke";
115c66bClaude44import { getLastTick, getTickCount } from "../lib/autopilot";
826eccfTest User45
46type CheckStatus = "green" | "yellow" | "red";
47
48interface CheckResult {
49 category: string;
50 name: string;
51 status: CheckStatus;
52 detail: string;
53 fix?: string;
54}
55
56const diagnose = new Hono<AuthEnv>();
57diagnose.use("*", softAuth);
58
59async function gate(c: any): Promise<{ user: any } | Response> {
60 const user = c.get("user");
61 if (!user) return c.redirect("/login?next=/admin/diagnose");
62 if (!(await isSiteAdmin(user.id))) {
63 return c.html(
64 <Layout title="Forbidden" user={user}>
65 <div class="empty-state">
66 <h2>403 — Not a site admin</h2>
67 <p>You don't have permission to view this page.</p>
68 </div>
69 </Layout>,
70 403
71 );
72 }
73 return { user };
74}
75
76// ─── Individual checks ───────────────────────────────────────────────────
77
78function checkEmail(): CheckResult {
79 if (config.emailProvider !== "resend") {
80 return {
81 category: "Email",
82 name: "Provider",
83 status: "red",
84 detail: `EMAIL_PROVIDER=${config.emailProvider} — verification + magic-link emails go to stderr, not inboxes.`,
85 fix: "Set EMAIL_PROVIDER=resend in /etc/gluecron.env, then `systemctl restart gluecron`.",
86 };
87 }
88 if (!config.resendApiKey) {
89 return {
90 category: "Email",
91 name: "Provider",
92 status: "red",
93 detail: "EMAIL_PROVIDER=resend but RESEND_API_KEY is empty — every send will fail.",
94 fix: "Add RESEND_API_KEY=re_xxx to /etc/gluecron.env, then restart.",
95 };
96 }
97 return {
98 category: "Email",
99 name: "Provider",
100 status: "green",
101 detail: `Resend wired (from: ${config.emailFrom}). Use the test button below to confirm.`,
102 };
103}
104
105function checkAnthropic(): CheckResult {
106 if (!config.anthropicApiKey) {
107 return {
108 category: "AI",
109 name: "Anthropic API key",
110 status: "yellow",
111 detail: "ANTHROPIC_API_KEY unset — AI PR review + AI deploy-failure analysis disabled.",
112 fix: "Add ANTHROPIC_API_KEY=sk-ant-xxx to /etc/gluecron.env.",
113 };
114 }
115 return {
116 category: "AI",
117 name: "Anthropic API key",
118 status: "green",
119 detail: `Key present (length ${config.anthropicApiKey.length}).`,
120 };
121}
122
123function checkGateTest(): CheckResult {
124 const hasUrl = !!process.env.GATETEST_URL;
125 const hasKey = !!process.env.GATETEST_API_KEY;
126 if (!hasUrl && !hasKey) {
127 return {
128 category: "GateTest",
129 name: "Scanner integration",
130 status: "yellow",
131 detail: "Unconfigured — push-time GateTest scans skip silently.",
132 fix: "Set GATETEST_URL + GATETEST_API_KEY in /etc/gluecron.env to enable per-push scans.",
133 };
134 }
135 if (hasUrl && !hasKey) {
136 return {
137 category: "GateTest",
138 name: "Scanner integration",
139 status: "red",
140 detail: "GATETEST_URL set but GATETEST_API_KEY empty — calls will 401.",
141 fix: "Add GATETEST_API_KEY to /etc/gluecron.env.",
142 };
143 }
144 return {
145 category: "GateTest",
146 name: "Scanner integration",
147 status: "green",
148 detail: `Configured — pushes POST to ${process.env.GATETEST_URL}.`,
149 };
150}
151
152function checkBuildSha(): CheckResult {
153 const sha = process.env.BUILD_SHA?.trim();
154 if (sha) {
155 return {
156 category: "Deploy",
157 name: "BUILD_SHA pinned",
158 status: "green",
159 detail: `${sha.slice(0, 12)} — service worker rotates per deploy.`,
160 };
161 }
162 return {
163 category: "Deploy",
164 name: "BUILD_SHA pinned",
165 status: "yellow",
166 detail: "BUILD_SHA unset — falling back to dev-stable. Browsers won't see new deploys reflected in the SW cache.",
167 fix: "Latest scripts/self-deploy.sh + hetzner-deploy.yml pin this automatically; trigger a deploy.",
168 };
169}
170
171function checkAppBaseUrl(c: any): CheckResult {
172 const expected = config.appBaseUrl;
173 const host = c.req.header("host") || "";
174 const proto =
175 c.req.header("x-forwarded-proto") ||
176 (c.req.url.startsWith("https://") ? "https" : "http");
177 const actual = `${proto}://${host}`;
178 if (!expected || expected === "http://localhost:3000") {
179 return {
180 category: "Config",
181 name: "APP_BASE_URL canonical",
182 status: "yellow",
183 detail: `APP_BASE_URL is "${expected}" — outbound email links + WebAuthn origin will be wrong.`,
184 fix: "Set APP_BASE_URL=https://gluecron.com in /etc/gluecron.env.",
185 };
186 }
187 if (host && !expected.endsWith(host)) {
188 return {
189 category: "Config",
190 name: "APP_BASE_URL canonical",
191 status: "yellow",
192 detail: `APP_BASE_URL=${expected} but request arrived at ${actual}. WebAuthn passkeys issued for one host can't be used at the other.`,
193 fix: "Align APP_BASE_URL with the host you actually serve from.",
194 };
195 }
196 return {
197 category: "Config",
198 name: "APP_BASE_URL canonical",
199 status: "green",
200 detail: expected,
201 };
202}
203
204function checkDatabase(): CheckResult {
205 const url = config.databaseUrl;
206 if (!url) {
207 return {
208 category: "Database",
209 name: "Connection string",
210 status: "red",
211 detail: "DATABASE_URL unset — every page that queries the DB will 500.",
212 fix: "Set DATABASE_URL in /etc/gluecron.env.",
213 };
214 }
215 let masked = url;
216 try {
217 const u = new URL(url);
218 masked = `${u.protocol}//${u.username ? "***" : ""}@${u.host}${u.pathname}`;
219 } catch {
220 // unparseable
221 return {
222 category: "Database",
223 name: "Connection string",
224 status: "red",
225 detail: "DATABASE_URL is not a valid URL.",
226 fix: "Fix the URL format: postgres://user:pass@host:port/dbname",
227 };
228 }
229 return {
230 category: "Database",
231 name: "Connection string",
232 status: "green",
233 detail: masked,
234 };
235}
236
237async function checkMigrations(): Promise<CheckResult> {
238 try {
239 const drizzleDir = join(process.cwd(), "drizzle");
240 const files = (await readdir(drizzleDir)).filter((f) => f.endsWith(".sql"));
241 const latest = latestMigration(files);
242 if (!latest) {
243 return {
244 category: "Database",
245 name: "Migrations applied",
246 status: "yellow",
247 detail: "No migration files found in drizzle/.",
248 };
249 }
250 const rows = (await db.execute(
251 `SELECT name FROM _migrations ORDER BY name DESC LIMIT 1` as never
252 )) as any;
253 const list = rows?.rows ?? (Array.isArray(rows) ? rows : []);
254 const applied: string | undefined = list[0]?.name;
255 if (!applied) {
256 return {
257 category: "Database",
258 name: "Migrations applied",
259 status: "red",
260 detail: "_migrations table is empty.",
261 fix: "Run `bun run db:migrate` on the box.",
262 };
263 }
264 if (applied !== latest) {
265 return {
266 category: "Database",
267 name: "Migrations applied",
268 status: "red",
269 detail: `DB at ${applied}, drizzle/ has ${latest}.`,
270 fix: "Run `bun run db:migrate` on the box, or redeploy (the workflow runs it).",
271 };
272 }
273 return {
274 category: "Database",
275 name: "Migrations applied",
276 status: "green",
277 detail: `Latest: ${applied}`,
278 };
279 } catch (err) {
280 return {
281 category: "Database",
282 name: "Migrations applied",
283 status: "yellow",
284 detail: `Couldn't read migration state: ${(err as Error).message.slice(0, 100)}`,
285 };
286 }
287}
288
289async function checkAutoMerge(): Promise<CheckResult> {
a3b6378Claude290 // Resolve which repo to check. SELF_HOST_REPO is the canonical
291 // "this is the platform's own repo" pointer — falling back to
292 // `ccantynz/Gluecron.com` keeps the legacy default behaviour.
293 // Without this, the check used to hardcode `ccantynz` and report
294 // "Owner not found" for installs where the canonical owner is
295 // `ccantynz-alt` or anything else.
296 const selfRepo = process.env.SELF_HOST_REPO || "ccantynz/Gluecron.com";
297 const [ownerName, repoName] = selfRepo.includes("/")
298 ? selfRepo.split("/")
299 : [selfRepo, "Gluecron.com"];
826eccfTest User300 try {
301 const [owner] = await db
302 .select({ id: users.id })
303 .from(users)
a3b6378Claude304 .where(eq(users.username, ownerName))
826eccfTest User305 .limit(1);
306 if (!owner) {
307 return {
308 category: "Auto-merge",
309 name: "main protection",
310 status: "yellow",
a3b6378Claude311 detail: `Owner user '${ownerName}' not found in users table (looked up via SELF_HOST_REPO).`,
312 fix: "Set SELF_HOST_REPO=<actual-owner>/<repo> in /etc/gluecron.env, or register the owner.",
826eccfTest User313 };
314 }
315 const [repo] = await db
316 .select({ id: repositories.id })
317 .from(repositories)
318 .where(
319 and(
320 eq(repositories.ownerId, owner.id),
a3b6378Claude321 eq(repositories.name, repoName)
826eccfTest User322 )
323 )
324 .limit(1);
325 if (!repo) {
326 return {
327 category: "Auto-merge",
328 name: "main protection",
329 status: "yellow",
a3b6378Claude330 detail: `Repository row for ${ownerName}/${repoName} not found. Either the platform repo isn't registered in its own DB, or SELF_HOST_REPO points at the wrong owner/name.`,
331 fix: `Create the repo at /new (owner=${ownerName}, name=${repoName}), or correct SELF_HOST_REPO in /etc/gluecron.env.`,
826eccfTest User332 };
333 }
334 const [bp] = await db
335 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
336 .from(branchProtection)
337 .where(
338 and(
339 eq(branchProtection.repositoryId, repo.id),
340 eq(branchProtection.pattern, "main")
341 )
342 )
343 .limit(1);
344 if (!bp) {
345 return {
346 category: "Auto-merge",
347 name: "main protection",
348 status: "yellow",
349 detail: "No branch_protection row for main yet.",
a3b6378Claude350 fix: `Visit /${ownerName}/${repoName}/gates/protection to configure.`,
826eccfTest User351 };
352 }
353 return {
354 category: "Auto-merge",
355 name: "main protection",
356 status: bp.enableAutoMerge ? "green" : "yellow",
357 detail: bp.enableAutoMerge
358 ? "Auto-merge ENABLED on main."
359 : "Auto-merge DISABLED on main.",
360 fix: bp.enableAutoMerge
361 ? undefined
362 : "Visit /admin/ops to enable.",
363 };
364 } catch (err) {
365 return {
366 category: "Auto-merge",
367 name: "main protection",
368 status: "yellow",
369 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
370 };
371 }
372}
373
374async function checkSyntheticMonitor(): Promise<CheckResult> {
375 try {
376 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
377 const reds = await db
378 .select({ name: syntheticChecks.checkName })
379 .from(syntheticChecks)
380 .where(
381 and(
382 eq(syntheticChecks.status, "red"),
383 gt(syntheticChecks.checkedAt, oneHourAgo)
384 )
385 )
386 .orderBy(desc(syntheticChecks.checkedAt))
387 .limit(10);
388 if (reds.length === 0) {
389 return {
390 category: "Monitor",
391 name: "Synthetic checks (1h)",
392 status: "green",
393 detail: "All synthetic checks green in the last hour.",
394 };
395 }
396 const names = Array.from(new Set(reds.map((r) => r.name))).slice(0, 5);
397 return {
398 category: "Monitor",
399 name: "Synthetic checks (1h)",
400 status: "red",
401 detail: `Red in last hour: ${names.join(", ")}.`,
402 fix: "Open /admin/status for the full row table.",
403 };
404 } catch (err) {
405 return {
406 category: "Monitor",
407 name: "Synthetic checks (1h)",
408 status: "yellow",
409 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
410 };
411 }
412}
413
414function checkSelfHost(): CheckResult {
415 const repo = process.env.SELF_HOST_REPO;
416 if (!repo) {
417 return {
418 category: "Self-host",
419 name: "Bootstrap",
420 status: "yellow",
421 detail: "SELF_HOST_REPO unset — pushes to this repo don't trigger self-deploy.",
422 fix: "Run scripts/self-host-bootstrap.ts on the box and add SELF_HOST_REPO=ccantynz/Gluecron.com to /etc/gluecron.env.",
423 };
424 }
425 return {
426 category: "Self-host",
427 name: "Bootstrap",
428 status: "green",
429 detail: `Self-hosting ${repo} — push to main fires self-deploy.sh.`,
430 };
431}
432
115c66bClaude433// ─── New checks (2026-05-16 reliability sweep) ───────────────────────────
434
435/**
436 * Is the autopilot loop ticking on schedule? If not, half the platform's
437 * self-healing breaks silently — mirror sync, advisory rescans, scheduled
438 * workflows, auto-merge sweep, stale-sweep all skip.
439 */
440function checkAutopilot(): CheckResult {
441 if (process.env.AUTOPILOT_DISABLED === "1") {
442 return {
443 category: "Autopilot",
444 name: "Background loop",
445 status: "yellow",
446 detail: "AUTOPILOT_DISABLED=1 — background maintenance loop is OFF.",
447 fix: "Remove or unset AUTOPILOT_DISABLED in /etc/gluecron.env to re-enable.",
448 };
449 }
450 const total = getTickCount();
451 const tick = getLastTick();
452 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
453 const intervalMs =
454 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
455 ? Number(intervalRaw)
456 : 5 * 60 * 1000;
457 // Allow 2x the interval before flagging — accounts for slow ticks.
458 const staleMs = intervalMs * 2;
459 if (!tick) {
460 if (total === 0) {
461 return {
462 category: "Autopilot",
463 name: "Background loop",
464 status: "yellow",
465 detail: `Loop is enabled but has not ticked yet. First tick fires after ${Math.round(intervalMs / 1000)}s.`,
466 };
467 }
468 return {
469 category: "Autopilot",
470 name: "Background loop",
471 status: "red",
472 detail: `${total} tick(s) recorded but last tick result is missing — loop may have crashed.`,
473 fix: "Check journalctl -u gluecron for [autopilot] errors. Run a tick manually at /admin/autopilot.",
474 };
475 }
476 const finishedAt = new Date(tick.finishedAt).getTime();
477 const ageMs = Date.now() - finishedAt;
478 if (ageMs > staleMs) {
479 return {
480 category: "Autopilot",
481 name: "Background loop",
482 status: "red",
483 detail: `Last tick was ${Math.round(ageMs / 1000)}s ago (interval is ${Math.round(intervalMs / 1000)}s). Loop is stalled.`,
484 fix: "Run a tick manually at /admin/autopilot. Check journalctl for [autopilot] errors.",
485 };
486 }
487 const failed = tick.tasks.filter((t) => !t.ok).length;
488 if (failed > 0) {
489 return {
490 category: "Autopilot",
491 name: "Background loop",
492 status: "yellow",
493 detail: `Loop running but ${failed}/${tick.tasks.length} tasks failed in the last tick.`,
494 fix: "Open /admin/autopilot for the per-task error list.",
495 };
496 }
497 return {
498 category: "Autopilot",
499 name: "Background loop",
500 status: "green",
501 detail: `Ticking on schedule (${total} tick${total === 1 ? "" : "s"} this process; last ${Math.round(ageMs / 1000)}s ago).`,
502 };
503}
504
505/**
506 * When did we last successfully deploy? Stale deploys are an early
507 * warning sign the deploy pipeline is broken silently (which is exactly
508 * what happened on 2026-05-15 — 17 hours of failed deploys, no alert).
509 */
510async function checkRecentDeploy(): Promise<CheckResult> {
511 try {
512 const [latest] = await db
513 .select({
514 sha: platformDeploys.sha,
515 status: platformDeploys.status,
516 startedAt: platformDeploys.startedAt,
517 finishedAt: platformDeploys.finishedAt,
518 error: platformDeploys.error,
519 })
520 .from(platformDeploys)
521 .orderBy(desc(platformDeploys.startedAt))
522 .limit(1);
523 if (!latest) {
524 return {
525 category: "Deploy",
526 name: "Latest deploy",
527 status: "yellow",
528 detail: "No deploys recorded yet. The hetzner-deploy.yml workflow posts events to /api/events/deploy/* — set DEPLOY_EVENT_TOKEN in the workflow env to enable.",
529 };
530 }
531 const ref = latest.finishedAt || latest.startedAt;
532 const ageHours = (Date.now() - new Date(ref).getTime()) / (60 * 60 * 1000);
533 const sha7 = (latest.sha || "").slice(0, 7);
534 if (latest.status === "failed") {
535 return {
536 category: "Deploy",
537 name: "Latest deploy",
538 status: "red",
539 detail: `Last deploy (${sha7}) FAILED ${ageHours.toFixed(1)}h ago: ${(latest.error || "no error message").slice(0, 200)}.`,
540 fix: "Open /admin/deploys for the run timeline. Trigger a new deploy after fixing.",
541 };
542 }
543 if (latest.status === "in_progress") {
544 return {
545 category: "Deploy",
546 name: "Latest deploy",
547 status: "yellow",
548 detail: `Deploy in progress (${sha7}, started ${ageHours.toFixed(1)}h ago).`,
549 };
550 }
551 if (latest.status === "succeeded" && ageHours > 48) {
552 return {
553 category: "Deploy",
554 name: "Latest deploy",
555 status: "yellow",
556 detail: `Last deploy was ${sha7} ${ageHours.toFixed(1)}h ago. If you pushed to main since then, the deploy pipeline may have silently failed.`,
557 fix: "Check the GitHub Actions Hetzner deploy run for the latest main commit.",
558 };
559 }
560 return {
561 category: "Deploy",
562 name: "Latest deploy",
563 status: "green",
564 detail: `${sha7} deployed cleanly ${ageHours.toFixed(1)}h ago.`,
565 };
566 } catch (err) {
567 return {
568 category: "Deploy",
569 name: "Latest deploy",
570 status: "yellow",
571 detail: `Couldn't read platform_deploys: ${(err as Error).message.slice(0, 100)}`,
572 };
573 }
574}
575
576/**
577 * Is the workflow worker draining the queue? A backed-up queue or a
578 * stuck queued row means CI gates aren't firing.
579 */
580async function checkWorkflowQueue(): Promise<CheckResult> {
581 try {
582 const [queued] = await db
583 .select({ n: sql<number>`count(*)::int` })
584 .from(workflowRuns)
585 .where(eq(workflowRuns.status, "queued"));
586 const queuedN = Number(queued?.n || 0);
587 const [running] = await db
588 .select({ n: sql<number>`count(*)::int` })
589 .from(workflowRuns)
590 .where(eq(workflowRuns.status, "running"));
591 const runningN = Number(running?.n || 0);
592 if (queuedN > 25) {
593 return {
594 category: "Workflows",
595 name: "Run queue",
596 status: "red",
597 detail: `${queuedN} runs queued (running: ${runningN}). The worker is backed up.`,
598 fix: "Check journalctl for [workflow-runner] errors. Restart gluecron if persistent.",
599 };
600 }
601 if (queuedN > 5) {
602 return {
603 category: "Workflows",
604 name: "Run queue",
605 status: "yellow",
606 detail: `${queuedN} runs queued, ${runningN} running. Worker may be slow.`,
607 };
608 }
609 return {
610 category: "Workflows",
611 name: "Run queue",
612 status: "green",
613 detail: `${queuedN} queued, ${runningN} running.`,
614 };
615 } catch (err) {
616 return {
617 category: "Workflows",
618 name: "Run queue",
619 status: "yellow",
620 detail: `Couldn't read workflow_runs: ${(err as Error).message.slice(0, 100)}`,
621 };
622 }
623}
624
625/**
626 * Crontech deploy webhook secret — without it, the webhook POSTs
627 * unsigned and Crontech rejects with 401, but our hook side never sees
628 * the rejection because the request is fire-and-forget.
629 */
630function checkCrontechWebhook(): CheckResult {
631 const url = process.env.CRONTECH_DEPLOY_URL;
632 const secret = process.env.CRONTECH_HMAC_SECRET;
633 if (!url) {
634 return {
635 category: "Crontech",
636 name: "Deploy webhook",
637 status: "yellow",
638 detail: "CRONTECH_DEPLOY_URL unset — pushes to the Crontech repo don't notify the deploy pipeline.",
639 fix: "Optional integration. Set CRONTECH_DEPLOY_URL + CRONTECH_HMAC_SECRET if you want push-triggered Crontech deploys.",
640 };
641 }
642 if (!secret) {
643 return {
644 category: "Crontech",
645 name: "Deploy webhook",
646 status: "red",
647 detail: "CRONTECH_DEPLOY_URL set but CRONTECH_HMAC_SECRET empty — webhook will be rejected as unsigned.",
648 fix: "Add CRONTECH_HMAC_SECRET to /etc/gluecron.env (match the value configured on Crontech's side).",
649 };
650 }
651 return {
652 category: "Crontech",
653 name: "Deploy webhook",
654 status: "green",
655 detail: `Configured (POST to ${url}).`,
656 };
657}
658
826eccfTest User659// ─── Page handler ────────────────────────────────────────────────────────
660
661function pill(status: CheckStatus): any {
662 const map: Record<CheckStatus, { bg: string; fg: string; label: string }> = {
663 green: { bg: "rgba(52,211,153,0.16)", fg: "#34d399", label: "✓ OK" },
664 yellow: { bg: "rgba(245,158,11,0.16)", fg: "#f59e0b", label: "! WARN" },
665 red: { bg: "rgba(248,113,113,0.16)", fg: "#f87171", label: "× FAIL" },
666 };
667 const s = map[status];
668 return (
669 <span
670 style={`display:inline-flex;align-items:center;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${s.bg};color:${s.fg};white-space:nowrap`}
671 >
672 {s.label}
673 </span>
674 );
675}
676
115c66bClaude677async function runAllChecks(c: any): Promise<CheckResult[]> {
678 return [
826eccfTest User679 checkEmail(),
680 checkAnthropic(),
681 checkGateTest(),
682 checkBuildSha(),
683 checkAppBaseUrl(c),
684 checkDatabase(),
685 await checkMigrations(),
686 await checkAutoMerge(),
687 await checkSyntheticMonitor(),
688 checkSelfHost(),
115c66bClaude689 // 2026-05-16 reliability sweep additions:
690 checkAutopilot(),
691 await checkRecentDeploy(),
692 await checkWorkflowQueue(),
693 checkCrontechWebhook(),
826eccfTest User694 ];
115c66bClaude695}
696
697// JSON endpoint for programmatic monitoring. Same gate as the HTML page
698// (site-admin only) so deploy state isn't public.
699diagnose.get("/admin/diagnose.json", async (c) => {
700 const g = await gate(c);
701 if (g instanceof Response) return g;
702 const results = await runAllChecks(c);
703 const counts = {
704 green: results.filter((r) => r.status === "green").length,
705 yellow: results.filter((r) => r.status === "yellow").length,
706 red: results.filter((r) => r.status === "red").length,
707 };
708 const overall =
709 counts.red > 0 ? "red" : counts.yellow > 0 ? "yellow" : "green";
710 return c.json({
711 ok: true,
712 overall,
713 counts,
714 checks: results,
715 asOf: new Date().toISOString(),
716 });
717});
718
719// /admin/health alias — same handler, friendlier URL. The user expected
720// this to exist; making the expectation reality is cheaper than arguing
721// about naming.
722diagnose.get("/admin/health", async (c) => {
723 return c.redirect("/admin/diagnose");
724});
725
726diagnose.get("/admin/diagnose", async (c) => {
727 const g = await gate(c);
728 if (g instanceof Response) return g;
729 const { user } = g;
730
731 const results: CheckResult[] = await runAllChecks(c);
826eccfTest User732
733 const counts = {
734 green: results.filter((r) => r.status === "green").length,
735 yellow: results.filter((r) => r.status === "yellow").length,
736 red: results.filter((r) => r.status === "red").length,
737 };
738 const headline =
739 counts.red > 0
740 ? `${counts.red} failing`
741 : counts.yellow > 0
742 ? `${counts.yellow} needs attention`
743 : "All green";
744 const headlineColor =
745 counts.red > 0
746 ? "var(--red, #cf222e)"
747 : counts.yellow > 0
748 ? "#d97706"
749 : "var(--green, #2da44e)";
750
751 const flash = c.req.query("test_email");
752
753 return c.html(
754 <Layout title="Diagnose — admin" user={user}>
755 <div style="max-width:920px;margin:0 auto;padding:var(--space-5) var(--space-3)">
756 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
757 <h1 style="margin:0">Diagnose</h1>
758 <a href="/admin" class="btn btn-sm">
759 Back to admin
760 </a>
761 </div>
762 <p style="color:var(--text-muted);margin-bottom:var(--space-4)">
763 Read-only health scan of every config knob the platform depends on.
764 One row per check. Green = wired, yellow = degraded, red = broken.
765 </p>
766
767 <div
768 style={`background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4);margin-bottom:var(--space-4);display:flex;align-items:center;gap:var(--space-3)`}
769 >
770 <span
771 style={`display:inline-block;width:14px;height:14px;border-radius:50%;background:${headlineColor}`}
772 />
773 <strong style="font-size:18px">{headline}</strong>
774 <span style="color:var(--text-muted);font-size:13px">
775 {counts.green} green · {counts.yellow} warn · {counts.red} fail
776 </span>
777 </div>
778
779 {flash && (
780 <div
781 class={flash === "ok" ? "banner" : "auth-error"}
782 style="margin-bottom:var(--space-4)"
783 >
784 {flash === "ok"
785 ? "Test email dispatched. If the provider is 'log' you'll see it in journalctl, not your inbox."
786 : `Test email failed: ${decodeURIComponent(flash)}`}
787 </div>
788 )}
789
790 <div
791 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:var(--space-4)"
792 >
793 {results.map((r, i) => (
794 <div
795 style={`padding:var(--space-3) var(--space-4);${i < results.length - 1 ? "border-bottom:1px solid var(--border);" : ""}display:grid;grid-template-columns:120px 80px 1fr;gap:var(--space-3);align-items:start`}
796 >
797 <div>
798 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">
799 {r.category}
800 </div>
801 <div style="font-weight:600;font-size:14px;margin-top:2px">
802 {r.name}
803 </div>
804 </div>
805 <div>{pill(r.status)}</div>
806 <div>
807 <div style="font-size:13px;line-height:1.45">{r.detail}</div>
808 {r.fix && (
809 <div
810 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
811 >
812 → {r.fix}
813 </div>
814 )}
815 </div>
816 </div>
817 ))}
818 </div>
819
820 <div
821 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4)"
822 >
823 <h3
824 style="margin:0 0 var(--space-2) 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
825 >
826 Test email delivery
827 </h3>
828 <p style="font-size:13px;color:var(--text-muted);margin:0 0 var(--space-3) 0">
829 Fires a one-line test email to <strong>{user.email}</strong> using
830 the configured provider. If EMAIL_PROVIDER=log it appears in
831 journalctl; if resend, in your inbox in &lt;30s.
832 </p>
833 <form method="post" action="/admin/diagnose/test-email" style="margin:0">
834 <input
835 type="hidden"
836 name="_csrf"
837 value={(c.get("csrfToken") as string | undefined) || ""}
838 />
839 <button type="submit" class="btn btn-sm btn-primary">
840 Send test email
841 </button>
842 </form>
843 </div>
844 </div>
845 </Layout>
846 );
847});
848
849diagnose.post("/admin/diagnose/test-email", async (c) => {
850 const g = await gate(c);
851 if (g instanceof Response) return g;
852 const { user } = g;
853 if (!user.email) {
854 return c.redirect(
855 `/admin/diagnose?test_email=${encodeURIComponent("admin has no email on record")}`
856 );
857 }
858 const stamp = new Date().toISOString();
859 const result = await sendEmail({
860 to: user.email,
861 subject: "Gluecron — diagnose test email",
862 text:
863 `This is a test email from /admin/diagnose at ${stamp}.\n\n` +
864 `If you received this in your inbox, EMAIL_PROVIDER=resend is wired correctly.\n` +
865 `If you only see it in journalctl, EMAIL_PROVIDER is still 'log'.\n`,
866 });
867 if (!result.ok) {
868 return c.redirect(
869 `/admin/diagnose?test_email=${encodeURIComponent(result.error || result.skipped || "unknown failure")}`
870 );
871 }
872 return c.redirect("/admin/diagnose?test_email=ok");
873});
874
875export default diagnose;