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