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.tsxBlame863 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> {
290 try {
291 const [owner] = await db
292 .select({ id: users.id })
293 .from(users)
294 .where(eq(users.username, "ccantynz"))
295 .limit(1);
296 if (!owner) {
297 return {
298 category: "Auto-merge",
299 name: "main protection",
300 status: "yellow",
301 detail: "Owner user 'ccantynz' not found.",
302 };
303 }
304 const [repo] = await db
305 .select({ id: repositories.id })
306 .from(repositories)
307 .where(
308 and(
309 eq(repositories.ownerId, owner.id),
310 eq(repositories.name, "Gluecron.com")
311 )
312 )
313 .limit(1);
314 if (!repo) {
315 return {
316 category: "Auto-merge",
317 name: "main protection",
318 status: "yellow",
319 detail: "Repository row for ccantynz/Gluecron.com not found.",
320 };
321 }
322 const [bp] = await db
323 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
324 .from(branchProtection)
325 .where(
326 and(
327 eq(branchProtection.repositoryId, repo.id),
328 eq(branchProtection.pattern, "main")
329 )
330 )
331 .limit(1);
332 if (!bp) {
333 return {
334 category: "Auto-merge",
335 name: "main protection",
336 status: "yellow",
337 detail: "No branch_protection row for main yet.",
338 fix: "Visit /ccantynz/Gluecron.com/gates/protection to configure.",
339 };
340 }
341 return {
342 category: "Auto-merge",
343 name: "main protection",
344 status: bp.enableAutoMerge ? "green" : "yellow",
345 detail: bp.enableAutoMerge
346 ? "Auto-merge ENABLED on main."
347 : "Auto-merge DISABLED on main.",
348 fix: bp.enableAutoMerge
349 ? undefined
350 : "Visit /admin/ops to enable.",
351 };
352 } catch (err) {
353 return {
354 category: "Auto-merge",
355 name: "main protection",
356 status: "yellow",
357 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
358 };
359 }
360}
361
362async function checkSyntheticMonitor(): Promise<CheckResult> {
363 try {
364 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
365 const reds = await db
366 .select({ name: syntheticChecks.checkName })
367 .from(syntheticChecks)
368 .where(
369 and(
370 eq(syntheticChecks.status, "red"),
371 gt(syntheticChecks.checkedAt, oneHourAgo)
372 )
373 )
374 .orderBy(desc(syntheticChecks.checkedAt))
375 .limit(10);
376 if (reds.length === 0) {
377 return {
378 category: "Monitor",
379 name: "Synthetic checks (1h)",
380 status: "green",
381 detail: "All synthetic checks green in the last hour.",
382 };
383 }
384 const names = Array.from(new Set(reds.map((r) => r.name))).slice(0, 5);
385 return {
386 category: "Monitor",
387 name: "Synthetic checks (1h)",
388 status: "red",
389 detail: `Red in last hour: ${names.join(", ")}.`,
390 fix: "Open /admin/status for the full row table.",
391 };
392 } catch (err) {
393 return {
394 category: "Monitor",
395 name: "Synthetic checks (1h)",
396 status: "yellow",
397 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
398 };
399 }
400}
401
402function checkSelfHost(): CheckResult {
403 const repo = process.env.SELF_HOST_REPO;
404 if (!repo) {
405 return {
406 category: "Self-host",
407 name: "Bootstrap",
408 status: "yellow",
409 detail: "SELF_HOST_REPO unset — pushes to this repo don't trigger self-deploy.",
410 fix: "Run scripts/self-host-bootstrap.ts on the box and add SELF_HOST_REPO=ccantynz/Gluecron.com to /etc/gluecron.env.",
411 };
412 }
413 return {
414 category: "Self-host",
415 name: "Bootstrap",
416 status: "green",
417 detail: `Self-hosting ${repo} — push to main fires self-deploy.sh.`,
418 };
419}
420
115c66bClaude421// ─── New checks (2026-05-16 reliability sweep) ───────────────────────────
422
423/**
424 * Is the autopilot loop ticking on schedule? If not, half the platform's
425 * self-healing breaks silently — mirror sync, advisory rescans, scheduled
426 * workflows, auto-merge sweep, stale-sweep all skip.
427 */
428function checkAutopilot(): CheckResult {
429 if (process.env.AUTOPILOT_DISABLED === "1") {
430 return {
431 category: "Autopilot",
432 name: "Background loop",
433 status: "yellow",
434 detail: "AUTOPILOT_DISABLED=1 — background maintenance loop is OFF.",
435 fix: "Remove or unset AUTOPILOT_DISABLED in /etc/gluecron.env to re-enable.",
436 };
437 }
438 const total = getTickCount();
439 const tick = getLastTick();
440 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
441 const intervalMs =
442 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
443 ? Number(intervalRaw)
444 : 5 * 60 * 1000;
445 // Allow 2x the interval before flagging — accounts for slow ticks.
446 const staleMs = intervalMs * 2;
447 if (!tick) {
448 if (total === 0) {
449 return {
450 category: "Autopilot",
451 name: "Background loop",
452 status: "yellow",
453 detail: `Loop is enabled but has not ticked yet. First tick fires after ${Math.round(intervalMs / 1000)}s.`,
454 };
455 }
456 return {
457 category: "Autopilot",
458 name: "Background loop",
459 status: "red",
460 detail: `${total} tick(s) recorded but last tick result is missing — loop may have crashed.`,
461 fix: "Check journalctl -u gluecron for [autopilot] errors. Run a tick manually at /admin/autopilot.",
462 };
463 }
464 const finishedAt = new Date(tick.finishedAt).getTime();
465 const ageMs = Date.now() - finishedAt;
466 if (ageMs > staleMs) {
467 return {
468 category: "Autopilot",
469 name: "Background loop",
470 status: "red",
471 detail: `Last tick was ${Math.round(ageMs / 1000)}s ago (interval is ${Math.round(intervalMs / 1000)}s). Loop is stalled.`,
472 fix: "Run a tick manually at /admin/autopilot. Check journalctl for [autopilot] errors.",
473 };
474 }
475 const failed = tick.tasks.filter((t) => !t.ok).length;
476 if (failed > 0) {
477 return {
478 category: "Autopilot",
479 name: "Background loop",
480 status: "yellow",
481 detail: `Loop running but ${failed}/${tick.tasks.length} tasks failed in the last tick.`,
482 fix: "Open /admin/autopilot for the per-task error list.",
483 };
484 }
485 return {
486 category: "Autopilot",
487 name: "Background loop",
488 status: "green",
489 detail: `Ticking on schedule (${total} tick${total === 1 ? "" : "s"} this process; last ${Math.round(ageMs / 1000)}s ago).`,
490 };
491}
492
493/**
494 * When did we last successfully deploy? Stale deploys are an early
495 * warning sign the deploy pipeline is broken silently (which is exactly
496 * what happened on 2026-05-15 — 17 hours of failed deploys, no alert).
497 */
498async function checkRecentDeploy(): Promise<CheckResult> {
499 try {
500 const [latest] = await db
501 .select({
502 sha: platformDeploys.sha,
503 status: platformDeploys.status,
504 startedAt: platformDeploys.startedAt,
505 finishedAt: platformDeploys.finishedAt,
506 error: platformDeploys.error,
507 })
508 .from(platformDeploys)
509 .orderBy(desc(platformDeploys.startedAt))
510 .limit(1);
511 if (!latest) {
512 return {
513 category: "Deploy",
514 name: "Latest deploy",
515 status: "yellow",
516 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.",
517 };
518 }
519 const ref = latest.finishedAt || latest.startedAt;
520 const ageHours = (Date.now() - new Date(ref).getTime()) / (60 * 60 * 1000);
521 const sha7 = (latest.sha || "").slice(0, 7);
522 if (latest.status === "failed") {
523 return {
524 category: "Deploy",
525 name: "Latest deploy",
526 status: "red",
527 detail: `Last deploy (${sha7}) FAILED ${ageHours.toFixed(1)}h ago: ${(latest.error || "no error message").slice(0, 200)}.`,
528 fix: "Open /admin/deploys for the run timeline. Trigger a new deploy after fixing.",
529 };
530 }
531 if (latest.status === "in_progress") {
532 return {
533 category: "Deploy",
534 name: "Latest deploy",
535 status: "yellow",
536 detail: `Deploy in progress (${sha7}, started ${ageHours.toFixed(1)}h ago).`,
537 };
538 }
539 if (latest.status === "succeeded" && ageHours > 48) {
540 return {
541 category: "Deploy",
542 name: "Latest deploy",
543 status: "yellow",
544 detail: `Last deploy was ${sha7} ${ageHours.toFixed(1)}h ago. If you pushed to main since then, the deploy pipeline may have silently failed.`,
545 fix: "Check the GitHub Actions Hetzner deploy run for the latest main commit.",
546 };
547 }
548 return {
549 category: "Deploy",
550 name: "Latest deploy",
551 status: "green",
552 detail: `${sha7} deployed cleanly ${ageHours.toFixed(1)}h ago.`,
553 };
554 } catch (err) {
555 return {
556 category: "Deploy",
557 name: "Latest deploy",
558 status: "yellow",
559 detail: `Couldn't read platform_deploys: ${(err as Error).message.slice(0, 100)}`,
560 };
561 }
562}
563
564/**
565 * Is the workflow worker draining the queue? A backed-up queue or a
566 * stuck queued row means CI gates aren't firing.
567 */
568async function checkWorkflowQueue(): Promise<CheckResult> {
569 try {
570 const [queued] = await db
571 .select({ n: sql<number>`count(*)::int` })
572 .from(workflowRuns)
573 .where(eq(workflowRuns.status, "queued"));
574 const queuedN = Number(queued?.n || 0);
575 const [running] = await db
576 .select({ n: sql<number>`count(*)::int` })
577 .from(workflowRuns)
578 .where(eq(workflowRuns.status, "running"));
579 const runningN = Number(running?.n || 0);
580 if (queuedN > 25) {
581 return {
582 category: "Workflows",
583 name: "Run queue",
584 status: "red",
585 detail: `${queuedN} runs queued (running: ${runningN}). The worker is backed up.`,
586 fix: "Check journalctl for [workflow-runner] errors. Restart gluecron if persistent.",
587 };
588 }
589 if (queuedN > 5) {
590 return {
591 category: "Workflows",
592 name: "Run queue",
593 status: "yellow",
594 detail: `${queuedN} runs queued, ${runningN} running. Worker may be slow.`,
595 };
596 }
597 return {
598 category: "Workflows",
599 name: "Run queue",
600 status: "green",
601 detail: `${queuedN} queued, ${runningN} running.`,
602 };
603 } catch (err) {
604 return {
605 category: "Workflows",
606 name: "Run queue",
607 status: "yellow",
608 detail: `Couldn't read workflow_runs: ${(err as Error).message.slice(0, 100)}`,
609 };
610 }
611}
612
613/**
614 * Crontech deploy webhook secret — without it, the webhook POSTs
615 * unsigned and Crontech rejects with 401, but our hook side never sees
616 * the rejection because the request is fire-and-forget.
617 */
618function checkCrontechWebhook(): CheckResult {
619 const url = process.env.CRONTECH_DEPLOY_URL;
620 const secret = process.env.CRONTECH_HMAC_SECRET;
621 if (!url) {
622 return {
623 category: "Crontech",
624 name: "Deploy webhook",
625 status: "yellow",
626 detail: "CRONTECH_DEPLOY_URL unset — pushes to the Crontech repo don't notify the deploy pipeline.",
627 fix: "Optional integration. Set CRONTECH_DEPLOY_URL + CRONTECH_HMAC_SECRET if you want push-triggered Crontech deploys.",
628 };
629 }
630 if (!secret) {
631 return {
632 category: "Crontech",
633 name: "Deploy webhook",
634 status: "red",
635 detail: "CRONTECH_DEPLOY_URL set but CRONTECH_HMAC_SECRET empty — webhook will be rejected as unsigned.",
636 fix: "Add CRONTECH_HMAC_SECRET to /etc/gluecron.env (match the value configured on Crontech's side).",
637 };
638 }
639 return {
640 category: "Crontech",
641 name: "Deploy webhook",
642 status: "green",
643 detail: `Configured (POST to ${url}).`,
644 };
645}
646
826eccfTest User647// ─── Page handler ────────────────────────────────────────────────────────
648
649function pill(status: CheckStatus): any {
650 const map: Record<CheckStatus, { bg: string; fg: string; label: string }> = {
651 green: { bg: "rgba(52,211,153,0.16)", fg: "#34d399", label: "✓ OK" },
652 yellow: { bg: "rgba(245,158,11,0.16)", fg: "#f59e0b", label: "! WARN" },
653 red: { bg: "rgba(248,113,113,0.16)", fg: "#f87171", label: "× FAIL" },
654 };
655 const s = map[status];
656 return (
657 <span
658 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`}
659 >
660 {s.label}
661 </span>
662 );
663}
664
115c66bClaude665async function runAllChecks(c: any): Promise<CheckResult[]> {
666 return [
826eccfTest User667 checkEmail(),
668 checkAnthropic(),
669 checkGateTest(),
670 checkBuildSha(),
671 checkAppBaseUrl(c),
672 checkDatabase(),
673 await checkMigrations(),
674 await checkAutoMerge(),
675 await checkSyntheticMonitor(),
676 checkSelfHost(),
115c66bClaude677 // 2026-05-16 reliability sweep additions:
678 checkAutopilot(),
679 await checkRecentDeploy(),
680 await checkWorkflowQueue(),
681 checkCrontechWebhook(),
826eccfTest User682 ];
115c66bClaude683}
684
685// JSON endpoint for programmatic monitoring. Same gate as the HTML page
686// (site-admin only) so deploy state isn't public.
687diagnose.get("/admin/diagnose.json", async (c) => {
688 const g = await gate(c);
689 if (g instanceof Response) return g;
690 const results = await runAllChecks(c);
691 const counts = {
692 green: results.filter((r) => r.status === "green").length,
693 yellow: results.filter((r) => r.status === "yellow").length,
694 red: results.filter((r) => r.status === "red").length,
695 };
696 const overall =
697 counts.red > 0 ? "red" : counts.yellow > 0 ? "yellow" : "green";
698 return c.json({
699 ok: true,
700 overall,
701 counts,
702 checks: results,
703 asOf: new Date().toISOString(),
704 });
705});
706
707// /admin/health alias — same handler, friendlier URL. The user expected
708// this to exist; making the expectation reality is cheaper than arguing
709// about naming.
710diagnose.get("/admin/health", async (c) => {
711 return c.redirect("/admin/diagnose");
712});
713
714diagnose.get("/admin/diagnose", async (c) => {
715 const g = await gate(c);
716 if (g instanceof Response) return g;
717 const { user } = g;
718
719 const results: CheckResult[] = await runAllChecks(c);
826eccfTest User720
721 const counts = {
722 green: results.filter((r) => r.status === "green").length,
723 yellow: results.filter((r) => r.status === "yellow").length,
724 red: results.filter((r) => r.status === "red").length,
725 };
726 const headline =
727 counts.red > 0
728 ? `${counts.red} failing`
729 : counts.yellow > 0
730 ? `${counts.yellow} needs attention`
731 : "All green";
732 const headlineColor =
733 counts.red > 0
734 ? "var(--red, #cf222e)"
735 : counts.yellow > 0
736 ? "#d97706"
737 : "var(--green, #2da44e)";
738
739 const flash = c.req.query("test_email");
740
741 return c.html(
742 <Layout title="Diagnose — admin" user={user}>
743 <div style="max-width:920px;margin:0 auto;padding:var(--space-5) var(--space-3)">
744 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
745 <h1 style="margin:0">Diagnose</h1>
746 <a href="/admin" class="btn btn-sm">
747 Back to admin
748 </a>
749 </div>
750 <p style="color:var(--text-muted);margin-bottom:var(--space-4)">
751 Read-only health scan of every config knob the platform depends on.
752 One row per check. Green = wired, yellow = degraded, red = broken.
753 </p>
754
755 <div
756 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)`}
757 >
758 <span
759 style={`display:inline-block;width:14px;height:14px;border-radius:50%;background:${headlineColor}`}
760 />
761 <strong style="font-size:18px">{headline}</strong>
762 <span style="color:var(--text-muted);font-size:13px">
763 {counts.green} green · {counts.yellow} warn · {counts.red} fail
764 </span>
765 </div>
766
767 {flash && (
768 <div
769 class={flash === "ok" ? "banner" : "auth-error"}
770 style="margin-bottom:var(--space-4)"
771 >
772 {flash === "ok"
773 ? "Test email dispatched. If the provider is 'log' you'll see it in journalctl, not your inbox."
774 : `Test email failed: ${decodeURIComponent(flash)}`}
775 </div>
776 )}
777
778 <div
779 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:var(--space-4)"
780 >
781 {results.map((r, i) => (
782 <div
783 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`}
784 >
785 <div>
786 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">
787 {r.category}
788 </div>
789 <div style="font-weight:600;font-size:14px;margin-top:2px">
790 {r.name}
791 </div>
792 </div>
793 <div>{pill(r.status)}</div>
794 <div>
795 <div style="font-size:13px;line-height:1.45">{r.detail}</div>
796 {r.fix && (
797 <div
798 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
799 >
800 → {r.fix}
801 </div>
802 )}
803 </div>
804 </div>
805 ))}
806 </div>
807
808 <div
809 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4)"
810 >
811 <h3
812 style="margin:0 0 var(--space-2) 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
813 >
814 Test email delivery
815 </h3>
816 <p style="font-size:13px;color:var(--text-muted);margin:0 0 var(--space-3) 0">
817 Fires a one-line test email to <strong>{user.email}</strong> using
818 the configured provider. If EMAIL_PROVIDER=log it appears in
819 journalctl; if resend, in your inbox in &lt;30s.
820 </p>
821 <form method="post" action="/admin/diagnose/test-email" style="margin:0">
822 <input
823 type="hidden"
824 name="_csrf"
825 value={(c.get("csrfToken") as string | undefined) || ""}
826 />
827 <button type="submit" class="btn btn-sm btn-primary">
828 Send test email
829 </button>
830 </form>
831 </div>
832 </div>
833 </Layout>
834 );
835});
836
837diagnose.post("/admin/diagnose/test-email", async (c) => {
838 const g = await gate(c);
839 if (g instanceof Response) return g;
840 const { user } = g;
841 if (!user.email) {
842 return c.redirect(
843 `/admin/diagnose?test_email=${encodeURIComponent("admin has no email on record")}`
844 );
845 }
846 const stamp = new Date().toISOString();
847 const result = await sendEmail({
848 to: user.email,
849 subject: "Gluecron — diagnose test email",
850 text:
851 `This is a test email from /admin/diagnose at ${stamp}.\n\n` +
852 `If you received this in your inbox, EMAIL_PROVIDER=resend is wired correctly.\n` +
853 `If you only see it in journalctl, EMAIL_PROVIDER is still 'log'.\n`,
854 });
855 if (!result.ok) {
856 return c.redirect(
857 `/admin/diagnose?test_email=${encodeURIComponent(result.error || result.skipped || "unknown failure")}`
858 );
859 }
860 return c.redirect("/admin/diagnose?test_email=ok");
861});
862
863export default diagnose;