Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1337 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.",
b5dd694Claude226 fix: "Fix the URL format: postgres://user:pass@host:port/dbname", // secrets-ok: placeholder example URL, not a real credential
826eccfTest User227 };
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
e7b4b7eClaude677/**
678 * Map a check to its most useful "Fix" deep-link. Pure category-based
679 * routing — keeps the per-check status logic untouched, but lets the new
680 * polish surface a one-click jump to the page that resolves the issue.
681 */
682function fixHrefForCheck(r: CheckResult): { href: string; label: string } | null {
683 // Anything env/secret-driven goes to /admin/integrations — the in-app
684 // editor for /etc/gluecron.env that already gates on the same admin role.
685 const envSurface = { href: "/admin/integrations", label: "Open integrations" };
686 switch (r.category) {
687 case "Email":
688 case "AI":
689 case "GateTest":
690 case "Crontech":
691 case "Config":
692 case "Self-host":
693 return envSurface;
694 case "Auto-merge":
695 return { href: "/admin/ops", label: "Open ops" };
696 case "Monitor":
697 return { href: "/admin/status", label: "Open status" };
698 case "Autopilot":
699 return { href: "/admin/autopilot", label: "Open autopilot" };
700 case "Deploy":
701 return { href: "/admin/deploys", label: "Open deploys" };
702 case "Workflows":
703 return { href: "/admin/ops", label: "Open ops" };
704 case "Database":
705 return envSurface;
706 default:
707 return null;
708 }
709}
710
711/* ─────────────────────────────────────────────────────────────────────────
712 * Scoped CSS — every class prefixed `.health-` so this surface can't
713 * bleed into the wider admin. Mirrors the gradient-hairline hero +
714 * radial-orb + per-card pattern from `admin-integrations` and
715 * `error-page` (the just-shipped 2026 visual recipe).
716 * ───────────────────────────────────────────────────────────────────── */
717const healthStyles = `
eed4684Claude718 .health-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e7b4b7eClaude719
720 .health-hero {
721 position: relative;
722 margin-bottom: var(--space-5);
723 padding: var(--space-5) var(--space-6);
724 background: var(--bg-elevated);
725 border: 1px solid var(--border);
726 border-radius: 16px;
727 overflow: hidden;
728 }
729 .health-hero::before {
730 content: '';
731 position: absolute;
732 top: 0; left: 0; right: 0;
733 height: 2px;
734 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
735 opacity: 0.75;
736 pointer-events: none;
737 }
738 .health-hero-orb {
739 position: absolute;
740 inset: -30% -15% auto auto;
741 width: 460px; height: 460px;
742 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
743 filter: blur(80px);
744 opacity: 0.75;
745 pointer-events: none;
746 z-index: 0;
747 }
748 .health-hero-inner { position: relative; z-index: 1; }
749 .health-hero-top {
750 display: flex;
751 align-items: center;
752 justify-content: space-between;
753 gap: var(--space-3);
754 margin-bottom: var(--space-3);
755 flex-wrap: wrap;
756 }
757 .health-eyebrow {
758 display: inline-flex;
759 align-items: center;
760 gap: 8px;
761 text-transform: uppercase;
762 font-family: var(--font-mono);
763 font-size: 11px;
764 letter-spacing: 0.18em;
765 color: var(--text-muted);
766 font-weight: 600;
767 }
768 .health-eyebrow-dot {
769 width: 8px; height: 8px;
770 border-radius: 9999px;
771 background: linear-gradient(135deg, #8c6dff, #36c5d6);
772 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
773 }
774 .health-back {
775 font-size: 12.5px;
776 color: var(--text-muted);
777 text-decoration: none;
778 padding: 6px 12px;
779 border-radius: 8px;
780 border: 1px solid var(--border-strong, var(--border));
781 background: transparent;
782 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
783 }
784 .health-back:hover {
785 color: var(--text-strong);
786 border-color: rgba(140,109,255,0.45);
787 background: rgba(140,109,255,0.06);
788 text-decoration: none;
789 }
790 .health-title {
791 font-family: var(--font-display);
792 font-size: clamp(28px, 4vw, 40px);
793 font-weight: 800;
794 letter-spacing: -0.028em;
795 line-height: 1.05;
796 margin: 0 0 var(--space-3);
797 color: var(--text-strong);
798 }
799 .health-title-grad {
800 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
801 -webkit-background-clip: text;
802 background-clip: text;
803 -webkit-text-fill-color: transparent;
804 color: transparent;
805 }
806 .health-title-grad.is-warn {
807 background-image: linear-gradient(135deg, #fde68a 0%, #fbbf24 50%, #d97706 100%);
808 }
809 .health-title-grad.is-fail {
810 background-image: linear-gradient(135deg, #fecaca 0%, #f87171 50%, #ef4444 100%);
811 }
812 .health-summary {
813 display: flex;
814 align-items: center;
815 gap: var(--space-3);
816 flex-wrap: wrap;
817 }
818 .health-summary-pill {
819 display: inline-flex;
820 align-items: center;
821 gap: 8px;
822 padding: 6px 14px;
823 border-radius: 9999px;
824 font-size: 13px;
825 font-weight: 600;
826 letter-spacing: -0.005em;
827 }
828 .health-summary-pill .dot {
829 width: 9px; height: 9px;
830 border-radius: 9999px;
831 background: currentColor;
832 box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
833 }
834 .health-summary-pill.is-green {
835 background: rgba(52,211,153,0.12);
836 color: #6ee7b7;
837 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
838 }
839 .health-summary-pill.is-warn {
840 background: rgba(251,191,36,0.10);
841 color: #fde68a;
842 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
843 }
844 .health-summary-pill.is-fail {
845 background: rgba(248,113,113,0.10);
846 color: #fecaca;
847 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.34);
848 }
849 .health-summary-breakdown {
850 font-size: 12.5px;
851 color: var(--text-muted);
852 font-family: var(--font-mono);
853 }
854 .health-summary-breakdown .sep { opacity: 0.45; margin: 0 6px; }
855 .health-summary-stamp {
856 margin-left: auto;
857 font-size: 11.5px;
858 color: var(--text-muted);
859 font-family: var(--font-mono);
860 font-variant-numeric: tabular-nums;
861 letter-spacing: 0.01em;
862 }
863
864 .health-banner {
865 margin-bottom: var(--space-4);
866 padding: 10px 14px;
867 border-radius: 10px;
868 font-size: 13.5px;
869 border: 1px solid var(--border);
870 background: rgba(255,255,255,0.025);
871 color: var(--text);
872 }
873 .health-banner.is-ok {
874 border-color: rgba(52,211,153,0.40);
875 background: rgba(52,211,153,0.08);
876 color: #bbf7d0;
877 }
878 .health-banner.is-error {
879 border-color: rgba(248,113,113,0.40);
880 background: rgba(248,113,113,0.08);
881 color: #fecaca;
882 }
883
884 .health-grid {
885 display: grid;
886 grid-template-columns: 1fr;
887 gap: var(--space-3);
888 margin-bottom: var(--space-5);
889 }
890 @media (min-width: 720px) {
891 .health-grid { grid-template-columns: 1fr 1fr; }
892 }
893
894 .health-card {
895 position: relative;
896 background: var(--bg-elevated);
897 border: 1px solid var(--border);
898 border-radius: 14px;
899 padding: var(--space-4);
900 display: flex;
901 flex-direction: column;
902 gap: 10px;
903 transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
904 }
905 .health-card:hover {
906 border-color: var(--border-strong, var(--border));
907 transform: translateY(-1px);
908 box-shadow: 0 6px 18px -10px rgba(0,0,0,0.45);
909 }
910 .health-card.is-red { border-color: rgba(248,113,113,0.34); }
911 .health-card.is-yellow { border-color: rgba(251,191,36,0.30); }
912 .health-card.is-green { border-color: rgba(52,211,153,0.22); }
913
914 .health-card-head {
915 display: flex;
916 align-items: flex-start;
917 gap: 12px;
918 justify-content: space-between;
919 }
920 .health-card-id { display: flex; align-items: flex-start; gap: 12px; min-width: 0; }
921 .health-card-dot {
922 flex: 0 0 auto;
923 width: 12px; height: 12px;
924 border-radius: 9999px;
925 margin-top: 5px;
926 background: var(--text-muted);
927 box-shadow: 0 0 0 3px rgba(255,255,255,0.04);
928 }
929 .health-card-dot.is-green {
930 background: #34d399;
931 box-shadow: 0 0 0 3px rgba(52,211,153,0.16);
932 }
933 .health-card-dot.is-yellow {
934 background: #f59e0b;
935 box-shadow: 0 0 0 3px rgba(245,158,11,0.18);
936 }
937 .health-card-dot.is-red {
938 background: #f87171;
939 box-shadow: 0 0 0 3px rgba(248,113,113,0.22);
940 animation: health-pulse 1.8s ease-in-out infinite;
941 }
942 @keyframes health-pulse {
943 0%, 100% { box-shadow: 0 0 0 3px rgba(248,113,113,0.22); }
944 50% { box-shadow: 0 0 0 7px rgba(248,113,113,0.05); }
945 }
946 @media (prefers-reduced-motion: reduce) {
947 .health-card-dot.is-red { animation: none; }
948 }
949 .health-card-title {
950 min-width: 0;
951 }
952 .health-card-category {
953 font-size: 10.5px;
954 letter-spacing: 0.14em;
955 text-transform: uppercase;
956 color: var(--text-muted);
957 font-weight: 700;
958 margin-bottom: 2px;
959 }
960 .health-card-name {
961 font-family: var(--font-mono);
962 font-size: 14px;
963 font-weight: 600;
964 color: var(--text-strong);
965 letter-spacing: -0.005em;
966 overflow-wrap: anywhere;
967 }
968
969 .health-card-detail {
970 font-size: 13px;
971 line-height: 1.55;
972 color: var(--text);
973 margin: 0;
974 overflow-wrap: anywhere;
975 }
976
977 .health-card-fix {
978 font-size: 12.5px;
979 line-height: 1.5;
980 color: var(--text-muted);
981 margin: 0;
982 padding: 10px 12px;
983 background: rgba(140,109,255,0.05);
984 border: 1px solid rgba(140,109,255,0.18);
985 border-radius: 10px;
986 }
987 .health-card.is-red .health-card-fix {
988 background: rgba(248,113,113,0.05);
989 border-color: rgba(248,113,113,0.22);
990 }
991 .health-card.is-yellow .health-card-fix {
992 background: rgba(251,191,36,0.05);
993 border-color: rgba(251,191,36,0.22);
994 }
995 .health-card-fix-label {
996 display: block;
997 font-size: 10.5px;
998 text-transform: uppercase;
999 letter-spacing: 0.14em;
1000 font-weight: 700;
1001 color: var(--text-muted);
1002 margin-bottom: 4px;
1003 }
1004
1005 .health-card-foot {
1006 display: flex;
1007 justify-content: flex-end;
1008 margin-top: auto;
1009 padding-top: 4px;
1010 }
1011 .health-card-action {
1012 display: inline-flex;
1013 align-items: center;
1014 gap: 6px;
1015 padding: 6px 12px;
1016 border-radius: 8px;
1017 font-size: 12.5px;
1018 font-weight: 600;
1019 text-decoration: none;
1020 color: var(--text);
1021 background: transparent;
1022 border: 1px solid var(--border-strong, var(--border));
1023 transition: background 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease;
1024 }
1025 .health-card-action:hover {
1026 color: var(--text-strong);
1027 border-color: rgba(140,109,255,0.45);
1028 background: rgba(140,109,255,0.08);
1029 text-decoration: none;
1030 transform: translateY(-1px);
1031 }
1032 .health-card-action .arrow { font-size: 14px; line-height: 1; }
1033
1034 .health-test {
1035 position: relative;
1036 background: var(--bg-elevated);
1037 border: 1px solid var(--border);
1038 border-radius: 14px;
1039 padding: var(--space-4) var(--space-5);
1040 overflow: hidden;
1041 }
1042 .health-test::before {
1043 content: '';
1044 position: absolute;
1045 top: 0; left: 0; right: 0;
1046 height: 1px;
1047 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 50%, transparent 100%);
1048 opacity: 0.6;
1049 }
1050 .health-test h3 {
1051 margin: 0 0 4px 0;
1052 font-family: var(--font-display);
1053 font-size: 16px;
1054 font-weight: 700;
1055 color: var(--text-strong);
1056 letter-spacing: -0.012em;
1057 }
1058 .health-test p {
1059 margin: 0 0 var(--space-3) 0;
1060 font-size: 13px;
1061 color: var(--text-muted);
1062 line-height: 1.5;
1063 }
1064 .health-test form { margin: 0; }
1065`;
1066
115c66bClaude1067async function runAllChecks(c: any): Promise<CheckResult[]> {
1068 return [
826eccfTest User1069 checkEmail(),
1070 checkAnthropic(),
1071 checkGateTest(),
1072 checkBuildSha(),
1073 checkAppBaseUrl(c),
1074 checkDatabase(),
1075 await checkMigrations(),
1076 await checkAutoMerge(),
1077 await checkSyntheticMonitor(),
1078 checkSelfHost(),
115c66bClaude1079 // 2026-05-16 reliability sweep additions:
1080 checkAutopilot(),
1081 await checkRecentDeploy(),
1082 await checkWorkflowQueue(),
1083 checkCrontechWebhook(),
826eccfTest User1084 ];
115c66bClaude1085}
1086
1087// JSON endpoint for programmatic monitoring. Same gate as the HTML page
1088// (site-admin only) so deploy state isn't public.
1089diagnose.get("/admin/diagnose.json", async (c) => {
1090 const g = await gate(c);
1091 if (g instanceof Response) return g;
1092 const results = await runAllChecks(c);
1093 const counts = {
1094 green: results.filter((r) => r.status === "green").length,
1095 yellow: results.filter((r) => r.status === "yellow").length,
1096 red: results.filter((r) => r.status === "red").length,
1097 };
1098 const overall =
1099 counts.red > 0 ? "red" : counts.yellow > 0 ? "yellow" : "green";
1100 return c.json({
1101 ok: true,
1102 overall,
1103 counts,
1104 checks: results,
1105 asOf: new Date().toISOString(),
1106 });
1107});
1108
1109// /admin/health alias — same handler, friendlier URL. The user expected
1110// this to exist; making the expectation reality is cheaper than arguing
1111// about naming.
1112diagnose.get("/admin/health", async (c) => {
1113 return c.redirect("/admin/diagnose");
1114});
1115
1116diagnose.get("/admin/diagnose", async (c) => {
1117 const g = await gate(c);
1118 if (g instanceof Response) return g;
1119 const { user } = g;
1120
1121 const results: CheckResult[] = await runAllChecks(c);
826eccfTest User1122
1123 const counts = {
1124 green: results.filter((r) => r.status === "green").length,
1125 yellow: results.filter((r) => r.status === "yellow").length,
1126 red: results.filter((r) => r.status === "red").length,
1127 };
e7b4b7eClaude1128 const total = results.length;
1129 const overall: CheckStatus =
1130 counts.red > 0 ? "red" : counts.yellow > 0 ? "yellow" : "green";
1131
1132 // Headline copy reads as a verdict, not a tally. The gradient swap
1133 // (green → yellow → red) makes the page status legible at a glance.
1134 const verdict =
1135 overall === "red"
1136 ? "Issues detected."
1137 : overall === "yellow"
1138 ? "Degraded."
1139 : "Healthy.";
1140 const verdictGradClass =
1141 overall === "red"
1142 ? "health-title-grad is-fail"
1143 : overall === "yellow"
1144 ? "health-title-grad is-warn"
1145 : "health-title-grad";
1146
1147 const summaryPillClass =
1148 overall === "red"
1149 ? "health-summary-pill is-fail"
1150 : overall === "yellow"
1151 ? "health-summary-pill is-warn"
1152 : "health-summary-pill is-green";
1153 const summaryPillText = `${counts.green} of ${total} checks green`;
1154
1155 // Last-checked stamp — server-rendered "asOf" in tabular-nums. The
1156 // operator wants a real, observable timestamp on this dashboard.
1157 const asOf = new Date();
1158 const asOfDisplay = asOf.toISOString().replace("T", " ").slice(0, 19) + " UTC";
826eccfTest User1159
1160 const flash = c.req.query("test_email");
1161
1162 return c.html(
1163 <Layout title="Diagnose — admin" user={user}>
e7b4b7eClaude1164 <div class="health-wrap">
1165 <section class="health-hero">
1166 <div class="health-hero-orb" aria-hidden="true" />
1167 <div class="health-hero-inner">
1168 <div class="health-hero-top">
1169 <div class="health-eyebrow">
1170 <span class="health-eyebrow-dot" aria-hidden="true" />
1171 Platform health · live
1172 </div>
1173 <a href="/admin" class="health-back">
1174 ← Back to admin
1175 </a>
1176 </div>
1177 <h1 class="health-title">
1178 <span class={verdictGradClass}>{verdict}</span>
1179 </h1>
1180 <div class="health-summary">
1181 <span class={summaryPillClass}>
1182 <span class="dot" aria-hidden="true" />
1183 {summaryPillText}
1184 </span>
1185 <span class="health-summary-breakdown">
1186 {counts.green} green
1187 <span class="sep">·</span>
1188 {counts.yellow} warn
1189 <span class="sep">·</span>
1190 {counts.red} fail
1191 </span>
1192 <span class="health-summary-stamp" title="Server time">
1193 checked {asOfDisplay}
1194 </span>
1195 </div>
1196 </div>
1197 </section>
826eccfTest User1198
1199 {flash && (
1200 <div
e7b4b7eClaude1201 class={
1202 "health-banner " + (flash === "ok" ? "is-ok" : "is-error")
1203 }
826eccfTest User1204 >
1205 {flash === "ok"
1206 ? "Test email dispatched. If the provider is 'log' you'll see it in journalctl, not your inbox."
1207 : `Test email failed: ${decodeURIComponent(flash)}`}
1208 </div>
1209 )}
1210
e7b4b7eClaude1211 <div class="health-grid">
1212 {results.map((r) => {
1213 const cardClass = `health-card is-${r.status}`;
1214 const dotClass = `health-card-dot is-${r.status}`;
1215 const fix = fixHrefForCheck(r);
1216 return (
1217 <article class={cardClass} data-category={r.category}>
1218 <div class="health-card-head">
1219 <div class="health-card-id">
1220 <span class={dotClass} aria-hidden="true" />
1221 <div class="health-card-title">
1222 <div class="health-card-category">{r.category}</div>
1223 <div class="health-card-name">{r.name}</div>
1224 </div>
1225 </div>
1226 {pill(r.status)}
826eccfTest User1227 </div>
e7b4b7eClaude1228 <p class="health-card-detail">{r.detail}</p>
826eccfTest User1229 {r.fix && (
e7b4b7eClaude1230 <div class="health-card-fix">
1231 <span class="health-card-fix-label">How to fix</span>
1232 {r.fix}
826eccfTest User1233 </div>
1234 )}
e7b4b7eClaude1235 {fix && (
1236 <div class="health-card-foot">
1237 <a class="health-card-action" href={fix.href}>
1238 {fix.label}
1239 <span class="arrow" aria-hidden="true">→</span>
1240 </a>
1241 </div>
1242 )}
1243 </article>
1244 );
1245 })}
826eccfTest User1246 </div>
1247
c6018a5Claude1248 <div class="health-test">
1249 <h3>AI background tasks</h3>
1250 <p>
1251 These tasks run continuously inside the autopilot tick — no
1252 external scheduler. Each one fires on every signal it cares
1253 about (CI failure / gate finding / monitor heartbeat) and
1254 degrades gracefully when <code>ANTHROPIC_API_KEY</code> is
1255 unset.
1256 </p>
1257 <ul style="margin: 8px 0 0; padding-left: 20px; line-height: 1.7; font-size: 13.5px;">
1258 <li>
1259 <strong>AI CI healer</strong> — on every failed workflow
1260 run, Claude reads the failure log + recent diff and proposes
1261 targeted file edits. Source: <code>src/lib/ai-ci-healer.ts</code>.
1262 </li>
1263 <li>
1264 <strong>AI patch generator</strong> — when GateTest or
1265 advisory scan reports a finding, this generates a concrete
1266 diff PR proposing the fix. Source: <code>src/lib/ai-patch-generator.ts</code>.
1267 </li>
1268 <li>
1269 <strong>AI proactive monitor</strong> — sweeps every repo
1270 looking for stale TODOs, suspicious patterns, and stuck PRs;
1271 files issues automatically. Findings surface in{" "}
1272 <a href="/settings/audit">/settings/audit</a>. Source:{" "}
1273 <code>src/lib/ai-proactive-monitor.ts</code>.
1274 </li>
1275 <li>
1276 <strong>AI build tasks</strong> — picks up issues labelled
1277 <code>ai:build</code> and ships a PR for them. Source:{" "}
1278 <code>src/lib/ai-build-tasks.ts</code>.
1279 </li>
1280 </ul>
1281 <p style="margin-top: 12px;">
1282 See <a href="/admin/autopilot">/admin/autopilot</a> for the
1283 per-task tick log and force-run controls.
1284 </p>
1285 </div>
1286
e7b4b7eClaude1287 <div class="health-test">
1288 <h3>Test email delivery</h3>
1289 <p>
826eccfTest User1290 Fires a one-line test email to <strong>{user.email}</strong> using
1291 the configured provider. If EMAIL_PROVIDER=log it appears in
1292 journalctl; if resend, in your inbox in &lt;30s.
1293 </p>
e7b4b7eClaude1294 <form method="post" action="/admin/diagnose/test-email">
826eccfTest User1295 <input
1296 type="hidden"
1297 name="_csrf"
1298 value={(c.get("csrfToken") as string | undefined) || ""}
1299 />
1300 <button type="submit" class="btn btn-sm btn-primary">
1301 Send test email
1302 </button>
1303 </form>
1304 </div>
1305 </div>
e7b4b7eClaude1306 <style dangerouslySetInnerHTML={{ __html: healthStyles }} />
826eccfTest User1307 </Layout>
1308 );
1309});
1310
1311diagnose.post("/admin/diagnose/test-email", async (c) => {
1312 const g = await gate(c);
1313 if (g instanceof Response) return g;
1314 const { user } = g;
1315 if (!user.email) {
1316 return c.redirect(
1317 `/admin/diagnose?test_email=${encodeURIComponent("admin has no email on record")}`
1318 );
1319 }
1320 const stamp = new Date().toISOString();
1321 const result = await sendEmail({
1322 to: user.email,
1323 subject: "Gluecron — diagnose test email",
1324 text:
1325 `This is a test email from /admin/diagnose at ${stamp}.\n\n` +
1326 `If you received this in your inbox, EMAIL_PROVIDER=resend is wired correctly.\n` +
1327 `If you only see it in journalctl, EMAIL_PROVIDER is still 'log'.\n`,
1328 });
1329 if (!result.ok) {
1330 return c.redirect(
1331 `/admin/diagnose?test_email=${encodeURIComponent(result.error || result.skipped || "unknown failure")}`
1332 );
1333 }
1334 return c.redirect("/admin/diagnose?test_email=ok");
1335});
1336
1337export default diagnose;