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-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.tsxBlame1575 lines · 3 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";
0ec4eceTest User37import { apiTokens, branchProtection, repositories, users } from "../db/schema";
9dd96b9Test User38import { platformDeploys } from "../db/schema-deploys";
39import { Layout } from "../views/layout";
f4a1547ccantynz-alt40import { AdminShell } from "../views/admin-shell";
9dd96b9Test User41import { softAuth } from "../middleware/auth";
42import type { AuthEnv } from "../middleware/auth";
43import { isSiteAdmin } from "../lib/admin";
44import { audit as realAudit } from "../lib/notify";
0ec4eceTest User45import { config } from "../lib/config";
9dd96b9Test User46import {
47 runEnableAutoMerge as realRunEnableAutoMerge,
48 type DbLike,
49 type EnableAutoMergeArgs,
50 type EnableAutoMergeResult,
51} from "../../scripts/enable-auto-merge";
52import {
53 checkAnthropicKey,
54 checkAutopilotEnabled,
55 checkAutoMergeSweepRegistered,
56 checkMigration0040,
57 type CheckResult,
58} from "../../scripts/check-auto-merge-readiness";
59import {
60 findPreviousSuccessfulDeploy as realFindPrev,
61 triggerRollback as realTriggerRollback,
62 type PreviousDeploy,
63 type TriggerRollbackResult,
64} from "../lib/rollback-deploy";
65import { relativeTime, shortSha } from "./admin-deploys-page";
66
67// ---------------------------------------------------------------------------
68// DI hooks — every external collaborator is swappable so tests can drive
69// the handlers without spinning up Neon, GitHub, or the autopilot module.
70// ---------------------------------------------------------------------------
71
72type AuditFn = typeof realAudit;
73type RunEnableAutoMergeFn = (
74 db: DbLike,
75 args: EnableAutoMergeArgs,
76 audit: AuditFn
77) => Promise<EnableAutoMergeResult>;
78type FindPrevFn = typeof realFindPrev;
79type TriggerRollbackFn = typeof realTriggerRollback;
80
81interface OpsDeps {
82 runEnableAutoMerge: RunEnableAutoMergeFn;
83 findPreviousSuccessfulDeploy: FindPrevFn;
84 triggerRollback: TriggerRollbackFn;
85 audit: AuditFn;
86}
87
88const REAL_DEPS: OpsDeps = {
89 runEnableAutoMerge: realRunEnableAutoMerge,
90 findPreviousSuccessfulDeploy: realFindPrev,
91 triggerRollback: realTriggerRollback,
92 audit: realAudit,
93};
94
95let _deps: OpsDeps = REAL_DEPS;
96
97/** Test-only: replace one or more collaborators. Pass `null` to reset. */
98export function __setOpsDepsForTests(d: Partial<OpsDeps> | null): void {
99 _deps = d ? { ...REAL_DEPS, ..._deps, ...d } : REAL_DEPS;
100}
101
102// The repo + pattern we operate on. `/admin/ops` is a site-admin tool for
5c7fbd2Claude103// the platform's own repo. Env-overridable (SELF_HOST_REPO) because the
104// canonical name varies per deployment — on the main site it's
105// "ccantynz-alt/Gluecron.com" (the actual user who signed up). The CLI
106// (N1) still works for any other repo.
107const OPS_REPO = process.env.SELF_HOST_REPO || "ccantynz-alt/Gluecron.com";
9dd96b9Test User108const OPS_PATTERN = "main";
109
110// ---------------------------------------------------------------------------
111// Status panel — every read wrapped in try/catch so a single missing row
112// doesn't 500 the entire page.
113// ---------------------------------------------------------------------------
114
115interface AutoMergeState {
116 enabled: boolean;
117 exists: boolean;
118}
119
120async function readAutoMergeState(): Promise<AutoMergeState> {
121 try {
122 // Resolve owner/name from the constant. Owner is `ccantynz` and the
123 // repo name is `Gluecron.com`.
124 const [owner, repoName] = OPS_REPO.split("/");
125 if (!owner || !repoName) return { enabled: false, exists: false };
126 const [ownerRow] = await db
127 .select({ id: users.id })
128 .from(users)
129 .where(eq(users.username, owner))
130 .limit(1);
131 if (!ownerRow) return { enabled: false, exists: false };
132 const [repoRow] = await db
133 .select({ id: repositories.id })
134 .from(repositories)
135 .where(
136 and(
137 eq(repositories.ownerId, ownerRow.id),
138 eq(repositories.name, repoName)
139 )
140 )
141 .limit(1);
142 if (!repoRow) return { enabled: false, exists: false };
143 const [bp] = await db
144 .select({ enableAutoMerge: branchProtection.enableAutoMerge })
145 .from(branchProtection)
146 .where(
147 and(
148 eq(branchProtection.repositoryId, repoRow.id),
149 eq(branchProtection.pattern, OPS_PATTERN)
150 )
151 )
152 .limit(1);
153 if (!bp) return { enabled: false, exists: false };
154 return { enabled: !!bp.enableAutoMerge, exists: true };
155 } catch (err) {
156 console.error("[admin-ops] readAutoMergeState:", err);
157 return { enabled: false, exists: false };
158 }
159}
160
161interface LatestDeploy {
162 sha: string;
163 status: string;
164 startedAt: Date;
165 finishedAt: Date | null;
166}
167
168async function readLatestDeploy(): Promise<LatestDeploy | null> {
169 try {
170 const [row] = await db
171 .select({
172 sha: platformDeploys.sha,
173 status: platformDeploys.status,
174 startedAt: platformDeploys.startedAt,
175 finishedAt: platformDeploys.finishedAt,
176 })
177 .from(platformDeploys)
178 .orderBy(desc(platformDeploys.startedAt))
179 .limit(1);
180 return row ?? null;
181 } catch (err) {
182 console.error("[admin-ops] readLatestDeploy:", err);
183 return null;
184 }
185}
186
187async function readReadinessChecks(): Promise<CheckResult[]> {
188 const out: CheckResult[] = [];
189 // 1. Migration probe
190 try {
191 out.push(
192 await checkMigration0040(async () => {
193 try {
194 const rows = await db.execute(
195 sql`SELECT column_name FROM information_schema.columns
196 WHERE table_name = 'branch_protection'
197 AND column_name = 'enable_auto_merge'
198 LIMIT 1`
199 );
200 const list =
201 (rows as any).rows ?? (Array.isArray(rows) ? rows : []);
202 return { exists: list.length > 0 };
203 } catch (err) {
204 return {
205 exists: false,
206 error: err instanceof Error ? err.message : String(err),
207 };
208 }
209 })
210 );
211 } catch {
212 out.push({
213 name: "Migration 0040 applied",
214 status: "fail",
215 reason: "check threw",
216 });
217 }
218 // 2 + 3. env-driven checks
219 out.push(checkAnthropicKey(process.env));
220 out.push(checkAutopilotEnabled(process.env));
221 // 4. autopilot sweep registration — best effort
222 try {
223 const mod = await import("../lib/autopilot");
224 out.push(checkAutoMergeSweepRegistered(mod.defaultTasks()));
225 } catch {
226 out.push({
227 name: "K3 auto-merge-sweep task registered",
228 status: "fail",
229 reason: "autopilot module failed to load",
230 });
231 }
232 return out;
233}
234
235// ---------------------------------------------------------------------------
236// Render helpers
237// ---------------------------------------------------------------------------
238
e2ae06aClaude239/* ─────────────────────────────────────────────────────────────────────────
240 * Scoped CSS — every class prefixed `.ops-` so this surface can't bleed
241 * into other admin pages. Mirrors the gradient-hairline hero + card
242 * patterns from admin-integrations.tsx and error-page.tsx.
243 * ───────────────────────────────────────────────────────────────────── */
244const opsStyles = `
eed4684Claude245 .ops-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e2ae06aClaude246
247 /* ─── Hero ─── */
248 .ops-hero {
249 position: relative;
250 margin-bottom: var(--space-5);
251 padding: var(--space-5) var(--space-6);
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 16px;
255 overflow: hidden;
256 }
257 .ops-hero::before {
258 content: '';
259 position: absolute;
260 top: 0; left: 0; right: 0;
261 height: 2px;
6fd5915Claude262 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
e2ae06aClaude263 opacity: 0.7;
264 pointer-events: none;
265 }
266 .ops-hero-orb {
267 position: absolute;
268 inset: -20% -10% auto auto;
269 width: 380px; height: 380px;
6fd5915Claude270 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
e2ae06aClaude271 filter: blur(80px);
272 opacity: 0.7;
273 pointer-events: none;
274 z-index: 0;
275 }
276 .ops-hero-inner {
277 position: relative;
278 z-index: 1;
279 display: flex;
280 align-items: flex-end;
281 justify-content: space-between;
282 gap: var(--space-4);
283 flex-wrap: wrap;
284 }
285 .ops-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
286 .ops-eyebrow {
287 font-size: 12px;
288 color: var(--text-muted);
289 margin-bottom: var(--space-2);
290 letter-spacing: 0.02em;
291 display: inline-flex;
292 align-items: center;
293 gap: 8px;
294 }
295 .ops-eyebrow .ops-eyebrow-pill {
296 display: inline-flex;
297 align-items: center;
298 justify-content: center;
299 width: 18px; height: 18px;
300 border-radius: 6px;
6fd5915Claude301 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt302 color: var(--accent);
6fd5915Claude303 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
e2ae06aClaude304 }
305 .ops-eyebrow .ops-who { color: var(--accent); font-weight: 600; }
306 .ops-title {
307 font-size: clamp(28px, 4vw, 40px);
308 font-family: var(--font-display);
309 font-weight: 800;
310 letter-spacing: -0.028em;
311 line-height: 1.05;
312 margin: 0 0 var(--space-2);
313 color: var(--text-strong);
314 }
315 .ops-title-grad {
6fd5915Claude316 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
e2ae06aClaude317 -webkit-background-clip: text;
318 background-clip: text;
319 -webkit-text-fill-color: transparent;
320 color: transparent;
321 }
322 .ops-sub {
323 font-size: 15px;
324 color: var(--text-muted);
325 margin: 0;
326 line-height: 1.5;
327 max-width: 620px;
328 }
329 .ops-sub code {
330 font-family: var(--font-mono);
331 font-size: 13px;
332 background: var(--bg-tertiary);
333 padding: 1px 5px;
334 border-radius: 4px;
335 }
336 .ops-hero-back {
337 display: inline-flex;
338 align-items: center;
339 gap: 6px;
340 padding: 7px 12px;
341 font-size: 12.5px;
342 color: var(--text-muted);
343 background: rgba(255,255,255,0.02);
344 border: 1px solid var(--border);
345 border-radius: 8px;
346 text-decoration: none;
347 font-weight: 500;
348 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
349 }
350 .ops-hero-back:hover {
351 border-color: var(--border-strong);
352 color: var(--text-strong);
353 background: rgba(255,255,255,0.04);
354 text-decoration: none;
355 }
356
357 /* ─── Banners ─── */
358 .ops-banner {
359 margin-bottom: var(--space-4);
360 padding: 10px 14px;
361 border-radius: 10px;
362 font-size: 13.5px;
363 border: 1px solid var(--border);
364 background: rgba(255,255,255,0.025);
365 color: var(--text);
366 display: flex;
367 align-items: center;
368 gap: 10px;
369 }
370 .ops-banner.is-ok {
371 border-color: rgba(52,211,153,0.40);
372 background: rgba(52,211,153,0.08);
373 color: #bbf7d0;
374 }
375 .ops-banner.is-error {
376 border-color: rgba(248,113,113,0.40);
377 background: rgba(248,113,113,0.08);
378 color: #fecaca;
379 }
380 .ops-banner-dot {
381 width: 8px; height: 8px;
382 border-radius: 9999px;
383 background: currentColor;
384 flex-shrink: 0;
385 }
386
387 /* ─── Section cards ─── */
388 .ops-section {
389 margin-bottom: var(--space-5);
390 background: var(--bg-elevated);
391 border: 1px solid var(--border);
392 border-radius: 14px;
393 overflow: hidden;
394 }
395 .ops-section-head {
396 padding: var(--space-4) var(--space-5);
397 border-bottom: 1px solid var(--border);
398 display: flex;
399 align-items: flex-start;
400 justify-content: space-between;
401 gap: var(--space-3);
402 flex-wrap: wrap;
403 }
404 .ops-section-head-text { flex: 1; min-width: 240px; }
405 .ops-section-title {
406 margin: 0;
407 font-family: var(--font-display);
408 font-size: 17px;
409 font-weight: 700;
410 letter-spacing: -0.018em;
411 color: var(--text-strong);
412 display: flex;
413 align-items: center;
414 gap: 10px;
415 }
416 .ops-section-title-icon {
417 display: inline-flex;
418 align-items: center;
419 justify-content: center;
420 width: 26px; height: 26px;
421 border-radius: 8px;
6fd5915Claude422 background: rgba(91,110,232,0.12);
e589f77ccantynz-alt423 color: var(--accent);
6fd5915Claude424 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
e2ae06aClaude425 flex-shrink: 0;
426 }
427 .ops-section-sub {
428 margin: 6px 0 0 36px;
429 font-size: 12.5px;
430 color: var(--text-muted);
431 line-height: 1.45;
432 }
433 .ops-section-body { padding: var(--space-4) var(--space-5); }
434
435 /* ─── Auto-merge toggle pill ─── */
436 .ops-toggle-row {
437 display: flex;
438 align-items: center;
439 justify-content: space-between;
440 gap: var(--space-3);
441 flex-wrap: wrap;
442 margin-bottom: var(--space-4);
443 }
444 .ops-toggle-form { margin: 0; }
445 .ops-toggle {
446 display: inline-flex;
447 align-items: center;
448 gap: 10px;
449 padding: 8px 18px 8px 12px;
450 border-radius: 9999px;
451 border: 1px solid var(--border-strong);
452 background: rgba(255,255,255,0.025);
453 color: var(--text);
454 font: inherit;
455 font-size: 13px;
456 font-weight: 600;
457 cursor: pointer;
458 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
459 }
6fd5915Claude460 .ops-toggle:hover { transform: translateY(-1px); border-color: rgba(91,110,232,0.45); }
e2ae06aClaude461 .ops-toggle:disabled { cursor: not-allowed; opacity: 0.5; transform: none; }
462 .ops-toggle-dot {
463 width: 10px; height: 10px;
464 border-radius: 9999px;
465 background: #6b7280;
466 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
467 }
468 .ops-toggle.is-on {
469 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
470 color: #062b1f;
471 border-color: rgba(52,211,153,0.55);
472 box-shadow: 0 6px 18px -6px rgba(52,211,153,0.45);
473 }
474 .ops-toggle.is-on .ops-toggle-dot {
475 background: #062b1f;
476 box-shadow: 0 0 0 3px rgba(6,43,31,0.20);
477 }
478 .ops-toggle.is-danger {
479 background: rgba(248,113,113,0.10);
480 border-color: rgba(248,113,113,0.40);
481 color: #fecaca;
482 }
e589f77ccantynz-alt483 .ops-toggle.is-danger .ops-toggle-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(248,113,113,0.25); }
e2ae06aClaude484 .ops-toggle-status {
485 display: flex;
486 align-items: center;
487 gap: 10px;
488 font-size: 13px;
489 color: var(--text-muted);
490 flex-wrap: wrap;
491 }
492 .ops-toggle-status .ops-target {
493 font-family: var(--font-mono);
494 font-size: 12px;
495 color: var(--text);
496 background: rgba(255,255,255,0.04);
497 border: 1px solid var(--border);
498 padding: 2px 8px;
499 border-radius: 6px;
500 }
501
502 .ops-blurb {
503 font-size: 12.5px;
504 color: var(--text-muted);
505 line-height: 1.55;
506 margin: 0;
507 }
508
509 /* ─── Readiness traffic lights ─── */
510 .ops-readiness {
511 list-style: none;
512 padding: 0;
513 margin: 0;
514 display: flex;
515 flex-direction: column;
516 gap: 2px;
517 border-top: 1px solid var(--border);
518 }
519 .ops-readiness li {
520 display: flex;
521 align-items: flex-start;
522 gap: 12px;
523 padding: 10px 2px;
524 border-bottom: 1px solid var(--border);
525 font-size: 13px;
526 }
527 .ops-readiness li:last-child { border-bottom: none; }
528 .ops-light {
529 flex-shrink: 0;
530 margin-top: 5px;
531 width: 10px; height: 10px;
532 border-radius: 9999px;
533 background: #6b7280;
534 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
535 }
536 .ops-light.is-pass {
e589f77ccantynz-alt537 background: var(--green);
e2ae06aClaude538 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
539 }
540 .ops-light.is-warn {
541 background: #fbbf24;
542 box-shadow: 0 0 0 3px rgba(251,191,36,0.22), 0 0 8px rgba(251,191,36,0.40);
543 }
544 .ops-light.is-fail {
e589f77ccantynz-alt545 background: var(--red);
e2ae06aClaude546 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
547 animation: opsPulse 1.8s ease-in-out infinite;
548 }
549 @keyframes opsPulse {
550 0%, 100% { opacity: 1; transform: scale(1); }
551 50% { opacity: 0.7; transform: scale(0.92); }
552 }
553 @media (prefers-reduced-motion: reduce) {
554 .ops-light.is-fail { animation: none; }
555 }
556 .ops-readiness-text { flex: 1; min-width: 0; }
557 .ops-readiness-name {
558 font-family: var(--font-mono);
559 font-size: 12.5px;
560 font-weight: 600;
561 color: var(--text-strong);
562 word-break: break-word;
563 }
564 .ops-readiness-detail {
565 margin-top: 2px;
566 font-size: 12px;
567 color: var(--text-muted);
568 line-height: 1.45;
569 }
570 .ops-readiness-summary {
571 display: inline-flex;
572 align-items: center;
573 gap: 8px;
574 padding: 4px 10px;
575 border-radius: 9999px;
576 font-size: 11.5px;
577 font-weight: 600;
578 letter-spacing: 0.04em;
579 text-transform: uppercase;
580 flex-shrink: 0;
581 }
582 .ops-readiness-summary.is-ok {
583 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt584 color: var(--green);
e2ae06aClaude585 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
586 }
587 .ops-readiness-summary.is-fail {
588 background: rgba(248,113,113,0.12);
589 color: #fecaca;
590 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
591 }
592 .ops-readiness-summary .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
593
594 /* ─── Pill (deploy status) ─── */
595 .ops-pill {
596 display: inline-flex;
597 align-items: center;
598 gap: 6px;
599 padding: 2px 10px;
600 border-radius: 9999px;
601 font-size: 11.5px;
602 font-weight: 600;
603 }
e589f77ccantynz-alt604 .ops-pill.is-ok { background: rgba(52,211,153,0.16); color: var(--green); }
605 .ops-pill.is-bad { background: rgba(248,113,113,0.16); color: var(--red); }
e2ae06aClaude606 .ops-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
607
608 /* ─── Deploy meta strip ─── */
609 .ops-meta-strip {
610 display: flex;
611 align-items: center;
612 gap: 10px;
613 font-size: 13px;
614 color: var(--text-muted);
615 margin-bottom: var(--space-3);
616 flex-wrap: wrap;
617 }
618 .ops-meta-strip code {
619 font-family: var(--font-mono);
620 font-size: 12px;
621 background: rgba(255,255,255,0.04);
622 border: 1px solid var(--border);
623 padding: 2px 7px;
624 border-radius: 6px;
625 color: var(--text);
626 }
627
628 /* ─── GateTest credentials block ─── */
629 .ops-creds {
630 display: grid;
631 grid-template-columns: 180px 1fr;
632 gap: 8px 14px;
633 margin-bottom: var(--space-3);
634 align-items: center;
635 }
636 @media (max-width: 600px) {
637 .ops-creds { grid-template-columns: 1fr; gap: 4px 0; }
638 .ops-creds-key { margin-top: var(--space-2); }
639 }
640 .ops-creds-key {
641 font-family: var(--font-mono);
642 font-size: 12.5px;
643 font-weight: 600;
644 color: var(--text-strong);
645 letter-spacing: -0.005em;
646 }
647 .ops-creds-val {
648 display: flex;
649 align-items: center;
650 gap: 8px;
651 min-width: 0;
652 }
653 .ops-creds-val code {
654 flex: 1;
655 min-width: 0;
656 font-family: var(--font-mono);
657 font-size: 12.5px;
658 color: var(--text);
659 background: rgba(255,255,255,0.04);
660 border: 1px solid var(--border);
661 padding: 6px 10px;
662 border-radius: 8px;
663 word-break: break-all;
664 overflow-wrap: anywhere;
665 }
666 .ops-creds-val code.is-muted { color: var(--text-muted); font-style: italic; }
667 .ops-creds-val code.is-fresh {
668 color: #e9d5ff;
6fd5915Claude669 background: linear-gradient(135deg, rgba(91,110,232,0.12), rgba(95,143,160,0.08));
670 border-color: rgba(91,110,232,0.40);
e2ae06aClaude671 }
672 .ops-copy {
673 display: inline-flex;
674 align-items: center;
675 justify-content: center;
676 width: 32px; height: 32px;
677 flex-shrink: 0;
678 border-radius: 8px;
679 background: rgba(255,255,255,0.04);
680 border: 1px solid var(--border);
681 color: var(--text-muted);
682 cursor: pointer;
683 transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
684 font: inherit;
685 }
686 .ops-copy:hover {
6fd5915Claude687 background: rgba(91,110,232,0.10);
688 border-color: rgba(91,110,232,0.40);
e2ae06aClaude689 color: var(--text-strong);
690 }
691 .ops-copy.is-copied {
692 background: rgba(52,211,153,0.14);
693 border-color: rgba(52,211,153,0.45);
e589f77ccantynz-alt694 color: var(--green);
e2ae06aClaude695 }
696 .ops-copy svg { display: block; }
697
698 .ops-fresh-token-warn {
699 display: flex;
700 align-items: flex-start;
701 gap: 8px;
702 margin: 0 0 var(--space-3);
703 padding: 10px 12px;
704 font-size: 12.5px;
705 color: #fde68a;
706 background: rgba(251,191,36,0.08);
707 border: 1px solid rgba(251,191,36,0.35);
708 border-radius: 10px;
709 line-height: 1.45;
710 }
711 .ops-fresh-token-warn::before {
712 content: '!';
713 flex-shrink: 0;
714 display: inline-flex;
715 align-items: center;
716 justify-content: center;
717 width: 18px; height: 18px;
718 border-radius: 9999px;
719 background: rgba(251,191,36,0.20);
720 color: #fbbf24;
721 font-weight: 700;
722 font-size: 11px;
723 font-family: var(--font-display);
724 }
725
726 /* ─── Buttons ─── */
727 .ops-btn {
728 display: inline-flex;
729 align-items: center;
730 gap: 6px;
731 padding: 9px 16px;
732 border-radius: 10px;
733 font-size: 13px;
734 font-weight: 600;
735 text-decoration: none;
736 border: 1px solid transparent;
737 cursor: pointer;
738 font: inherit;
739 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
740 line-height: 1;
741 }
742 .ops-btn-primary {
6fd5915Claude743 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
e2ae06aClaude744 color: #ffffff;
6fd5915Claude745 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
e2ae06aClaude746 }
747 .ops-btn-primary:hover:not(:disabled) {
748 transform: translateY(-1px);
6fd5915Claude749 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
e2ae06aClaude750 }
751 .ops-btn-primary:disabled {
752 cursor: not-allowed;
753 opacity: 0.5;
754 box-shadow: none;
755 transform: none;
756 }
757 .ops-btn-ghost {
758 background: transparent;
759 color: var(--text);
760 border-color: var(--border-strong);
761 }
762 .ops-btn-ghost:hover:not(:disabled) {
6fd5915Claude763 background: rgba(91,110,232,0.06);
764 border-color: rgba(91,110,232,0.45);
e2ae06aClaude765 color: var(--text-strong);
766 }
767 .ops-btn-danger {
768 background: transparent;
e589f77ccantynz-alt769 color: var(--red);
e2ae06aClaude770 border-color: rgba(248,113,113,0.35);
771 }
772 .ops-btn-danger:hover:not(:disabled) {
773 border-style: dashed;
774 border-color: rgba(248,113,113,0.70);
775 background: rgba(248,113,113,0.06);
776 color: #fecaca;
777 }
778 .ops-btn-danger:disabled {
779 cursor: not-allowed;
780 opacity: 0.5;
781 color: var(--text-muted);
782 border-color: var(--border);
783 }
784
785 .ops-actions {
786 display: flex;
787 align-items: center;
788 gap: var(--space-3);
789 flex-wrap: wrap;
790 }
791 .ops-actions form { margin: 0; }
792 .ops-action-hint {
793 font-size: 12px;
794 color: var(--text-muted);
795 line-height: 1.5;
796 max-width: 460px;
797 }
798
799 /* ─── 403 fallback ─── */
800 .ops-403 {
801 max-width: 540px;
802 margin: var(--space-12) auto;
803 padding: var(--space-6);
804 text-align: center;
805 background: var(--bg-elevated);
806 border: 1px solid var(--border);
807 border-radius: 16px;
808 }
809 .ops-403 h2 {
810 font-family: var(--font-display);
811 font-size: 22px;
812 margin: 0 0 8px;
813 color: var(--text-strong);
814 }
815 .ops-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
816`;
817
818/* Inline copy-to-clipboard helper. Looks for any `[data-ops-copy]` button,
819 * reads the linked element's textContent, and pulses a `.is-copied` class
820 * for ~1.6s. Works even in non-secure contexts via a hidden-textarea fallback. */
821const opsCopyScript = `
822 (function(){
823 function copyText(text, btn){
824 var done = function(){
825 btn.classList.add('is-copied');
826 var prev = btn.getAttribute('aria-label') || 'Copy';
827 btn.setAttribute('aria-label', 'Copied');
828 setTimeout(function(){
829 btn.classList.remove('is-copied');
830 btn.setAttribute('aria-label', prev);
831 }, 1600);
832 };
833 function fallback(){
834 var ta = document.createElement('textarea');
835 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
836 document.body.appendChild(ta); ta.select();
837 try { document.execCommand('copy'); done(); } catch(e){}
838 document.body.removeChild(ta);
839 }
840 if (navigator.clipboard && navigator.clipboard.writeText) {
841 navigator.clipboard.writeText(text).then(done).catch(fallback);
842 } else { fallback(); }
843 }
844 document.addEventListener('click', function(ev){
845 var t = ev.target;
846 while (t && t !== document.body && !(t.getAttribute && t.getAttribute('data-ops-copy') !== null)) {
847 t = t.parentNode;
848 }
849 if (!t || t === document.body) return;
850 var sel = t.getAttribute('data-ops-copy');
851 var src = sel ? document.querySelector(sel) : t.previousElementSibling;
852 if (!src) return;
853 copyText((src.textContent || '').trim(), t);
854 });
855 })();
856`;
857
858/* Small inline-SVG icons. */
859function IconShield() {
9dd96b9Test User860 return (
e2ae06aClaude861 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
862 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
863 </svg>
9dd96b9Test User864 );
865}
e2ae06aClaude866function IconBolt() {
867 return (
868 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
869 <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
870 </svg>
871 );
872}
873function IconCheck() {
874 return (
875 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
876 <polyline points="20 6 9 17 4 12" />
877 </svg>
878 );
879}
880function IconRocket() {
881 return (
882 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
883 <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
884 <path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
885 <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
886 <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
887 </svg>
888 );
889}
890function IconKey() {
891 return (
892 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
893 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
894 </svg>
895 );
896}
897function IconRollback() {
898 return (
899 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
900 <polyline points="1 4 1 10 7 10" />
901 <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
902 </svg>
903 );
904}
905function IconCopy() {
9dd96b9Test User906 return (
e2ae06aClaude907 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
908 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
909 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
910 </svg>
9dd96b9Test User911 );
912}
e2ae06aClaude913function IconArrowLeft() {
914 return (
915 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
916 <line x1="19" y1="12" x2="5" y2="12" />
917 <polyline points="12 19 5 12 12 5" />
918 </svg>
919 );
920}
921
922/** Map a CheckResult.status into a traffic-light class. */
923function readinessClass(status: string): "is-pass" | "is-warn" | "is-fail" {
924 if (status === "pass") return "is-pass";
925 if (status === "fail") return "is-fail";
926 return "is-warn";
927}
9dd96b9Test User928
929// ---------------------------------------------------------------------------
930// Gating
931// ---------------------------------------------------------------------------
932
933const ops = new Hono<AuthEnv>();
934ops.use("*", softAuth);
935
936async function gate(c: any): Promise<{ user: any } | Response> {
937 const user = c.get("user");
938 if (!user) return c.redirect("/login?next=/admin/ops");
939 if (!(await isSiteAdmin(user.id))) {
940 return c.html(
941 <Layout title="Forbidden" user={user}>
e2ae06aClaude942 <div class="ops-403">
9dd96b9Test User943 <h2>403 — Not a site admin</h2>
944 <p>You don't have permission to view this page.</p>
945 </div>
e2ae06aClaude946 <style dangerouslySetInnerHTML={{ __html: opsStyles }} />
9dd96b9Test User947 </Layout>,
948 403
949 );
950 }
951 return { user };
952}
953
954function redirectWith(c: any, kind: "success" | "error", msg: string): Response {
955 return c.redirect(`/admin/ops?${kind}=${encodeURIComponent(msg)}`);
956}
957
958// ---------------------------------------------------------------------------
959// GET /admin/ops
960// ---------------------------------------------------------------------------
961
962ops.get("/admin/ops", async (c) => {
963 const g = await gate(c);
964 if (g instanceof Response) return g;
965 const { user } = g;
966
967 // Surface flash messages from prior POSTs.
968 const success = c.req.query("success");
969 const error = c.req.query("error");
970
971 // Pull every status read in parallel — a slow one shouldn't block the rest.
972 const [autoMergeState, readiness, latest, previous] = await Promise.all([
973 readAutoMergeState(),
974 readReadinessChecks(),
975 readLatestDeploy(),
976 _deps.findPreviousSuccessfulDeploy().catch(() => null),
977 ]);
978
979 const readinessAllGreen = readiness.every((r) => r.status === "pass");
e2ae06aClaude980 const readinessFailCount = readiness.filter((r) => r.status !== "pass").length;
981 const gatetestToken = c.req.query("gatetest_token");
9dd96b9Test User982
983 return c.html(
f4a1547ccantynz-alt984 <AdminShell active="ops" title="Operations" user={user}>
e2ae06aClaude985 <div class="ops-wrap">
986 {/* ─── Hero ─── */}
987 <section class="ops-hero">
988 <div class="ops-hero-orb" aria-hidden="true" />
989 <div class="ops-hero-inner">
990 <div class="ops-hero-text">
991 <div class="ops-eyebrow">
992 <span class="ops-eyebrow-pill" aria-hidden="true">
993 <IconShield />
994 </span>
995 Operations · Site admin · <span class="ops-who">{user.username}</span>
996 </div>
997 <h1 class="ops-title">
998 <span class="ops-title-grad">Run the platform.</span>
999 </h1>
1000 <p class="ops-sub">
1001 Every operational lever that used to live in a terminal — auto-merge,
1002 deploys, scanner credentials, rollback. Every action is audit-logged
1003 under <code>admin.ops.*</code>.
1004 </p>
1005 </div>
1006 <a href="/admin" class="ops-hero-back">
1007 <IconArrowLeft />
1008 Back to admin
1009 </a>
1010 </div>
1011 </section>
9dd96b9Test User1012
1013 {success && (
e2ae06aClaude1014 <div class="ops-banner is-ok" role="status">
1015 <span class="ops-banner-dot" aria-hidden="true" />
9dd96b9Test User1016 {decodeURIComponent(success)}
1017 </div>
1018 )}
1019 {error && (
e2ae06aClaude1020 <div class="ops-banner is-error" role="alert">
1021 <span class="ops-banner-dot" aria-hidden="true" />
9dd96b9Test User1022 {decodeURIComponent(error)}
1023 </div>
1024 )}
1025
e2ae06aClaude1026 {/* ─── Auto-merge section ─── */}
1027 <section class="ops-section">
1028 <header class="ops-section-head">
1029 <div class="ops-section-head-text">
1030 <h3 class="ops-section-title">
1031 <span class="ops-section-title-icon" aria-hidden="true">
1032 <IconBolt />
1033 </span>
1034 AI auto-merge on main
1035 </h3>
1036 <p class="ops-section-sub">
1037 When enabled, every PR Claude opens that passes gates auto-merges
1038 within ~30s and deploys ~25s later — under a minute end-to-end.
1039 </p>
1040 </div>
1041 </header>
1042 <div class="ops-section-body">
1043 <div class="ops-toggle-row">
1044 {autoMergeState.enabled ? (
1045 <form
1046 method="post"
1047 action="/admin/ops/auto-merge/disable"
1048 class="ops-toggle-form"
1049 >
1050 <button
1051 type="submit"
1052 class="ops-toggle is-on"
1053 aria-label="Disable AI auto-merge"
1054 title="Click to disable"
1055 >
1056 <span class="ops-toggle-dot" aria-hidden="true" />
1057 Enabled · click to disable
1058 </button>
1059 </form>
1060 ) : (
1061 <form
1062 method="post"
1063 action="/admin/ops/auto-merge/enable"
1064 class="ops-toggle-form"
1065 >
1066 <button
1067 type="submit"
1068 class={readinessAllGreen ? "ops-toggle" : "ops-toggle is-danger"}
1069 disabled={!readinessAllGreen}
1070 aria-label="Enable AI auto-merge"
1071 title={
1072 readinessAllGreen
1073 ? "Enable AI auto-merge"
1074 : "Fix the readiness items first"
1075 }
1076 >
1077 <span class="ops-toggle-dot" aria-hidden="true" />
1078 {readinessAllGreen
1079 ? "Disabled · click to enable"
1080 : "Blocked · fix readiness first"}
1081 </button>
1082 </form>
1083 )}
1084 <div class="ops-toggle-status">
1085 <span class="ops-target">{OPS_REPO}@{OPS_PATTERN}</span>
1086 </div>
1087 </div>
9dd96b9Test User1088
e2ae06aClaude1089 <header
1090 style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:6px"
9dd96b9Test User1091 >
e2ae06aClaude1092 <div
1093 style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.08em;font-weight:600"
1094 >
1095 Readiness check
1096 </div>
1097 <span
1098 class={
1099 "ops-readiness-summary " +
1100 (readinessAllGreen ? "is-ok" : "is-fail")
1101 }
1102 >
1103 <span class="dot" aria-hidden="true" />
1104 {readinessAllGreen
1105 ? "all green"
1106 : `${readinessFailCount} blocking`}
1107 </span>
1108 </header>
1109 <ul class="ops-readiness" aria-label="Auto-merge readiness checks">
9dd96b9Test User1110 {readiness.map((r) => (
e2ae06aClaude1111 <li>
9dd96b9Test User1112 <span
e2ae06aClaude1113 class={"ops-light " + readinessClass(r.status)}
1114 aria-label={r.status}
1115 />
1116 <div class="ops-readiness-text">
1117 <div class="ops-readiness-name">{r.name}</div>
9dd96b9Test User1118 {r.reason && (
e2ae06aClaude1119 <div class="ops-readiness-detail">{r.reason}</div>
9dd96b9Test User1120 )}
e2ae06aClaude1121 </div>
9dd96b9Test User1122 </li>
1123 ))}
1124 </ul>
1125 </div>
e2ae06aClaude1126 </section>
9dd96b9Test User1127
e2ae06aClaude1128 {/* ─── Deploy section ─── */}
1129 <section class="ops-section">
1130 <header class="ops-section-head">
1131 <div class="ops-section-head-text">
1132 <h3 class="ops-section-title">
1133 <span class="ops-section-title-icon" aria-hidden="true">
1134 <IconRocket />
1135 </span>
1136 Deploy
1137 </h3>
1138 <p class="ops-section-sub">
1139 Fires hetzner-deploy.yml on main. Typical run: 25–90 seconds.
1140 </p>
1141 </div>
1142 </header>
1143 <div class="ops-section-body">
1144 <div class="ops-meta-strip">
1145 {latest ? (
1146 <>
1147 <span>Last deploy</span>
1148 <span
1149 class={
1150 "ops-pill " +
1151 (latest.status === "succeeded" ? "is-ok" : "is-bad")
1152 }
1153 >
1154 <span class="dot" aria-hidden="true" />
1155 {latest.status}
1156 </span>
1157 <code>{shortSha(latest.sha)}</code>
1158 <span title={latest.startedAt.toISOString()}>
1159 {relativeTime(latest.startedAt)}
1160 </span>
1161 </>
1162 ) : (
1163 <span>Last deploy: —</span>
1164 )}
1165 </div>
1166 <div class="ops-actions">
1167 <form method="post" action="/admin/ops/deploy/trigger">
1168 <button type="submit" class="ops-btn ops-btn-primary">
1169 <IconRocket />
1170 Trigger deploy now
9dd96b9Test User1171 </button>
1172 </form>
e2ae06aClaude1173 <a href="/admin/deploys" class="ops-btn ops-btn-ghost">
1174 Watch deploys
1175 </a>
1176 </div>
1177 </div>
1178 </section>
1179
1180 {/* ─── GateTest credentials section ─── */}
1181 <section class="ops-section">
1182 <header class="ops-section-head">
1183 <div class="ops-section-head-text">
1184 <h3 class="ops-section-title">
1185 <span class="ops-section-title-icon" aria-hidden="true">
1186 <IconKey />
1187 </span>
1188 GateTest scanner credentials
1189 </h3>
1190 <p class="ops-section-sub">
1191 Two values to paste into GateTest's environment so it can scan
1192 this site. Token is admin-scoped — revoke at{" "}
1193 <a href="/settings/tokens" style="color:var(--accent);text-decoration:none">/settings/tokens</a>{" "}
1194 when scanning is done.
1195 </p>
1196 </div>
1197 </header>
1198 <div class="ops-section-body">
1199 <div class="ops-creds">
1200 <div class="ops-creds-key">GLUECRON_BASE_URL</div>
1201 <div class="ops-creds-val">
1202 <code id="ops-creds-base-url">{config.appBaseUrl}</code>
1203 <button
1204 type="button"
1205 class="ops-copy"
1206 data-ops-copy="#ops-creds-base-url"
1207 aria-label="Copy GLUECRON_BASE_URL"
1208 title="Copy"
1209 >
1210 <IconCopy />
1211 </button>
1212 </div>
1213
1214 <div class="ops-creds-key">GLUECRON_API_TOKEN</div>
1215 <div class="ops-creds-val">
1216 {gatetestToken ? (
1217 <>
1218 <code id="ops-creds-token" class="is-fresh">{gatetestToken}</code>
1219 <button
1220 type="button"
1221 class="ops-copy"
1222 data-ops-copy="#ops-creds-token"
1223 aria-label="Copy GLUECRON_API_TOKEN"
1224 title="Copy"
1225 >
1226 <IconCopy />
1227 </button>
1228 </>
1229 ) : (
1230 <code class="is-muted">— click below to issue (shown once) —</code>
1231 )}
1232 </div>
1233 </div>
1234
1235 {gatetestToken && (
1236 <p class="ops-fresh-token-warn">
1237 <span>
1238 Copy the token now. It is hashed in the DB and will not be
1239 shown again.
1240 </span>
1241 </p>
1242 )}
1243
1244 <div class="ops-actions">
1245 <form method="post" action="/admin/ops/gatetest-token">
1246 <button type="submit" class="ops-btn ops-btn-primary">
1247 <IconKey />
1248 Issue scanner token
1249 </button>
1250 </form>
1251 <span class="ops-action-hint">
1252 Mints a fresh admin-scoped API token and reveals it once on the
1253 next page load.
1254 </span>
1255 </div>
1256 </div>
1257 </section>
1258
1259 {/* ─── Rollback section ─── */}
1260 <section class="ops-section">
1261 <header class="ops-section-head">
1262 <div class="ops-section-head-text">
1263 <h3 class="ops-section-title">
1264 <span class="ops-section-title-icon" aria-hidden="true">
1265 <IconRollback />
1266 </span>
1267 Rollback
1268 </h3>
1269 <p class="ops-section-sub">
1270 Resets main to the previous tagged release. Use if the latest
1271 deploy broke something.
1272 </p>
1273 </div>
1274 </header>
1275 <div class="ops-section-body">
1276 <div class="ops-meta-strip">
1277 {previous ? (
1278 <>
1279 <span>Previous successful deploy</span>
1280 <code>{shortSha(previous.sha)}</code>
1281 <span title={previous.finishedAt.toISOString()}>
1282 {relativeTime(previous.finishedAt)}
1283 </span>
1284 </>
1285 ) : (
1286 <span>Previous successful deploy: —</span>
1287 )}
1288 </div>
1289 <div class="ops-actions">
9dd96b9Test User1290 <form
1291 method="post"
e2ae06aClaude1292 action="/admin/ops/rollback"
1293 onsubmit="return confirm('Roll back main to the previous tagged release?')"
9dd96b9Test User1294 >
1295 <button
1296 type="submit"
e2ae06aClaude1297 class="ops-btn ops-btn-danger"
1298 disabled={!previous}
9dd96b9Test User1299 title={
e2ae06aClaude1300 previous
1301 ? `Rollback to ${shortSha(previous.sha)}`
1302 : "No prior successful deploy on file"
9dd96b9Test User1303 }
1304 >
e2ae06aClaude1305 <IconRollback />
1306 {previous ? `Rollback to ${shortSha(previous.sha)}` : "Rollback"}
9dd96b9Test User1307 </button>
1308 </form>
e2ae06aClaude1309 <span class="ops-action-hint">
1310 Confirms before dispatching. Watch{" "}
1311 <a href="/admin/deploys" style="color:var(--accent);text-decoration:none">/admin/deploys</a>{" "}
1312 for progress.
9dd96b9Test User1313 </span>
e2ae06aClaude1314 </div>
9dd96b9Test User1315 </div>
e2ae06aClaude1316 </section>
9dd96b9Test User1317 </div>
e2ae06aClaude1318 <style dangerouslySetInnerHTML={{ __html: opsStyles }} />
1319 <script dangerouslySetInnerHTML={{ __html: opsCopyScript }} />
f4a1547ccantynz-alt1320 </AdminShell>
9dd96b9Test User1321 );
1322});
1323
1324// ---------------------------------------------------------------------------
1325// POST /admin/ops/auto-merge/{enable,disable}
1326// ---------------------------------------------------------------------------
1327
1328async function handleAutoMergeFlip(c: any, off: boolean): Promise<Response> {
1329 const g = await gate(c);
1330 if (g instanceof Response) return g;
1331 const { user } = g;
1332
1333 try {
1334 const result = await _deps.runEnableAutoMerge(
1335 db as unknown as DbLike,
1336 {
1337 ownerSlash: OPS_REPO,
1338 pattern: OPS_PATTERN,
1339 off,
1340 actorUserId: user.id,
1341 },
1342 _deps.audit
1343 );
1344 try {
1345 await _deps.audit({
1346 userId: user.id,
1347 action: off ? "admin.ops.auto_merge_disable" : "admin.ops.auto_merge_enable",
1348 targetType: "branch_protection",
1349 targetId: result.after?.id,
1350 metadata: {
1351 repo: OPS_REPO,
1352 pattern: OPS_PATTERN,
1353 scriptAction: result.action,
1354 },
1355 });
1356 } catch {
1357 // audit failure is non-fatal for the user-facing flow
1358 }
1359 const verb = off ? "disabled" : "enabled";
1360 const tail =
1361 result.action === "noop"
1362 ? `already ${verb}`
1363 : result.action === "inserted"
1364 ? `${verb} (new rule created)`
1365 : `${verb}`;
1366 return redirectWith(c, "success", `Auto-merge ${tail} on ${OPS_REPO}@${OPS_PATTERN}.`);
1367 } catch (err) {
1368 const message = err instanceof Error ? err.message : String(err);
1369 return redirectWith(
1370 c,
1371 "error",
1372 `Failed to ${off ? "disable" : "enable"} auto-merge: ${message}`
1373 );
1374 }
1375}
1376
1377ops.post("/admin/ops/auto-merge/enable", (c) => handleAutoMergeFlip(c, false));
1378ops.post("/admin/ops/auto-merge/disable", (c) => handleAutoMergeFlip(c, true));
1379
1380// ---------------------------------------------------------------------------
1381// POST /admin/ops/deploy/trigger
1382//
1383// Re-uses the N4 handler. We don't duplicate the GitHub API call — we
1384// simply invoke `/admin/deploys/trigger` on the same Hono `app.request`
1385// with the caller's session cookie forwarded so softAuth + isSiteAdmin
1386// pass cleanly on the inner call.
1387// ---------------------------------------------------------------------------
1388
1389ops.post("/admin/ops/deploy/trigger", async (c) => {
1390 const g = await gate(c);
1391 if (g instanceof Response) return g;
1392 const { user } = g;
1393
1394 try {
1395 // Fetch the live app instance lazily — avoids a circular import at module
1396 // load time.
1397 const { default: app } = await import("../app");
1398 const cookie = c.req.header("cookie") ?? "";
1399 const origin = c.req.header("origin") ?? "";
1400 const host = c.req.header("host") ?? "";
1401 const res = await app.request("/admin/deploys/trigger", {
1402 method: "POST",
1403 headers: {
1404 "content-type": "application/json",
1405 cookie,
1406 origin,
1407 host,
1408 },
1409 body: JSON.stringify({}),
1410 });
1411 if (res.ok) {
1412 try {
1413 await _deps.audit({
1414 userId: user.id,
1415 action: "admin.ops.deploy_triggered",
1416 targetType: "workflow",
1417 targetId: "hetzner-deploy.yml",
1418 metadata: { repo: OPS_REPO },
1419 });
1420 } catch {
1421 /* non-fatal */
1422 }
1423 return redirectWith(c, "success", "Deploy dispatched — watch /admin/deploys for progress.");
1424 }
1425 let raw = "";
1426 try {
1427 raw = await res.text();
1428 } catch {
1429 /* swallow */
1430 }
1431 let msg = `deploy trigger returned ${res.status}`;
1432 try {
1433 const j = JSON.parse(raw);
1434 if (j?.error) msg = String(j.error);
1435 } catch {
1436 if (raw) msg = raw.slice(0, 240);
1437 }
1438 return redirectWith(c, "error", msg);
1439 } catch (err) {
1440 const message = err instanceof Error ? err.message : String(err);
1441 return redirectWith(c, "error", `Deploy trigger failed: ${message}`);
1442 }
1443});
1444
1445// ---------------------------------------------------------------------------
1446// POST /admin/ops/rollback
1447// ---------------------------------------------------------------------------
1448
1449ops.post("/admin/ops/rollback", async (c) => {
1450 const g = await gate(c);
1451 if (g instanceof Response) return g;
1452 const { user } = g;
1453
1454 let prev: PreviousDeploy | null = null;
1455 try {
1456 prev = await _deps.findPreviousSuccessfulDeploy();
1457 } catch (err) {
1458 const message = err instanceof Error ? err.message : String(err);
1459 return redirectWith(c, "error", `Rollback lookup failed: ${message}`);
1460 }
1461 if (!prev) {
1462 return redirectWith(
1463 c,
1464 "error",
1465 "No previous successful deploy on file — nothing to roll back to."
1466 );
1467 }
1468
1469 let result: TriggerRollbackResult;
1470 try {
1471 result = await _deps.triggerRollback({
1472 targetSha: prev.sha,
1473 triggeredByUserId: user.id,
1474 });
1475 } catch (err) {
1476 const message = err instanceof Error ? err.message : String(err);
1477 return redirectWith(c, "error", `Rollback dispatch threw: ${message}`);
1478 }
1479
1480 if (!result.ok) {
1481 return redirectWith(c, "error", result.error || "Rollback dispatch failed.");
1482 }
1483
1484 try {
1485 await _deps.audit({
1486 userId: user.id,
1487 action: "admin.ops.rollback_dispatched",
1488 targetType: "workflow",
1489 targetId: "hetzner-deploy.yml",
1490 metadata: { repo: OPS_REPO, target_sha: prev.sha },
1491 });
1492 } catch {
1493 /* non-fatal */
1494 }
1495
1496 return redirectWith(
1497 c,
1498 "success",
1499 `Rollback dispatched to ${shortSha(prev.sha)} — watch /admin/deploys for progress.`
1500 );
1501});
1502
0ec4eceTest User1503// ---------------------------------------------------------------------------
1504// POST /admin/ops/gatetest-token
1505// ---------------------------------------------------------------------------
1506//
1507// Mint a fresh admin-scoped API token for the GateTest scanner and surface
1508// it once via the redirect query string. The DB stores only the SHA-256
1509// hash (same shape tokens.tsx uses) so the plaintext is unrecoverable after
1510// this redirect — operator must copy it immediately into GateTest's
1511// environment.
1512
1513function generateGateTestToken(): string {
1514 const bytes = crypto.getRandomValues(new Uint8Array(32));
1515 return (
1516 "glc_" +
1517 Array.from(bytes)
1518 .map((b) => b.toString(16).padStart(2, "0"))
1519 .join("")
1520 );
1521}
1522
1523async function sha256Hex(input: string): Promise<string> {
1524 const data = new TextEncoder().encode(input);
1525 const hash = await crypto.subtle.digest("SHA-256", data);
1526 return Array.from(new Uint8Array(hash))
1527 .map((b) => b.toString(16).padStart(2, "0"))
1528 .join("");
1529}
1530
1531ops.post("/admin/ops/gatetest-token", async (c) => {
1532 const g = await gate(c);
1533 if (g instanceof Response) return g;
1534 const { user } = g;
1535
1536 const token = generateGateTestToken();
1537 const tokenH = await sha256Hex(token);
1538 const stamp = new Date().toISOString().slice(0, 10);
1539
1540 try {
1541 await db.insert(apiTokens).values({
1542 userId: user.id,
1543 name: `GateTest scanner (${stamp})`,
1544 tokenHash: tokenH,
1545 tokenPrefix: token.slice(0, 12),
1546 scopes: "admin",
1547 });
1548 } catch (err) {
1549 const message = err instanceof Error ? err.message : String(err);
1550 return redirectWith(c, "error", `Token insert failed: ${message}`);
1551 }
1552
1553 try {
1554 await _deps.audit({
1555 userId: user.id,
1556 action: "admin.ops.gatetest_token_issued",
1557 targetType: "api_token",
1558 metadata: { scope: "admin", prefix: token.slice(0, 12) },
1559 });
1560 } catch {
1561 /* non-fatal */
1562 }
1563
1564 return c.redirect(`/admin/ops?gatetest_token=${encodeURIComponent(token)}`);
1565});
1566
9dd96b9Test User1567export const __test = {
1568 readAutoMergeState,
1569 readLatestDeploy,
1570 readReadinessChecks,
1571 OPS_REPO,
1572 OPS_PATTERN,
1573};
1574
1575export default ops;