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.tsxBlame596 lines · 1 contributor
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";
25import { eq, and, desc, gt } from "drizzle-orm";
26import { readdir } from "fs/promises";
27import { join } from "path";
28import {
29 branchProtection,
30 repositories,
31 syntheticChecks,
32 users,
33} from "../db/schema";
34import { db } from "../db";
35import { Layout } from "../views/layout";
36import { softAuth } from "../middleware/auth";
37import type { AuthEnv } from "../middleware/auth";
38import { isSiteAdmin } from "../lib/admin";
39import { config } from "../lib/config";
40import { sendEmail } from "../lib/email";
41import { latestMigration } from "../lib/post-deploy-smoke";
42
43type CheckStatus = "green" | "yellow" | "red";
44
45interface CheckResult {
46 category: string;
47 name: string;
48 status: CheckStatus;
49 detail: string;
50 fix?: string;
51}
52
53const diagnose = new Hono<AuthEnv>();
54diagnose.use("*", softAuth);
55
56async function gate(c: any): Promise<{ user: any } | Response> {
57 const user = c.get("user");
58 if (!user) return c.redirect("/login?next=/admin/diagnose");
59 if (!(await isSiteAdmin(user.id))) {
60 return c.html(
61 <Layout title="Forbidden" user={user}>
62 <div class="empty-state">
63 <h2>403 — Not a site admin</h2>
64 <p>You don't have permission to view this page.</p>
65 </div>
66 </Layout>,
67 403
68 );
69 }
70 return { user };
71}
72
73// ─── Individual checks ───────────────────────────────────────────────────
74
75function checkEmail(): CheckResult {
76 if (config.emailProvider !== "resend") {
77 return {
78 category: "Email",
79 name: "Provider",
80 status: "red",
81 detail: `EMAIL_PROVIDER=${config.emailProvider} — verification + magic-link emails go to stderr, not inboxes.`,
82 fix: "Set EMAIL_PROVIDER=resend in /etc/gluecron.env, then `systemctl restart gluecron`.",
83 };
84 }
85 if (!config.resendApiKey) {
86 return {
87 category: "Email",
88 name: "Provider",
89 status: "red",
90 detail: "EMAIL_PROVIDER=resend but RESEND_API_KEY is empty — every send will fail.",
91 fix: "Add RESEND_API_KEY=re_xxx to /etc/gluecron.env, then restart.",
92 };
93 }
94 return {
95 category: "Email",
96 name: "Provider",
97 status: "green",
98 detail: `Resend wired (from: ${config.emailFrom}). Use the test button below to confirm.`,
99 };
100}
101
102function checkAnthropic(): CheckResult {
103 if (!config.anthropicApiKey) {
104 return {
105 category: "AI",
106 name: "Anthropic API key",
107 status: "yellow",
108 detail: "ANTHROPIC_API_KEY unset — AI PR review + AI deploy-failure analysis disabled.",
109 fix: "Add ANTHROPIC_API_KEY=sk-ant-xxx to /etc/gluecron.env.",
110 };
111 }
112 return {
113 category: "AI",
114 name: "Anthropic API key",
115 status: "green",
116 detail: `Key present (length ${config.anthropicApiKey.length}).`,
117 };
118}
119
120function checkGateTest(): CheckResult {
121 const hasUrl = !!process.env.GATETEST_URL;
122 const hasKey = !!process.env.GATETEST_API_KEY;
123 if (!hasUrl && !hasKey) {
124 return {
125 category: "GateTest",
126 name: "Scanner integration",
127 status: "yellow",
128 detail: "Unconfigured — push-time GateTest scans skip silently.",
129 fix: "Set GATETEST_URL + GATETEST_API_KEY in /etc/gluecron.env to enable per-push scans.",
130 };
131 }
132 if (hasUrl && !hasKey) {
133 return {
134 category: "GateTest",
135 name: "Scanner integration",
136 status: "red",
137 detail: "GATETEST_URL set but GATETEST_API_KEY empty — calls will 401.",
138 fix: "Add GATETEST_API_KEY to /etc/gluecron.env.",
139 };
140 }
141 return {
142 category: "GateTest",
143 name: "Scanner integration",
144 status: "green",
145 detail: `Configured — pushes POST to ${process.env.GATETEST_URL}.`,
146 };
147}
148
149function checkBuildSha(): CheckResult {
150 const sha = process.env.BUILD_SHA?.trim();
151 if (sha) {
152 return {
153 category: "Deploy",
154 name: "BUILD_SHA pinned",
155 status: "green",
156 detail: `${sha.slice(0, 12)} — service worker rotates per deploy.`,
157 };
158 }
159 return {
160 category: "Deploy",
161 name: "BUILD_SHA pinned",
162 status: "yellow",
163 detail: "BUILD_SHA unset — falling back to dev-stable. Browsers won't see new deploys reflected in the SW cache.",
164 fix: "Latest scripts/self-deploy.sh + hetzner-deploy.yml pin this automatically; trigger a deploy.",
165 };
166}
167
168function checkAppBaseUrl(c: any): CheckResult {
169 const expected = config.appBaseUrl;
170 const host = c.req.header("host") || "";
171 const proto =
172 c.req.header("x-forwarded-proto") ||
173 (c.req.url.startsWith("https://") ? "https" : "http");
174 const actual = `${proto}://${host}`;
175 if (!expected || expected === "http://localhost:3000") {
176 return {
177 category: "Config",
178 name: "APP_BASE_URL canonical",
179 status: "yellow",
180 detail: `APP_BASE_URL is "${expected}" — outbound email links + WebAuthn origin will be wrong.`,
181 fix: "Set APP_BASE_URL=https://gluecron.com in /etc/gluecron.env.",
182 };
183 }
184 if (host && !expected.endsWith(host)) {
185 return {
186 category: "Config",
187 name: "APP_BASE_URL canonical",
188 status: "yellow",
189 detail: `APP_BASE_URL=${expected} but request arrived at ${actual}. WebAuthn passkeys issued for one host can't be used at the other.`,
190 fix: "Align APP_BASE_URL with the host you actually serve from.",
191 };
192 }
193 return {
194 category: "Config",
195 name: "APP_BASE_URL canonical",
196 status: "green",
197 detail: expected,
198 };
199}
200
201function checkDatabase(): CheckResult {
202 const url = config.databaseUrl;
203 if (!url) {
204 return {
205 category: "Database",
206 name: "Connection string",
207 status: "red",
208 detail: "DATABASE_URL unset — every page that queries the DB will 500.",
209 fix: "Set DATABASE_URL in /etc/gluecron.env.",
210 };
211 }
212 let masked = url;
213 try {
214 const u = new URL(url);
215 masked = `${u.protocol}//${u.username ? "***" : ""}@${u.host}${u.pathname}`;
216 } catch {
217 // unparseable
218 return {
219 category: "Database",
220 name: "Connection string",
221 status: "red",
222 detail: "DATABASE_URL is not a valid URL.",
223 fix: "Fix the URL format: postgres://user:pass@host:port/dbname",
224 };
225 }
226 return {
227 category: "Database",
228 name: "Connection string",
229 status: "green",
230 detail: masked,
231 };
232}
233
234async function checkMigrations(): Promise<CheckResult> {
235 try {
236 const drizzleDir = join(process.cwd(), "drizzle");
237 const files = (await readdir(drizzleDir)).filter((f) => f.endsWith(".sql"));
238 const latest = latestMigration(files);
239 if (!latest) {
240 return {
241 category: "Database",
242 name: "Migrations applied",
243 status: "yellow",
244 detail: "No migration files found in drizzle/.",
245 };
246 }
247 const rows = (await db.execute(
248 `SELECT name FROM _migrations ORDER BY name DESC LIMIT 1` as never
249 )) as any;
250 const list = rows?.rows ?? (Array.isArray(rows) ? rows : []);
251 const applied: string | undefined = list[0]?.name;
252 if (!applied) {
253 return {
254 category: "Database",
255 name: "Migrations applied",
256 status: "red",
257 detail: "_migrations table is empty.",
258 fix: "Run `bun run db:migrate` on the box.",
259 };
260 }
261 if (applied !== latest) {
262 return {
263 category: "Database",
264 name: "Migrations applied",
265 status: "red",
266 detail: `DB at ${applied}, drizzle/ has ${latest}.`,
267 fix: "Run `bun run db:migrate` on the box, or redeploy (the workflow runs it).",
268 };
269 }
270 return {
271 category: "Database",
272 name: "Migrations applied",
273 status: "green",
274 detail: `Latest: ${applied}`,
275 };
276 } catch (err) {
277 return {
278 category: "Database",
279 name: "Migrations applied",
280 status: "yellow",
281 detail: `Couldn't read migration state: ${(err as Error).message.slice(0, 100)}`,
282 };
283 }
284}
285
286async function checkAutoMerge(): Promise<CheckResult> {
287 try {
288 const [owner] = await db
289 .select({ id: users.id })
290 .from(users)
291 .where(eq(users.username, "ccantynz"))
292 .limit(1);
293 if (!owner) {
294 return {
295 category: "Auto-merge",
296 name: "main protection",
297 status: "yellow",
298 detail: "Owner user 'ccantynz' not found.",
299 };
300 }
301 const [repo] = await db
302 .select({ id: repositories.id })
303 .from(repositories)
304 .where(
305 and(
306 eq(repositories.ownerId, owner.id),
307 eq(repositories.name, "Gluecron.com")
308 )
309 )
310 .limit(1);
311 if (!repo) {
312 return {
313 category: "Auto-merge",
314 name: "main protection",
315 status: "yellow",
316 detail: "Repository row for ccantynz/Gluecron.com not found.",
317 };
318 }
319 const [bp] = await db
320 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
321 .from(branchProtection)
322 .where(
323 and(
324 eq(branchProtection.repositoryId, repo.id),
325 eq(branchProtection.pattern, "main")
326 )
327 )
328 .limit(1);
329 if (!bp) {
330 return {
331 category: "Auto-merge",
332 name: "main protection",
333 status: "yellow",
334 detail: "No branch_protection row for main yet.",
335 fix: "Visit /ccantynz/Gluecron.com/gates/protection to configure.",
336 };
337 }
338 return {
339 category: "Auto-merge",
340 name: "main protection",
341 status: bp.enableAutoMerge ? "green" : "yellow",
342 detail: bp.enableAutoMerge
343 ? "Auto-merge ENABLED on main."
344 : "Auto-merge DISABLED on main.",
345 fix: bp.enableAutoMerge
346 ? undefined
347 : "Visit /admin/ops to enable.",
348 };
349 } catch (err) {
350 return {
351 category: "Auto-merge",
352 name: "main protection",
353 status: "yellow",
354 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
355 };
356 }
357}
358
359async function checkSyntheticMonitor(): Promise<CheckResult> {
360 try {
361 const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
362 const reds = await db
363 .select({ name: syntheticChecks.checkName })
364 .from(syntheticChecks)
365 .where(
366 and(
367 eq(syntheticChecks.status, "red"),
368 gt(syntheticChecks.checkedAt, oneHourAgo)
369 )
370 )
371 .orderBy(desc(syntheticChecks.checkedAt))
372 .limit(10);
373 if (reds.length === 0) {
374 return {
375 category: "Monitor",
376 name: "Synthetic checks (1h)",
377 status: "green",
378 detail: "All synthetic checks green in the last hour.",
379 };
380 }
381 const names = Array.from(new Set(reds.map((r) => r.name))).slice(0, 5);
382 return {
383 category: "Monitor",
384 name: "Synthetic checks (1h)",
385 status: "red",
386 detail: `Red in last hour: ${names.join(", ")}.`,
387 fix: "Open /admin/status for the full row table.",
388 };
389 } catch (err) {
390 return {
391 category: "Monitor",
392 name: "Synthetic checks (1h)",
393 status: "yellow",
394 detail: `Couldn't read: ${(err as Error).message.slice(0, 100)}`,
395 };
396 }
397}
398
399function checkSelfHost(): CheckResult {
400 const repo = process.env.SELF_HOST_REPO;
401 if (!repo) {
402 return {
403 category: "Self-host",
404 name: "Bootstrap",
405 status: "yellow",
406 detail: "SELF_HOST_REPO unset — pushes to this repo don't trigger self-deploy.",
407 fix: "Run scripts/self-host-bootstrap.ts on the box and add SELF_HOST_REPO=ccantynz/Gluecron.com to /etc/gluecron.env.",
408 };
409 }
410 return {
411 category: "Self-host",
412 name: "Bootstrap",
413 status: "green",
414 detail: `Self-hosting ${repo} — push to main fires self-deploy.sh.`,
415 };
416}
417
418// ─── Page handler ────────────────────────────────────────────────────────
419
420function pill(status: CheckStatus): any {
421 const map: Record<CheckStatus, { bg: string; fg: string; label: string }> = {
422 green: { bg: "rgba(52,211,153,0.16)", fg: "#34d399", label: "✓ OK" },
423 yellow: { bg: "rgba(245,158,11,0.16)", fg: "#f59e0b", label: "! WARN" },
424 red: { bg: "rgba(248,113,113,0.16)", fg: "#f87171", label: "× FAIL" },
425 };
426 const s = map[status];
427 return (
428 <span
429 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`}
430 >
431 {s.label}
432 </span>
433 );
434}
435
436diagnose.get("/admin/diagnose", async (c) => {
437 const g = await gate(c);
438 if (g instanceof Response) return g;
439 const { user } = g;
440
441 const results: CheckResult[] = [
442 checkEmail(),
443 checkAnthropic(),
444 checkGateTest(),
445 checkBuildSha(),
446 checkAppBaseUrl(c),
447 checkDatabase(),
448 await checkMigrations(),
449 await checkAutoMerge(),
450 await checkSyntheticMonitor(),
451 checkSelfHost(),
452 ];
453
454 const counts = {
455 green: results.filter((r) => r.status === "green").length,
456 yellow: results.filter((r) => r.status === "yellow").length,
457 red: results.filter((r) => r.status === "red").length,
458 };
459 const headline =
460 counts.red > 0
461 ? `${counts.red} failing`
462 : counts.yellow > 0
463 ? `${counts.yellow} needs attention`
464 : "All green";
465 const headlineColor =
466 counts.red > 0
467 ? "var(--red, #cf222e)"
468 : counts.yellow > 0
469 ? "#d97706"
470 : "var(--green, #2da44e)";
471
472 const flash = c.req.query("test_email");
473
474 return c.html(
475 <Layout title="Diagnose — admin" user={user}>
476 <div style="max-width:920px;margin:0 auto;padding:var(--space-5) var(--space-3)">
477 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
478 <h1 style="margin:0">Diagnose</h1>
479 <a href="/admin" class="btn btn-sm">
480 Back to admin
481 </a>
482 </div>
483 <p style="color:var(--text-muted);margin-bottom:var(--space-4)">
484 Read-only health scan of every config knob the platform depends on.
485 One row per check. Green = wired, yellow = degraded, red = broken.
486 </p>
487
488 <div
489 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)`}
490 >
491 <span
492 style={`display:inline-block;width:14px;height:14px;border-radius:50%;background:${headlineColor}`}
493 />
494 <strong style="font-size:18px">{headline}</strong>
495 <span style="color:var(--text-muted);font-size:13px">
496 {counts.green} green · {counts.yellow} warn · {counts.red} fail
497 </span>
498 </div>
499
500 {flash && (
501 <div
502 class={flash === "ok" ? "banner" : "auth-error"}
503 style="margin-bottom:var(--space-4)"
504 >
505 {flash === "ok"
506 ? "Test email dispatched. If the provider is 'log' you'll see it in journalctl, not your inbox."
507 : `Test email failed: ${decodeURIComponent(flash)}`}
508 </div>
509 )}
510
511 <div
512 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:var(--space-4)"
513 >
514 {results.map((r, i) => (
515 <div
516 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`}
517 >
518 <div>
519 <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em">
520 {r.category}
521 </div>
522 <div style="font-weight:600;font-size:14px;margin-top:2px">
523 {r.name}
524 </div>
525 </div>
526 <div>{pill(r.status)}</div>
527 <div>
528 <div style="font-size:13px;line-height:1.45">{r.detail}</div>
529 {r.fix && (
530 <div
531 style="font-size:12px;color:var(--text-muted);margin-top:4px;line-height:1.4"
532 >
533 → {r.fix}
534 </div>
535 )}
536 </div>
537 </div>
538 ))}
539 </div>
540
541 <div
542 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-3) var(--space-4)"
543 >
544 <h3
545 style="margin:0 0 var(--space-2) 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
546 >
547 Test email delivery
548 </h3>
549 <p style="font-size:13px;color:var(--text-muted);margin:0 0 var(--space-3) 0">
550 Fires a one-line test email to <strong>{user.email}</strong> using
551 the configured provider. If EMAIL_PROVIDER=log it appears in
552 journalctl; if resend, in your inbox in &lt;30s.
553 </p>
554 <form method="post" action="/admin/diagnose/test-email" style="margin:0">
555 <input
556 type="hidden"
557 name="_csrf"
558 value={(c.get("csrfToken") as string | undefined) || ""}
559 />
560 <button type="submit" class="btn btn-sm btn-primary">
561 Send test email
562 </button>
563 </form>
564 </div>
565 </div>
566 </Layout>
567 );
568});
569
570diagnose.post("/admin/diagnose/test-email", async (c) => {
571 const g = await gate(c);
572 if (g instanceof Response) return g;
573 const { user } = g;
574 if (!user.email) {
575 return c.redirect(
576 `/admin/diagnose?test_email=${encodeURIComponent("admin has no email on record")}`
577 );
578 }
579 const stamp = new Date().toISOString();
580 const result = await sendEmail({
581 to: user.email,
582 subject: "Gluecron — diagnose test email",
583 text:
584 `This is a test email from /admin/diagnose at ${stamp}.\n\n` +
585 `If you received this in your inbox, EMAIL_PROVIDER=resend is wired correctly.\n` +
586 `If you only see it in journalctl, EMAIL_PROVIDER is still 'log'.\n`,
587 });
588 if (!result.ok) {
589 return c.redirect(
590 `/admin/diagnose?test_email=${encodeURIComponent(result.error || result.skipped || "unknown failure")}`
591 );
592 }
593 return c.redirect("/admin/diagnose?test_email=ok");
594});
595
596export default diagnose;