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-ops.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-ops.tsxBlame704 lines · 2 contributors
9dd96b9Test User1/**
2 * Block R1 — `/admin/ops` site-admin operations console.
3 *
4 * Every operational lever the site admin used to pull from the terminal
5 * becomes a one-click form here:
6 *
7 * GET /admin/ops — render the ops page
8 * POST /admin/ops/auto-merge/enable — flip K2 auto-merge ON for ccantynz/main
9 * POST /admin/ops/auto-merge/disable — flip K2 auto-merge OFF for ccantynz/main
10 * POST /admin/ops/deploy/trigger — workflow_dispatch hetzner-deploy.yml (re-uses N4 internally)
11 * POST /admin/ops/rollback — workflow_dispatch with the previous-successful SHA
12 *
13 * Re-use, don't duplicate:
14 * - `runEnableAutoMerge` from `scripts/enable-auto-merge.ts` (N1) drives
15 * the auto-merge POSTs. We import its DI'd orchestrator + the real
16 * `audit` callback so tests can swap them.
17 * - `triggerRollback` from `src/lib/rollback-deploy.ts` (this block)
18 * drives the rollback POST. It mirrors N4's workflow_dispatch wire
19 * format and friendly-error mapping.
20 * - The deploy-trigger POST forwards to the existing N4 handler at
21 * `/admin/deploys/trigger` rather than re-implementing the GitHub API
22 * call. The page just calls it on the same Hono instance via a redirect.
23 *
24 * Readiness panel: we surface every check from
25 * `scripts/check-auto-merge-readiness.ts` so the operator can see what's
26 * blocking enablement before they hit the button.
27 *
28 * All POST handlers gate on `requireAuth` + `isSiteAdmin`, audit-log under
29 * `admin.ops.<action>`, and redirect back to `/admin/ops?success=<msg>` or
30 * `?error=<msg>`. CSRF protection is the same same-origin-or-token check
31 * the rest of the admin routes use.
32 */
33
34import { Hono } from "hono";
35import { and, desc, eq, sql } from "drizzle-orm";
36import { db } from "../db";
37import { branchProtection, repositories, users } from "../db/schema";
38import { platformDeploys } from "../db/schema-deploys";
39import { Layout } from "../views/layout";
40import { softAuth } from "../middleware/auth";
41import type { AuthEnv } from "../middleware/auth";
42import { isSiteAdmin } from "../lib/admin";
43import { audit as realAudit } from "../lib/notify";
44import {
45 runEnableAutoMerge as realRunEnableAutoMerge,
46 type DbLike,
47 type EnableAutoMergeArgs,
48 type EnableAutoMergeResult,
49} from "../../scripts/enable-auto-merge";
50import {
51 checkAnthropicKey,
52 checkAutopilotEnabled,
53 checkAutoMergeSweepRegistered,
54 checkMigration0040,
55 type CheckResult,
56} from "../../scripts/check-auto-merge-readiness";
57import {
58 findPreviousSuccessfulDeploy as realFindPrev,
59 triggerRollback as realTriggerRollback,
60 type PreviousDeploy,
61 type TriggerRollbackResult,
62} from "../lib/rollback-deploy";
63import { relativeTime, shortSha } from "./admin-deploys-page";
64
65// ---------------------------------------------------------------------------
66// DI hooks — every external collaborator is swappable so tests can drive
67// the handlers without spinning up Neon, GitHub, or the autopilot module.
68// ---------------------------------------------------------------------------
69
70type AuditFn = typeof realAudit;
71type RunEnableAutoMergeFn = (
72 db: DbLike,
73 args: EnableAutoMergeArgs,
74 audit: AuditFn
75) => Promise<EnableAutoMergeResult>;
76type FindPrevFn = typeof realFindPrev;
77type TriggerRollbackFn = typeof realTriggerRollback;
78
79interface OpsDeps {
80 runEnableAutoMerge: RunEnableAutoMergeFn;
81 findPreviousSuccessfulDeploy: FindPrevFn;
82 triggerRollback: TriggerRollbackFn;
83 audit: AuditFn;
84}
85
86const REAL_DEPS: OpsDeps = {
87 runEnableAutoMerge: realRunEnableAutoMerge,
88 findPreviousSuccessfulDeploy: realFindPrev,
89 triggerRollback: realTriggerRollback,
90 audit: realAudit,
91};
92
93let _deps: OpsDeps = REAL_DEPS;
94
95/** Test-only: replace one or more collaborators. Pass `null` to reset. */
96export function __setOpsDepsForTests(d: Partial<OpsDeps> | null): void {
97 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
98}
99
100// The repo + pattern we operate on. `/admin/ops` is a site-admin tool for
101// the platform's own repo. If the operator needs to flip auto-merge on a
102// different repo they still have the CLI (N1).
103const OPS_REPO = "ccantynz/Gluecron.com";
104const OPS_PATTERN = "main";
105
106// ---------------------------------------------------------------------------
107// Status panel — every read wrapped in try/catch so a single missing row
108// doesn't 500 the entire page.
109// ---------------------------------------------------------------------------
110
111interface AutoMergeState {
112 enabled: boolean;
113 exists: boolean;
114}
115
116async function readAutoMergeState(): Promise<AutoMergeState> {
117 try {
118 // Resolve owner/name from the constant. Owner is `ccantynz` and the
119 // repo name is `Gluecron.com`.
120 const [owner, repoName] = OPS_REPO.split("/");
121 if (!owner || !repoName) return { enabled: false, exists: false };
122 const [ownerRow] = await db
123 .select({ id: users.id })
124 .from(users)
125 .where(eq(users.username, owner))
126 .limit(1);
127 if (!ownerRow) return { enabled: false, exists: false };
128 const [repoRow] = await db
129 .select({ id: repositories.id })
130 .from(repositories)
131 .where(
132 and(
133 eq(repositories.ownerId, ownerRow.id),
134 eq(repositories.name, repoName)
135 )
136 )
137 .limit(1);
138 if (!repoRow) return { enabled: false, exists: false };
139 const [bp] = await db
140 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
141 .from(branchProtection)
142 .where(
143 and(
144 eq(branchProtection.repositoryId, repoRow.id),
145 eq(branchProtection.pattern, OPS_PATTERN)
146 )
147 )
148 .limit(1);
149 if (!bp) return { enabled: false, exists: false };
150 return { enabled: !!bp.enableAutoMerge, exists: true };
151 } catch (err) {
152 console.error("[admin-ops] readAutoMergeState:", err);
153 return { enabled: false, exists: false };
154 }
155}
156
157interface LatestDeploy {
158 sha: string;
159 status: string;
160 startedAt: Date;
161 finishedAt: Date | null;
162}
163
164async function readLatestDeploy(): Promise<LatestDeploy | null> {
165 try {
166 const [row] = await db
167 .select({
168 sha: platformDeploys.sha,
169 status: platformDeploys.status,
170 startedAt: platformDeploys.startedAt,
171 finishedAt: platformDeploys.finishedAt,
172 })
173 .from(platformDeploys)
174 .orderBy(desc(platformDeploys.startedAt))
175 .limit(1);
176 return row ?? null;
177 } catch (err) {
178 console.error("[admin-ops] readLatestDeploy:", err);
179 return null;
180 }
181}
182
183async function readReadinessChecks(): Promise<CheckResult[]> {
184 const out: CheckResult[] = [];
185 // 1. Migration probe
186 try {
187 out.push(
188 await checkMigration0040(async () => {
189 try {
190 const rows = await db.execute(
191 sql`SELECT column_name FROM information_schema.columns
192 WHERE table_name = 'branch_protection'
193 AND column_name = 'enable_auto_merge'
194 LIMIT 1`
195 );
196 const list =
197 (rows as any).rows ?? (Array.isArray(rows) ? rows : []);
198 return { exists: list.length > 0 };
199 } catch (err) {
200 return {
201 exists: false,
202 error: err instanceof Error ? err.message : String(err),
203 };
204 }
205 })
206 );
207 } catch {
208 out.push({
209 name: "Migration 0040 applied",
210 status: "fail",
211 reason: "check threw",
212 });
213 }
214 // 2 + 3. env-driven checks
215 out.push(checkAnthropicKey(process.env));
216 out.push(checkAutopilotEnabled(process.env));
217 // 4. autopilot sweep registration — best effort
218 try {
219 const mod = await import("../lib/autopilot");
220 out.push(checkAutoMergeSweepRegistered(mod.defaultTasks()));
221 } catch {
222 out.push({
223 name: "K3 auto-merge-sweep task registered",
224 status: "fail",
225 reason: "autopilot module failed to load",
226 });
227 }
228 return out;
229}
230
231// ---------------------------------------------------------------------------
232// Render helpers
233// ---------------------------------------------------------------------------
234
235function CardShell({
236 title,
237 children,
238}: {
239 title: string;
240 children: any;
241}) {
242 return (
243 <div
dc26881CC LABS App244 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:8px;padding:var(--space-4);margin-bottom:var(--space-4)"
9dd96b9Test User245 >
246 <h3
247 style="margin:0 0 12px 0;font-size:14px;letter-spacing:0.04em;text-transform:uppercase;color:var(--text-muted)"
248 >
249 {title}
250 </h3>
251 {children}
252 </div>
253 );
254}
255
256function Pill({ ok, label }: { ok: boolean; label: string }) {
257 return (
258 <span
259 style={`display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${
260 ok ? "rgba(52, 211, 153, 0.16)" : "rgba(248, 113, 113, 0.16)"
261 };color:${ok ? "#34d399" : "#f87171"}`}
262 >
263 <span aria-hidden="true">{ok ? "v" : "x"}</span>
264 <span>{label}</span>
265 </span>
266 );
267}
268
269// ---------------------------------------------------------------------------
270// Gating
271// ---------------------------------------------------------------------------
272
273const ops = new Hono<AuthEnv>();
274ops.use("*", softAuth);
275
276async function gate(c: any): Promise<{ user: any } | Response> {
277 const user = c.get("user");
278 if (!user) return c.redirect("/login?next=/admin/ops");
279 if (!(await isSiteAdmin(user.id))) {
280 return c.html(
281 <Layout title="Forbidden" user={user}>
282 <div class="empty-state">
283 <h2>403 — Not a site admin</h2>
284 <p>You don't have permission to view this page.</p>
285 </div>
286 </Layout>,
287 403
288 );
289 }
290 return { user };
291}
292
293function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
294 return c.redirect(`/admin/ops?${kind}=${encodeURIComponent(msg)}`);
295}
296
297// ---------------------------------------------------------------------------
298// GET /admin/ops
299// ---------------------------------------------------------------------------
300
301ops.get("/admin/ops", async (c) => {
302 const g = await gate(c);
303 if (g instanceof Response) return g;
304 const { user } = g;
305
306 // Surface flash messages from prior POSTs.
307 const success = c.req.query("success");
308 const error = c.req.query("error");
309
310 // Pull every status read in parallel — a slow one shouldn't block the rest.
311 const [autoMergeState, readiness, latest, previous] = await Promise.all([
312 readAutoMergeState(),
313 readReadinessChecks(),
314 readLatestDeploy(),
315 _deps.findPreviousSuccessfulDeploy().catch(() => null),
316 ]);
317
318 const readinessAllGreen = readiness.every((r) => r.status === "pass");
319
320 return c.html(
321 <Layout title="Operations — admin" user={user}>
dc26881CC LABS App322 <div style="max-width:880px;margin:0 auto;padding:var(--space-6) var(--space-4)">
9dd96b9Test User323 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">
324 <h1 style="margin:0">Operations</h1>
325 <a href="/admin" class="btn btn-sm">
326 Back to admin
327 </a>
328 </div>
329 <p style="color:var(--text-muted);margin-bottom:20px">
330 Site-admin controls for the live platform. Every action here is
331 audit-logged under <code>admin.ops.*</code>.
332 </p>
333
334 {success && (
335 <div class="auth-success" style="margin-bottom:16px">
336 {decodeURIComponent(success)}
337 </div>
338 )}
339 {error && (
340 <div class="auth-error" style="margin-bottom:16px">
341 {decodeURIComponent(error)}
342 </div>
343 )}
344
345 {/* ---- Auto-merge card ---- */}
346 <CardShell title="AI auto-merge on main">
dc26881CC LABS App347 <div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-3)">
9dd96b9Test User348 <span style="font-size:13px;color:var(--text-muted)">Status:</span>
349 <Pill
350 ok={autoMergeState.enabled}
351 label={autoMergeState.enabled ? "Enabled" : "Disabled"}
352 />
353 <span style="font-size:12px;color:var(--text-muted)">
354 {OPS_REPO}@{OPS_PATTERN}
355 </span>
356 </div>
357
358 <div style="margin-bottom:14px">
359 <div
360 style="font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px"
361 >
362 Readiness check
363 </div>
364 <ul style="list-style:none;padding:0;margin:0">
365 {readiness.map((r) => (
366 <li
367 style="display:flex;align-items:flex-start;gap:8px;padding:3px 0;font-size:13px"
368 >
369 <span
370 aria-hidden="true"
371 style={`color:${r.status === "pass" ? "#34d399" : "#f87171"};font-weight:700`}
372 >
373 {r.status === "pass" ? "v" : "x"}
374 </span>
375 <span>
376 {r.name}
377 {r.reason && (
378 <span style="color:var(--text-muted);margin-left:6px">
379 — {r.reason}
380 </span>
381 )}
382 </span>
383 </li>
384 ))}
385 </ul>
386 </div>
387
dc26881CC LABS App388 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User389 {autoMergeState.enabled ? (
390 <form
391 method="post"
392 action="/admin/ops/auto-merge/disable"
393 style="margin:0"
394 >
395 <button type="submit" class="btn btn-sm">
396 Disable
397 </button>
398 </form>
399 ) : (
400 <form
401 method="post"
402 action="/admin/ops/auto-merge/enable"
403 style="margin:0"
404 >
405 <button
406 type="submit"
407 class="btn btn-sm btn-primary"
408 disabled={!readinessAllGreen}
409 title={
410 readinessAllGreen
411 ? "Enable AI auto-merge"
412 : "Fix the readiness items first"
413 }
414 >
415 Enable
416 </button>
417 </form>
418 )}
419 <span style="font-size:12px;color:var(--text-muted)">
420 When enabled, every PR Claude opens that passes gates
421 auto-merges within ~30s and deploys ~25s later — under a
422 minute end-to-end.
423 </span>
424 </div>
425 </CardShell>
426
427 {/* ---- Deploy card ---- */}
428 <CardShell title="Deploy">
429 <div style="margin-bottom:12px;font-size:13px">
430 {latest ? (
431 <span>
432 <span style="color:var(--text-muted)">Last deploy: </span>
433 <Pill
434 ok={latest.status === "succeeded"}
435 label={latest.status}
436 />
437 {" · "}
438 <code class="meta-mono">{shortSha(latest.sha)}</code>
439 {" · "}
440 <span title={latest.startedAt.toISOString()}>
441 {relativeTime(latest.startedAt)}
442 </span>
443 </span>
444 ) : (
445 <span style="color:var(--text-muted)">
446 Last deploy: —
447 </span>
448 )}
449 </div>
dc26881CC LABS App450 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User451 <form
452 method="post"
453 action="/admin/ops/deploy/trigger"
454 style="margin:0"
455 >
456 <button type="submit" class="btn btn-sm btn-primary">
457 Trigger deploy now
458 </button>
459 </form>
460 <span style="font-size:12px;color:var(--text-muted)">
461 Fires hetzner-deploy.yml on main. ~25–90 sec.
462 </span>
463 </div>
464 </CardShell>
465
466 {/* ---- Rollback card ---- */}
467 <CardShell title="Rollback">
468 <div style="margin-bottom:12px;font-size:13px">
469 {previous ? (
470 <span>
471 <span style="color:var(--text-muted)">
472 Previous successful deploy:{" "}
473 </span>
474 <code class="meta-mono">{shortSha(previous.sha)}</code>
475 {" · "}
476 <span title={previous.finishedAt.toISOString()}>
477 {relativeTime(previous.finishedAt)}
478 </span>
479 </span>
480 ) : (
481 <span style="color:var(--text-muted)">
482 Previous successful deploy: —
483 </span>
484 )}
485 </div>
dc26881CC LABS App486 <div style="display:flex;gap:var(--space-2);align-items:center">
9dd96b9Test User487 <form
488 method="post"
489 action="/admin/ops/rollback"
490 style="margin:0"
491 onsubmit="return confirm('Roll back main to the previous tagged release?')"
492 >
493 <button
494 type="submit"
495 class="btn btn-sm btn-danger"
496 disabled={!previous}
497 title={
498 previous
499 ? `Rollback to ${shortSha(previous.sha)}`
500 : "No prior successful deploy on file"
501 }
502 >
503 {previous ? `Rollback to ${shortSha(previous.sha)}` : "Rollback"}
504 </button>
505 </form>
506 <span style="font-size:12px;color:var(--text-muted)">
507 Resets main to the previous tagged release. Use if the latest
508 deploy broke something.
509 </span>
510 </div>
511 </CardShell>
512 </div>
513 </Layout>
514 );
515});
516
517// ---------------------------------------------------------------------------
518// POST /admin/ops/auto-merge/{enable,disable}
519// ---------------------------------------------------------------------------
520
521async function handleAutoMergeFlip(c: any, off: boolean): Promise<Response> {
522 const g = await gate(c);
523 if (g instanceof Response) return g;
524 const { user } = g;
525
526 try {
527 const result = await _deps.runEnableAutoMerge(
528 db as unknown as DbLike,
529 {
530 ownerSlash: OPS_REPO,
531 pattern: OPS_PATTERN,
532 off,
533 actorUserId: user.id,
534 },
535 _deps.audit
536 );
537 try {
538 await _deps.audit({
539 userId: user.id,
540 action: off ? "admin.ops.auto_merge_disable" : "admin.ops.auto_merge_enable",
541 targetType: "branch_protection",
542 targetId: result.after?.id,
543 metadata: {
544 repo: OPS_REPO,
545 pattern: OPS_PATTERN,
546 scriptAction: result.action,
547 },
548 });
549 } catch {
550 // audit failure is non-fatal for the user-facing flow
551 }
552 const verb = off ? "disabled" : "enabled";
553 const tail =
554 result.action === "noop"
555 ? `already ${verb}`
556 : result.action === "inserted"
557 ? `${verb} (new rule created)`
558 : `${verb}`;
559 return redirectWith(c, "success", `Auto-merge ${tail} on ${OPS_REPO}@${OPS_PATTERN}.`);
560 } catch (err) {
561 const message = err instanceof Error ? err.message : String(err);
562 return redirectWith(
563 c,
564 "error",
565 `Failed to ${off ? "disable" : "enable"} auto-merge: ${message}`
566 );
567 }
568}
569
570ops.post("/admin/ops/auto-merge/enable", (c) => handleAutoMergeFlip(c, false));
571ops.post("/admin/ops/auto-merge/disable", (c) => handleAutoMergeFlip(c, true));
572
573// ---------------------------------------------------------------------------
574// POST /admin/ops/deploy/trigger
575//
576// Re-uses the N4 handler. We don't duplicate the GitHub API call — we
577// simply invoke `/admin/deploys/trigger` on the same Hono `app.request`
578// with the caller's session cookie forwarded so softAuth + isSiteAdmin
579// pass cleanly on the inner call.
580// ---------------------------------------------------------------------------
581
582ops.post("/admin/ops/deploy/trigger", async (c) => {
583 const g = await gate(c);
584 if (g instanceof Response) return g;
585 const { user } = g;
586
587 try {
588 // Fetch the live app instance lazily — avoids a circular import at module
589 // load time.
590 const { default: app } = await import("../app");
591 const cookie = c.req.header("cookie") ?? "";
592 const origin = c.req.header("origin") ?? "";
593 const host = c.req.header("host") ?? "";
594 const res = await app.request("/admin/deploys/trigger", {
595 method: "POST",
596 headers: {
597 "content-type": "application/json",
598 cookie,
599 origin,
600 host,
601 },
602 body: JSON.stringify({}),
603 });
604 if (res.ok) {
605 try {
606 await _deps.audit({
607 userId: user.id,
608 action: "admin.ops.deploy_triggered",
609 targetType: "workflow",
610 targetId: "hetzner-deploy.yml",
611 metadata: { repo: OPS_REPO },
612 });
613 } catch {
614 /* non-fatal */
615 }
616 return redirectWith(c, "success", "Deploy dispatched — watch /admin/deploys for progress.");
617 }
618 let raw = "";
619 try {
620 raw = await res.text();
621 } catch {
622 /* swallow */
623 }
624 let msg = `deploy trigger returned ${res.status}`;
625 try {
626 const j = JSON.parse(raw);
627 if (j?.error) msg = String(j.error);
628 } catch {
629 if (raw) msg = raw.slice(0, 240);
630 }
631 return redirectWith(c, "error", msg);
632 } catch (err) {
633 const message = err instanceof Error ? err.message : String(err);
634 return redirectWith(c, "error", `Deploy trigger failed: ${message}`);
635 }
636});
637
638// ---------------------------------------------------------------------------
639// POST /admin/ops/rollback
640// ---------------------------------------------------------------------------
641
642ops.post("/admin/ops/rollback", async (c) => {
643 const g = await gate(c);
644 if (g instanceof Response) return g;
645 const { user } = g;
646
647 let prev: PreviousDeploy | null = null;
648 try {
649 prev = await _deps.findPreviousSuccessfulDeploy();
650 } catch (err) {
651 const message = err instanceof Error ? err.message : String(err);
652 return redirectWith(c, "error", `Rollback lookup failed: ${message}`);
653 }
654 if (!prev) {
655 return redirectWith(
656 c,
657 "error",
658 "No previous successful deploy on file — nothing to roll back to."
659 );
660 }
661
662 let result: TriggerRollbackResult;
663 try {
664 result = await _deps.triggerRollback({
665 targetSha: prev.sha,
666 triggeredByUserId: user.id,
667 });
668 } catch (err) {
669 const message = err instanceof Error ? err.message : String(err);
670 return redirectWith(c, "error", `Rollback dispatch threw: ${message}`);
671 }
672
673 if (!result.ok) {
674 return redirectWith(c, "error", result.error || "Rollback dispatch failed.");
675 }
676
677 try {
678 await _deps.audit({
679 userId: user.id,
680 action: "admin.ops.rollback_dispatched",
681 targetType: "workflow",
682 targetId: "hetzner-deploy.yml",
683 metadata: { repo: OPS_REPO, target_sha: prev.sha },
684 });
685 } catch {
686 /* non-fatal */
687 }
688
689 return redirectWith(
690 c,
691 "success",
692 `Rollback dispatched to ${shortSha(prev.sha)} — watch /admin/deploys for progress.`
693 );
694});
695
696export const __test = {
697 readAutoMergeState,
698 readLatestDeploy,
699 readReadinessChecks,
700 OPS_REPO,
701 OPS_PATTERN,
702};
703
704export default ops;