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.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.tsxBlame4519 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
b218e63Claude24import { and, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
8f50ed0Claude25import { db } from "../db";
8cfb00eClaude26import { aiCostEvents, auditLog, issueComments, issues, prComments, pullRequests, repositories, users } from "../db/schema";
8f50ed0Claude27import { Layout } from "../views/layout";
5618f9aClaude28import { softAuth } from "../middleware/auth";
8f50ed0Claude29import type { AuthEnv } from "../middleware/auth";
30import {
31 grantSiteAdmin,
32 isSiteAdmin,
33 KNOWN_FLAGS,
34 listFlags,
35 listSiteAdmins,
36 revokeSiteAdmin,
37 setFlag,
38} from "../lib/admin";
39import { audit } from "../lib/notify";
08420cdClaude40import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
8e9f1d9Claude41import {
42 getLastTick,
43 getTickCount,
44 runAutopilotTick,
45} from "../lib/autopilot";
988380aClaude46import { ensureDemoContent, DEMO_USERNAME } from "../lib/demo-seed";
8f50ed0Claude47
48const admin = new Hono<AuthEnv>();
49admin.use("*", softAuth);
50
07f4b70Claude51/* ─────────────────────────────────────────────────────────────────────────
52 * Scoped CSS — every class prefixed `.admin-` so the block cannot bleed
53 * into other surfaces. Pattern mirrors dashboard-hero (commit a004c46),
54 * repo-home (commit 544d842), and settings polish (commit 98eb360).
55 * ───────────────────────────────────────────────────────────────────── */
56const adminStyles = `
eed4684Claude57 .admin-wrap { max-width: 1680px; margin: 0 auto; }
07f4b70Claude58
59 /* ─── Hero (main dashboard) ─── */
60 .admin-hero {
61 position: relative;
62 margin-bottom: var(--space-6);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .admin-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
6fd5915Claude74 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
07f4b70Claude75 opacity: 0.7;
76 pointer-events: none;
77 }
78 .admin-hero-bg {
79 position: absolute;
80 inset: -20% -10% auto auto;
81 width: 380px; height: 380px;
82 pointer-events: none;
83 z-index: 0;
84 }
85 .admin-hero-inner {
86 position: relative;
87 z-index: 1;
88 max-width: 720px;
89 }
90 .admin-hero-eyebrow {
91 font-size: 12px;
92 color: var(--text-muted);
93 margin-bottom: var(--space-2);
94 letter-spacing: 0.02em;
95 text-transform: none;
96 display: inline-flex;
97 align-items: center;
98 gap: 8px;
99 }
100 .admin-hero-eyebrow .admin-shield {
101 display: inline-flex;
102 align-items: center;
103 justify-content: center;
104 width: 18px; height: 18px;
105 border-radius: 6px;
6fd5915Claude106 background: rgba(91,110,232,0.14);
107 color: #5b6ee8;
108 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
07f4b70Claude109 }
110 .admin-hero-eyebrow .admin-who {
111 color: var(--accent);
112 font-weight: 600;
113 }
114 .admin-hero-title {
115 font-size: clamp(28px, 4vw, 40px);
116 font-family: var(--font-display);
117 font-weight: 800;
118 letter-spacing: -0.028em;
119 line-height: 1.05;
120 margin: 0 0 var(--space-2);
121 color: var(--text-strong);
122 }
123 .admin-hero-title .gradient-text,
124 .admin-hero-title-grad {
8cfb00eClaude125 color: var(--text-strong);
126 background: none;
127 -webkit-background-clip: unset;
128 background-clip: unset;
07f4b70Claude129 }
130 .admin-hero-sub {
131 font-size: 15px;
132 color: var(--text-muted);
133 margin: 0;
134 line-height: 1.5;
135 max-width: 620px;
136 }
137
138 /* ─── Section hero (sub-pages) ─── */
139 .admin-sec-hero {
140 position: relative;
141 margin-bottom: var(--space-5);
142 padding: var(--space-4) var(--space-5);
143 background: var(--bg-elevated);
144 border: 1px solid var(--border);
145 border-radius: 14px;
146 overflow: hidden;
147 display: flex;
148 align-items: flex-end;
149 justify-content: space-between;
150 gap: var(--space-4);
151 flex-wrap: wrap;
152 }
153 .admin-sec-hero::before {
154 content: '';
155 position: absolute;
156 top: 0; left: 0; right: 0;
157 height: 2px;
6fd5915Claude158 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
07f4b70Claude159 opacity: 0.6;
160 pointer-events: none;
161 }
162 .admin-sec-hero-text { flex: 1; min-width: 240px; }
163 .admin-sec-eyebrow {
164 font-size: 11px;
165 font-weight: 600;
166 letter-spacing: 0.08em;
167 text-transform: uppercase;
168 color: var(--accent);
169 margin-bottom: 6px;
170 }
171 .admin-sec-title {
172 font-family: var(--font-display);
173 font-size: clamp(22px, 2.8vw, 28px);
174 font-weight: 800;
175 letter-spacing: -0.022em;
176 line-height: 1.1;
177 margin: 0 0 4px;
178 color: var(--text-strong);
179 }
180 .admin-sec-sub {
181 font-size: 13.5px;
182 color: var(--text-muted);
183 margin: 0;
184 line-height: 1.5;
185 max-width: 620px;
186 }
187 .admin-sec-hero-actions {
188 display: flex;
189 gap: var(--space-2);
190 flex-wrap: wrap;
191 }
192
193 /* ─── Banners ─── */
194 .admin-banner {
195 margin-bottom: var(--space-4);
196 padding: 10px 14px;
197 border-radius: 10px;
198 font-size: 13.5px;
199 border: 1px solid var(--border);
200 background: rgba(255,255,255,0.025);
201 color: var(--text);
202 }
203 .admin-banner.is-error {
204 border-color: rgba(248,113,113,0.40);
205 background: rgba(248,113,113,0.08);
206 color: #fecaca;
207 }
208 .admin-banner.is-ok {
209 border-color: rgba(52,211,153,0.40);
210 background: rgba(52,211,153,0.08);
211 color: #bbf7d0;
212 }
213
214 /* ─── Stat grid ─── */
215 .admin-stat-grid {
216 display: grid;
217 grid-template-columns: repeat(3, 1fr);
218 gap: var(--space-3);
219 margin-bottom: var(--space-5);
220 }
221 @media (max-width: 720px) {
222 .admin-stat-grid { grid-template-columns: 1fr; }
f1dc7c7Claude223 .admin-hero { padding: var(--space-4); }
224 .admin-actions { grid-template-columns: 1fr; }
225 .admin-action { min-height: 44px; padding: 14px; }
226 .admin-list-row { flex-direction: column; align-items: stretch; padding: 14px; }
227 .admin-search { flex-direction: column; align-items: stretch; }
228 .admin-search .admin-input { width: 100%; }
229 .admin-card-body { padding: var(--space-4); }
230 .admin-card-foot { padding: var(--space-3) var(--space-4); justify-content: flex-start; }
231 .admin-ap-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
07f4b70Claude232 }
233 .admin-stat {
234 position: relative;
235 padding: var(--space-4) var(--space-4);
236 background: var(--bg-elevated);
237 border: 1px solid var(--border);
238 border-radius: 14px;
239 overflow: hidden;
240 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
241 }
242 .admin-stat::after {
243 content: '';
244 position: absolute;
245 inset: 0;
246 border-radius: inherit;
6fd5915Claude247 background: linear-gradient(135deg, rgba(91,110,232,0.05), rgba(95,143,160,0.04));
07f4b70Claude248 opacity: 0;
249 pointer-events: none;
250 transition: opacity 200ms ease;
251 }
252 .admin-stat:hover {
253 transform: translateY(-2px);
254 border-color: var(--border-strong);
255 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
256 }
257 .admin-stat:hover::after { opacity: 1; }
258 .admin-stat-head {
259 display: flex;
260 align-items: center;
261 justify-content: space-between;
262 margin-bottom: var(--space-2);
263 position: relative;
264 z-index: 1;
265 }
266 .admin-stat-label {
267 font-size: 11px;
268 font-weight: 600;
269 letter-spacing: 0.08em;
270 text-transform: uppercase;
271 color: var(--text-muted);
272 }
273 .admin-stat-icon {
274 display: inline-flex;
275 align-items: center;
276 justify-content: center;
277 width: 26px; height: 26px;
278 border-radius: 8px;
6fd5915Claude279 background: rgba(91,110,232,0.12);
280 color: #5b6ee8;
281 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
07f4b70Claude282 }
283 .admin-stat-value {
284 position: relative;
285 z-index: 1;
286 font-family: var(--font-display);
287 font-size: 36px;
288 font-weight: 800;
289 letter-spacing: -0.028em;
290 line-height: 1;
291 color: var(--text-strong);
292 }
293 .admin-stat-hint {
294 margin-top: 6px;
295 font-size: 12px;
296 color: var(--text-faint);
297 position: relative;
298 z-index: 1;
299 }
300
301 /* ─── Action grid ─── */
302 .admin-actions {
303 display: grid;
304 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
305 gap: var(--space-2);
306 margin-bottom: var(--space-6);
307 }
308 .admin-action {
309 display: flex;
310 align-items: center;
311 gap: 10px;
312 padding: 12px 14px;
313 background: var(--bg-elevated);
314 border: 1px solid var(--border);
315 border-radius: 12px;
316 color: var(--text);
317 text-decoration: none;
318 font-size: 13.5px;
319 font-weight: 500;
320 line-height: 1.25;
321 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
322 cursor: pointer;
323 text-align: left;
324 font-family: inherit;
325 }
326 .admin-action:hover {
327 border-color: var(--border-strong);
328 background: rgba(255,255,255,0.025);
329 transform: translateY(-1px);
330 }
331 .admin-action:focus-visible {
332 outline: none;
333 border-color: var(--border-focus);
6fd5915Claude334 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude335 }
336 .admin-action .admin-action-icon {
337 display: inline-flex;
338 align-items: center;
339 justify-content: center;
340 width: 30px; height: 30px;
341 border-radius: 8px;
342 background: rgba(255,255,255,0.03);
343 color: var(--text-muted);
344 flex-shrink: 0;
345 box-shadow: inset 0 0 0 1px var(--border);
346 }
347 .admin-action.is-primary {
6fd5915Claude348 border-color: rgba(91,110,232,0.35);
349 background: linear-gradient(135deg, rgba(91,110,232,0.14), rgba(95,143,160,0.10));
07f4b70Claude350 color: var(--text-strong);
351 }
352 .admin-action.is-primary:hover {
6fd5915Claude353 border-color: rgba(91,110,232,0.55);
354 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
07f4b70Claude355 }
356 .admin-action.is-primary .admin-action-icon {
6fd5915Claude357 background: rgba(91,110,232,0.18);
07f4b70Claude358 color: #c5b3ff;
6fd5915Claude359 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
07f4b70Claude360 }
361 .admin-action-form { display: contents; }
362
363 /* ─── Section header (h3 replacement) ─── */
364 .admin-h3 {
365 display: flex;
366 align-items: baseline;
367 justify-content: space-between;
368 gap: var(--space-3);
369 margin: var(--space-5) 0 var(--space-3);
370 }
371 .admin-h3 h3 {
372 font-family: var(--font-display);
373 font-size: 16px;
374 font-weight: 700;
375 letter-spacing: -0.014em;
376 margin: 0;
377 color: var(--text-strong);
378 }
379 .admin-h3-meta {
380 font-size: 12px;
381 color: var(--text-muted);
382 }
383
384 /* ─── Lists / cards ─── */
385 .admin-list {
386 background: var(--bg-elevated);
387 border: 1px solid var(--border);
388 border-radius: 14px;
389 overflow: hidden;
390 }
391 .admin-list-row {
392 display: flex;
393 align-items: center;
394 justify-content: space-between;
395 gap: var(--space-3);
396 padding: 12px 16px;
397 border-bottom: 1px solid var(--border-subtle);
398 transition: background 120ms ease;
399 }
400 .admin-list-row:last-child { border-bottom: none; }
401 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
402 .admin-list-empty {
403 padding: var(--space-5);
404 text-align: center;
405 color: var(--text-muted);
406 font-size: 13.5px;
407 }
408 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
409 .admin-avatar {
410 width: 30px; height: 30px;
411 border-radius: 9999px;
6fd5915Claude412 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
07f4b70Claude413 color: var(--text-strong);
414 display: inline-flex;
415 align-items: center;
416 justify-content: center;
417 font-size: 12px;
418 font-weight: 700;
6fd5915Claude419 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
07f4b70Claude420 flex-shrink: 0;
421 text-transform: uppercase;
422 }
423 .admin-avatar.is-admin {
6fd5915Claude424 background: linear-gradient(135deg, rgba(91,110,232,0.50), rgba(95,143,160,0.35));
07f4b70Claude425 color: #fff;
6fd5915Claude426 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.55), 0 0 12px rgba(91,110,232,0.25);
07f4b70Claude427 }
428 .admin-row-text { min-width: 0; }
429 .admin-row-title {
430 font-size: 14px;
431 font-weight: 600;
432 color: var(--text-strong);
433 text-decoration: none;
434 }
435 .admin-row-title:hover { color: var(--accent-hover); }
436 .admin-row-sub {
437 margin-top: 2px;
438 font-size: 12px;
439 color: var(--text-muted);
440 display: flex;
441 gap: 8px;
442 flex-wrap: wrap;
443 align-items: center;
444 }
445 .admin-pill {
446 display: inline-flex;
447 align-items: center;
448 gap: 4px;
449 padding: 2px 8px;
450 border-radius: 9999px;
451 font-size: 10.5px;
452 font-weight: 600;
453 letter-spacing: 0.04em;
454 text-transform: uppercase;
455 }
456 .admin-pill.is-admin {
6fd5915Claude457 background: rgba(91,110,232,0.16);
07f4b70Claude458 color: #c5b3ff;
6fd5915Claude459 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
07f4b70Claude460 }
461 .admin-pill.is-private {
462 background: rgba(251,191,36,0.10);
463 color: #fde68a;
464 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
465 }
466 .admin-pill.is-public {
467 background: rgba(255,255,255,0.04);
468 color: var(--text-muted);
469 box-shadow: inset 0 0 0 1px var(--border);
470 }
471 .admin-pill.is-on {
472 background: rgba(52,211,153,0.14);
473 color: #6ee7b7;
474 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
475 }
476 .admin-pill.is-off {
477 background: rgba(255,255,255,0.04);
478 color: var(--text-muted);
479 box-shadow: inset 0 0 0 1px var(--border);
480 }
481 .admin-pill .dot {
482 width: 6px; height: 6px;
483 border-radius: 9999px;
484 background: currentColor;
485 }
486
487 /* ─── Inline forms / search ─── */
488 .admin-search {
489 display: flex;
490 gap: 8px;
491 flex-wrap: wrap;
492 align-items: center;
493 margin-bottom: var(--space-4);
494 }
495 .admin-input {
496 width: 320px;
497 max-width: 100%;
498 padding: 9px 12px;
499 font-size: 14px;
500 color: var(--text);
501 background: var(--bg);
502 border: 1px solid var(--border-strong);
503 border-radius: 8px;
504 outline: none;
505 font-family: var(--font-sans);
506 transition: border-color 120ms ease, box-shadow 120ms ease;
507 }
508 .admin-input:focus {
509 border-color: var(--border-focus);
6fd5915Claude510 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude511 }
512
513 /* ─── Flags form ─── */
514 .admin-card {
515 background: var(--bg-elevated);
516 border: 1px solid var(--border);
517 border-radius: 14px;
518 overflow: hidden;
519 }
520 .admin-card-body { padding: var(--space-5); }
521 .admin-card-foot {
522 padding: var(--space-3) var(--space-5);
523 border-top: 1px solid var(--border);
524 background: rgba(255,255,255,0.012);
525 display: flex;
526 justify-content: flex-end;
527 gap: var(--space-2);
528 align-items: center;
529 flex-wrap: wrap;
530 }
531 .admin-card-foot .admin-foot-hint {
532 margin-right: auto;
533 font-size: 12.5px;
534 color: var(--text-muted);
535 }
536 .admin-field { margin-bottom: var(--space-4); }
537 .admin-field:last-child { margin-bottom: 0; }
538 .admin-field label {
539 display: block;
540 font-family: var(--font-mono);
541 font-size: 12.5px;
542 font-weight: 600;
543 color: var(--text-strong);
544 margin-bottom: 6px;
545 letter-spacing: -0.005em;
546 }
547 .admin-field .admin-input-mono {
548 width: 100%;
549 padding: 9px 12px;
550 font-size: 13.5px;
551 color: var(--text);
552 background: var(--bg);
553 border: 1px solid var(--border-strong);
554 border-radius: 8px;
555 outline: none;
556 font-family: var(--font-mono);
557 transition: border-color 120ms ease, box-shadow 120ms ease;
558 }
559 .admin-field .admin-input-mono:focus {
560 border-color: var(--border-focus);
6fd5915Claude561 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude562 }
563 .admin-field-hint {
564 font-size: 11.5px;
565 color: var(--text-muted);
566 margin-top: 6px;
567 line-height: 1.45;
568 }
569 .admin-field-hint code {
570 font-family: var(--font-mono);
571 font-size: 11.5px;
572 background: var(--bg-tertiary);
573 padding: 1px 5px;
574 border-radius: 4px;
575 }
576
577 /* ─── Digest forms ─── */
578 .admin-digest-row {
579 display: flex;
580 gap: 8px;
581 align-items: center;
582 flex-wrap: wrap;
583 }
584
585 /* ─── Autopilot specific ─── */
586 .admin-ap-table {
587 width: 100%;
588 border-collapse: collapse;
589 background: var(--bg-elevated);
590 border: 1px solid var(--border);
591 border-radius: 14px;
592 overflow: hidden;
593 }
594 .admin-ap-table thead th {
595 text-align: left;
596 font-size: 11px;
597 font-weight: 600;
598 letter-spacing: 0.08em;
599 text-transform: uppercase;
600 color: var(--text-muted);
601 padding: 10px 14px;
602 background: rgba(255,255,255,0.015);
603 border-bottom: 1px solid var(--border);
604 }
605 .admin-ap-table tbody td {
606 padding: 10px 14px;
607 border-bottom: 1px solid var(--border-subtle);
608 font-size: 13px;
609 color: var(--text);
610 vertical-align: top;
611 }
612 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
613 .admin-ap-table code {
614 font-family: var(--font-mono);
615 font-size: 12px;
616 color: var(--text-strong);
617 }
618 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
619 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
620 .admin-ap-empty {
621 padding: var(--space-5);
622 text-align: center;
623 color: var(--text-muted);
624 font-size: 13.5px;
625 background: var(--bg-elevated);
626 border: 1px dashed var(--border);
627 border-radius: 14px;
628 }
629 .admin-ap-foot {
630 margin-top: var(--space-5);
631 padding: var(--space-3) var(--space-4);
632 border: 1px solid var(--border-subtle);
633 background: rgba(255,255,255,0.015);
634 border-radius: 10px;
635 color: var(--text-muted);
636 font-size: 12.5px;
637 }
638 .admin-ap-foot code {
639 font-family: var(--font-mono);
640 font-size: 12px;
641 background: var(--bg-tertiary);
642 padding: 1px 5px;
643 border-radius: 4px;
644 color: var(--text);
645 }
646
647 /* ─── Misc ─── */
648 .admin-403 {
649 max-width: 540px;
650 margin: var(--space-12) auto;
651 padding: var(--space-6);
652 text-align: center;
653 background: var(--bg-elevated);
654 border: 1px solid var(--border);
655 border-radius: 16px;
656 }
657 .admin-403 h2 {
658 font-family: var(--font-display);
659 font-size: 22px;
660 margin: 0 0 8px;
661 color: var(--text-strong);
662 }
663 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
6dec21bClaude664
665 /* ─── Automation activity tile ─── */
666 .admin-automation-tile {
667 position: relative;
668 padding: var(--space-4);
669 background: var(--bg-elevated);
670 border: 1px solid var(--border);
671 border-radius: 14px;
672 overflow: hidden;
673 margin-bottom: var(--space-5);
674 }
675 .admin-automation-tile::before {
676 content: '';
677 position: absolute;
678 top: 0; left: 0; right: 0;
679 height: 2px;
680 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
681 opacity: 0.5;
682 pointer-events: none;
683 }
684 .admin-automation-heading {
685 display: flex;
686 align-items: center;
687 gap: 8px;
688 margin-bottom: var(--space-3);
689 }
690 .admin-automation-heading-icon {
691 display: inline-flex;
692 align-items: center;
693 justify-content: center;
694 width: 26px; height: 26px;
695 border-radius: 8px;
696 background: rgba(140,109,255,0.12);
697 color: #b69dff;
698 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
699 }
700 .admin-automation-heading-label {
701 font-family: var(--font-display);
702 font-size: 14px;
703 font-weight: 700;
704 letter-spacing: -0.012em;
705 color: var(--text-strong);
706 }
707 .admin-automation-heading-sub {
708 font-size: 12px;
709 color: var(--text-muted);
710 margin-left: auto;
711 }
712 .admin-automation-grid {
713 display: grid;
714 grid-template-columns: repeat(4, 1fr);
715 gap: var(--space-3);
716 }
717 @media (max-width: 720px) {
718 .admin-automation-grid { grid-template-columns: 1fr 1fr; }
719 }
720 .admin-automation-stat {
721 padding: var(--space-3) var(--space-3);
722 background: rgba(255,255,255,0.02);
723 border: 1px solid var(--border-subtle);
724 border-radius: 10px;
725 display: flex;
726 flex-direction: column;
727 gap: 4px;
728 }
729 .admin-automation-stat-label {
730 font-size: 10.5px;
731 font-weight: 600;
732 letter-spacing: 0.06em;
733 text-transform: uppercase;
734 color: var(--text-muted);
735 }
736 .admin-automation-stat-value {
737 font-family: var(--font-display);
738 font-size: 28px;
739 font-weight: 800;
740 letter-spacing: -0.022em;
741 line-height: 1;
742 color: var(--text-strong);
743 }
744 .admin-automation-stat-hint {
745 font-size: 11px;
746 color: var(--text-faint);
747 }
07f4b70Claude748`;
749
8929744Claude750/* ─────────────────────────────────────────────────────────────────────────
751 * Per-sub-page scoped CSS — each handler gets its own namespace so they
752 * cannot bleed into each other or back into the shared `.admin-*` panel.
753 *
754 * .adm-users-* /admin/users
755 * .adm-repos-* /admin/repos
756 * .adm-flags-* /admin/flags
757 * .adm-digests-* /admin/digests
758 * .adm-autopilot-* /admin/autopilot
759 *
760 * All five mirror the 2026 design language from /admin and /admin/ops:
761 * - gradient hairline (::before)
762 * - animated radial-gradient orb
763 * - clamp() display headline + gradient-text span
764 * - eyebrow + subtitle
765 * - cards with avatar/icon + mono IDs + action buttons
766 * - filter pills / search bar
767 * - empty state with orb
768 * ───────────────────────────────────────────────────────────────────── */
769const admUsersStyles = `
eed4684Claude770 .adm-users-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude771
772 /* Hero */
773 .adm-users-hero {
774 position: relative;
775 margin-bottom: var(--space-5);
776 padding: var(--space-5) var(--space-6);
777 background: var(--bg-elevated);
778 border: 1px solid var(--border);
779 border-radius: 16px;
780 overflow: hidden;
781 }
782 .adm-users-hero::before {
783 content: '';
784 position: absolute;
785 top: 0; left: 0; right: 0;
786 height: 2px;
6fd5915Claude787 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude788 opacity: 0.7;
789 pointer-events: none;
790 }
791 .adm-users-hero-inner {
792 position: relative;
793 z-index: 1;
794 display: flex;
795 align-items: flex-end;
796 justify-content: space-between;
797 gap: var(--space-4);
798 flex-wrap: wrap;
799 }
800 .adm-users-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
801 .adm-users-eyebrow {
802 font-size: 12px;
803 color: var(--text-muted);
804 margin-bottom: var(--space-2);
805 letter-spacing: 0.02em;
806 display: inline-flex;
807 align-items: center;
808 gap: 8px;
809 }
810 .adm-users-eyebrow-pill {
811 display: inline-flex;
812 align-items: center;
813 justify-content: center;
814 width: 22px; height: 22px;
815 border-radius: 6px;
6fd5915Claude816 background: rgba(91,110,232,0.14);
817 color: #5b6ee8;
818 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude819 }
820 .adm-users-title {
821 font-size: clamp(28px, 4vw, 40px);
822 font-family: var(--font-display);
823 font-weight: 800;
824 letter-spacing: -0.028em;
825 line-height: 1.05;
826 margin: 0 0 var(--space-2);
827 color: var(--text-strong);
828 }
829 .adm-users-title-grad {
8cfb00eClaude830 color: var(--text-strong);
831 background: none;
832 -webkit-background-clip: unset;
833 background-clip: unset;
8929744Claude834 }
835 .adm-users-sub {
836 font-size: 15px;
837 color: var(--text-muted);
838 margin: 0;
839 line-height: 1.5;
840 max-width: 620px;
841 }
842 .adm-users-back {
843 display: inline-flex;
844 align-items: center;
845 gap: 6px;
846 padding: 7px 12px;
847 font-size: 12.5px;
848 color: var(--text-muted);
849 background: rgba(255,255,255,0.02);
850 border: 1px solid var(--border);
851 border-radius: 8px;
852 text-decoration: none;
853 font-weight: 500;
854 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
855 }
856 .adm-users-back:hover {
857 border-color: var(--border-strong);
858 color: var(--text-strong);
859 background: rgba(255,255,255,0.04);
860 }
861
862 /* Filter bar */
863 .adm-users-filterbar {
864 display: flex;
865 gap: var(--space-3);
866 align-items: center;
867 justify-content: space-between;
868 margin-bottom: var(--space-4);
869 flex-wrap: wrap;
870 }
871 .adm-users-search {
872 position: relative;
873 display: flex;
874 align-items: center;
875 gap: 8px;
876 flex: 1;
877 min-width: 280px;
878 }
879 .adm-users-search-ico {
880 position: absolute;
881 left: 12px;
882 top: 50%;
883 transform: translateY(-50%);
884 color: var(--text-muted);
885 pointer-events: none;
886 display: inline-flex;
887 }
888 .adm-users-input {
889 flex: 1;
890 width: 100%;
891 padding: 10px 12px 10px 36px;
892 font-size: 14px;
893 color: var(--text);
894 background: var(--bg);
895 border: 1px solid var(--border-strong);
896 border-radius: 10px;
897 outline: none;
898 font-family: var(--font-sans);
899 transition: border-color 120ms ease, box-shadow 120ms ease;
900 }
901 .adm-users-input:focus {
902 border-color: var(--border-focus);
6fd5915Claude903 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude904 }
905 .adm-users-pills {
906 display: inline-flex;
907 gap: 6px;
908 flex-wrap: wrap;
909 }
910 .adm-users-pill {
911 display: inline-flex;
912 align-items: center;
913 gap: 6px;
914 padding: 4px 10px;
915 border-radius: 9999px;
916 font-size: 11.5px;
917 font-weight: 600;
918 background: rgba(255,255,255,0.04);
919 color: var(--text-muted);
920 box-shadow: inset 0 0 0 1px var(--border);
921 }
922 .adm-users-pill .dot {
923 width: 6px; height: 6px;
924 border-radius: 9999px;
925 background: currentColor;
926 }
927 .adm-users-pill.is-admin {
6fd5915Claude928 background: rgba(91,110,232,0.16);
8929744Claude929 color: #c5b3ff;
6fd5915Claude930 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude931 }
932
933 /* Buttons */
934 .adm-users-btn {
935 display: inline-flex;
936 align-items: center;
937 gap: 6px;
938 padding: 8px 14px;
939 border-radius: 8px;
940 font-size: 13px;
941 font-weight: 600;
942 text-decoration: none;
943 border: 1px solid var(--border-strong);
944 background: rgba(255,255,255,0.02);
945 color: var(--text);
946 cursor: pointer;
947 font: inherit;
948 font-weight: 600;
949 line-height: 1;
950 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
951 }
6fd5915Claude952 .adm-users-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude953 .adm-users-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
954 .adm-users-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
955 .adm-users-btn-primary {
6fd5915Claude956 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude957 color: #fff;
958 border-color: transparent;
6fd5915Claude959 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude960 }
6fd5915Claude961 .adm-users-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude962 .adm-users-btn-danger {
963 background: transparent;
964 color: #fca5a5;
965 border-color: rgba(248,113,113,0.40);
966 }
967 .adm-users-btn-danger:hover {
968 background: rgba(248,113,113,0.08);
969 border-color: rgba(248,113,113,0.70);
970 color: #fecaca;
971 }
972
973 /* Card grid */
974 .adm-users-grid {
975 display: grid;
976 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
977 gap: var(--space-3);
978 }
979 .adm-users-card {
980 position: relative;
981 padding: var(--space-4);
982 background: var(--bg-elevated);
983 border: 1px solid var(--border);
984 border-radius: 14px;
985 display: flex;
986 flex-direction: column;
987 gap: var(--space-3);
988 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
989 }
990 .adm-users-card:hover {
991 transform: translateY(-2px);
992 border-color: var(--border-strong);
993 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
994 }
995 .adm-users-card.is-admin {
6fd5915Claude996 border-color: rgba(91,110,232,0.35);
8929744Claude997 background:
6fd5915Claude998 linear-gradient(180deg, rgba(91,110,232,0.04), transparent 60%),
8929744Claude999 var(--bg-elevated);
1000 }
1001 .adm-users-card-head {
1002 display: flex;
1003 align-items: center;
1004 gap: 12px;
1005 }
1006 .adm-users-avatar {
1007 width: 38px; height: 38px;
1008 border-radius: 9999px;
6fd5915Claude1009 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
8929744Claude1010 color: var(--text-strong);
1011 display: inline-flex;
1012 align-items: center;
1013 justify-content: center;
1014 font-size: 14px;
1015 font-weight: 700;
6fd5915Claude1016 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
8929744Claude1017 flex-shrink: 0;
1018 text-transform: uppercase;
1019 }
1020 .adm-users-avatar.is-admin {
6fd5915Claude1021 background: linear-gradient(135deg, rgba(91,110,232,0.50), rgba(95,143,160,0.35));
8929744Claude1022 color: #fff;
6fd5915Claude1023 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.55), 0 0 12px rgba(91,110,232,0.25);
8929744Claude1024 }
1025 .adm-users-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1026 .adm-users-card-name {
1027 font-size: 14.5px;
1028 font-weight: 700;
1029 color: var(--text-strong);
1030 text-decoration: none;
1031 letter-spacing: -0.005em;
1032 }
1033 .adm-users-card-name:hover { color: var(--accent-hover, var(--accent)); }
1034 .adm-users-card-mono {
1035 font-family: var(--font-mono);
1036 font-size: 11px;
1037 color: var(--text-muted);
1038 background: rgba(255,255,255,0.03);
1039 padding: 1px 6px;
1040 border-radius: 4px;
1041 border: 1px solid var(--border-subtle);
1042 width: fit-content;
1043 }
1044 .adm-users-card-meta {
1045 display: flex;
1046 flex-direction: column;
1047 gap: 6px;
1048 font-size: 12.5px;
1049 }
1050 .adm-users-meta-item { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
1051 .adm-users-meta-key {
1052 font-family: var(--font-mono);
1053 font-size: 10.5px;
1054 text-transform: uppercase;
1055 letter-spacing: 0.06em;
1056 color: var(--text-muted);
1057 flex-shrink: 0;
1058 width: 50px;
1059 }
1060 .adm-users-meta-val { color: var(--text); word-break: break-all; min-width: 0; }
1061 .adm-users-card-actions {
1062 display: flex;
1063 gap: 8px;
1064 flex-wrap: wrap;
1065 margin-top: auto;
1066 }
1067 .adm-users-card-actions form { margin: 0; }
1068
1069 /* Empty state */
1070 .adm-users-empty {
1071 position: relative;
1072 padding: var(--space-12) var(--space-6);
1073 border: 1px dashed var(--border);
1074 border-radius: 16px;
1075 background: var(--bg-elevated);
1076 text-align: center;
1077 overflow: hidden;
1078 }
1079 .adm-users-empty-orb {
1080 position: absolute;
1081 inset: 50% auto auto 50%;
1082 transform: translate(-50%, -50%);
1083 width: 320px; height: 320px;
6fd5915Claude1084 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude1085 filter: blur(60px);
1086 pointer-events: none;
1087 z-index: 0;
1088 }
1089 .adm-users-empty-inner { position: relative; z-index: 1; }
1090 .adm-users-empty-icon {
1091 display: inline-flex;
1092 align-items: center;
1093 justify-content: center;
1094 width: 56px; height: 56px;
1095 border-radius: 16px;
6fd5915Claude1096 background: rgba(91,110,232,0.10);
1097 color: #5b6ee8;
1098 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1099 margin-bottom: var(--space-3);
1100 }
1101 .adm-users-empty-icon svg { width: 24px; height: 24px; }
1102 .adm-users-empty-title {
1103 font-family: var(--font-display);
1104 font-size: 20px;
1105 font-weight: 700;
1106 letter-spacing: -0.015em;
1107 color: var(--text-strong);
1108 margin-bottom: 6px;
1109 }
1110 .adm-users-empty-sub {
1111 color: var(--text-muted);
1112 font-size: 13.5px;
1113 line-height: 1.5;
1114 }
1115 .adm-users-empty-sub code {
1116 font-family: var(--font-mono);
1117 font-size: 12px;
1118 background: var(--bg-tertiary);
1119 padding: 1px 6px;
1120 border-radius: 4px;
1121 color: var(--text);
1122 }
f1dc7c7Claude1123
1124 @media (max-width: 720px) {
1125 .adm-users-wrap { padding: var(--space-4) var(--space-3); }
1126 .adm-users-hero { padding: var(--space-4); }
1127 .adm-users-grid { grid-template-columns: 1fr; }
1128 }
8929744Claude1129`;
1130
1131const admReposStyles = `
eed4684Claude1132 .adm-repos-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1133
1134 .adm-repos-hero {
1135 position: relative;
1136 margin-bottom: var(--space-5);
1137 padding: var(--space-5) var(--space-6);
1138 background: var(--bg-elevated);
1139 border: 1px solid var(--border);
1140 border-radius: 16px;
1141 overflow: hidden;
1142 }
1143 .adm-repos-hero::before {
1144 content: '';
1145 position: absolute;
1146 top: 0; left: 0; right: 0;
1147 height: 2px;
6fd5915Claude1148 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1149 opacity: 0.7;
1150 pointer-events: none;
1151 }
1152 .adm-repos-hero-inner {
1153 position: relative;
1154 z-index: 1;
1155 display: flex;
1156 align-items: flex-end;
1157 justify-content: space-between;
1158 gap: var(--space-4);
1159 flex-wrap: wrap;
1160 }
1161 .adm-repos-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1162 .adm-repos-eyebrow {
1163 font-size: 12px;
1164 color: var(--text-muted);
1165 margin-bottom: var(--space-2);
1166 letter-spacing: 0.02em;
1167 display: inline-flex;
1168 align-items: center;
1169 gap: 8px;
1170 }
1171 .adm-repos-eyebrow-pill {
1172 display: inline-flex;
1173 align-items: center;
1174 justify-content: center;
1175 width: 22px; height: 22px;
1176 border-radius: 6px;
6fd5915Claude1177 background: rgba(91,110,232,0.14);
1178 color: #5b6ee8;
1179 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1180 }
1181 .adm-repos-title {
1182 font-size: clamp(28px, 4vw, 40px);
1183 font-family: var(--font-display);
1184 font-weight: 800;
1185 letter-spacing: -0.028em;
1186 line-height: 1.05;
1187 margin: 0 0 var(--space-2);
1188 color: var(--text-strong);
1189 }
1190 .adm-repos-title-grad {
8cfb00eClaude1191 color: var(--text-strong);
1192 background: none;
1193 -webkit-background-clip: unset;
1194 background-clip: unset;
8929744Claude1195 }
1196 .adm-repos-sub {
1197 font-size: 15px;
1198 color: var(--text-muted);
1199 margin: 0;
1200 line-height: 1.5;
1201 max-width: 620px;
1202 }
1203 .adm-repos-back {
1204 display: inline-flex;
1205 align-items: center;
1206 gap: 6px;
1207 padding: 7px 12px;
1208 font-size: 12.5px;
1209 color: var(--text-muted);
1210 background: rgba(255,255,255,0.02);
1211 border: 1px solid var(--border);
1212 border-radius: 8px;
1213 text-decoration: none;
1214 font-weight: 500;
1215 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1216 }
1217 .adm-repos-back:hover {
1218 border-color: var(--border-strong);
1219 color: var(--text-strong);
1220 background: rgba(255,255,255,0.04);
1221 }
1222
1223 .adm-repos-pills {
1224 display: inline-flex;
1225 gap: 6px;
1226 flex-wrap: wrap;
1227 margin-bottom: var(--space-4);
1228 }
1229 .adm-repos-pill {
1230 display: inline-flex;
1231 align-items: center;
1232 gap: 6px;
1233 padding: 4px 10px;
1234 border-radius: 9999px;
1235 font-size: 11.5px;
1236 font-weight: 600;
1237 background: rgba(255,255,255,0.04);
1238 color: var(--text-muted);
1239 box-shadow: inset 0 0 0 1px var(--border);
1240 }
1241 .adm-repos-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1242 .adm-repos-pill.is-private {
1243 background: rgba(251,191,36,0.10);
1244 color: #fde68a;
1245 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
1246 }
1247 .adm-repos-pill.is-public {
1248 background: rgba(52,211,153,0.10);
1249 color: #86efac;
1250 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
1251 }
1252
1253 .adm-repos-btn {
1254 display: inline-flex;
1255 align-items: center;
1256 gap: 6px;
1257 padding: 8px 14px;
1258 border-radius: 8px;
1259 font-size: 13px;
1260 font-weight: 600;
1261 text-decoration: none;
1262 border: 1px solid var(--border-strong);
1263 background: rgba(255,255,255,0.02);
1264 color: var(--text);
1265 cursor: pointer;
1266 font: inherit;
1267 line-height: 1;
1268 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1269 }
6fd5915Claude1270 .adm-repos-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude1271 .adm-repos-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
1272 .adm-repos-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
1273 .adm-repos-btn-danger {
1274 background: transparent;
1275 color: #fca5a5;
1276 border-color: rgba(248,113,113,0.40);
1277 }
1278 .adm-repos-btn-danger:hover {
1279 background: rgba(248,113,113,0.08);
1280 border-color: rgba(248,113,113,0.70);
1281 color: #fecaca;
1282 }
1283
1284 .adm-repos-grid {
1285 display: grid;
1286 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1287 gap: var(--space-3);
1288 }
1289 .adm-repos-card {
1290 position: relative;
1291 padding: var(--space-4);
1292 background: var(--bg-elevated);
1293 border: 1px solid var(--border);
1294 border-radius: 14px;
1295 display: flex;
1296 flex-direction: column;
1297 gap: var(--space-3);
1298 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1299 }
1300 .adm-repos-card:hover {
1301 transform: translateY(-2px);
1302 border-color: var(--border-strong);
1303 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1304 }
1305 .adm-repos-card-head {
1306 display: flex;
1307 align-items: center;
1308 gap: 12px;
1309 }
1310 .adm-repos-icon {
1311 width: 38px; height: 38px;
1312 border-radius: 10px;
6fd5915Claude1313 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
1314 color: #5b6ee8;
8929744Claude1315 display: inline-flex;
1316 align-items: center;
1317 justify-content: center;
6fd5915Claude1318 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1319 flex-shrink: 0;
1320 }
1321 .adm-repos-icon svg { width: 18px; height: 18px; }
1322 .adm-repos-card-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
1323 .adm-repos-card-name {
1324 font-size: 14.5px;
1325 font-weight: 700;
1326 color: var(--text-strong);
1327 text-decoration: none;
1328 letter-spacing: -0.005em;
1329 word-break: break-all;
1330 }
1331 .adm-repos-card-name:hover { color: var(--accent-hover, var(--accent)); }
1332 .adm-repos-card-mono {
1333 font-family: var(--font-mono);
1334 font-size: 11px;
1335 color: var(--text-muted);
1336 background: rgba(255,255,255,0.03);
1337 padding: 1px 6px;
1338 border-radius: 4px;
1339 border: 1px solid var(--border-subtle);
1340 width: fit-content;
1341 }
1342 .adm-repos-card-meta {
1343 display: flex;
1344 gap: var(--space-3);
1345 flex-wrap: wrap;
1346 font-size: 12.5px;
1347 color: var(--text-muted);
1348 }
1349 .adm-repos-meta-item {
1350 display: inline-flex;
1351 align-items: center;
1352 gap: 5px;
1353 }
1354 .adm-repos-meta-item svg { width: 13px; height: 13px; color: var(--text-muted); }
1355 .adm-repos-card-actions {
1356 display: flex;
1357 gap: 8px;
1358 flex-wrap: wrap;
1359 margin-top: auto;
1360 }
1361 .adm-repos-card-actions form { margin: 0; }
1362
1363 .adm-repos-empty {
1364 position: relative;
1365 padding: var(--space-12) var(--space-6);
1366 border: 1px dashed var(--border);
1367 border-radius: 16px;
1368 background: var(--bg-elevated);
1369 text-align: center;
1370 overflow: hidden;
1371 }
1372 .adm-repos-empty-orb {
1373 position: absolute;
1374 inset: 50% auto auto 50%;
1375 transform: translate(-50%, -50%);
1376 width: 320px; height: 320px;
6fd5915Claude1377 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude1378 filter: blur(60px);
1379 pointer-events: none;
1380 z-index: 0;
1381 }
1382 .adm-repos-empty-inner { position: relative; z-index: 1; }
1383 .adm-repos-empty-icon {
1384 display: inline-flex;
1385 align-items: center;
1386 justify-content: center;
1387 width: 56px; height: 56px;
1388 border-radius: 16px;
6fd5915Claude1389 background: rgba(91,110,232,0.10);
1390 color: #5b6ee8;
1391 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1392 margin-bottom: var(--space-3);
1393 }
1394 .adm-repos-empty-icon svg { width: 24px; height: 24px; }
1395 .adm-repos-empty-title {
1396 font-family: var(--font-display);
1397 font-size: 20px;
1398 font-weight: 700;
1399 letter-spacing: -0.015em;
1400 color: var(--text-strong);
1401 margin-bottom: 6px;
1402 }
1403 .adm-repos-empty-sub {
1404 color: var(--text-muted);
1405 font-size: 13.5px;
1406 line-height: 1.5;
1407 }
f1dc7c7Claude1408
1409 @media (max-width: 720px) {
1410 .adm-repos-wrap { padding: var(--space-4) var(--space-3); }
1411 .adm-repos-hero { padding: var(--space-4); }
1412 .adm-repos-grid { grid-template-columns: 1fr; }
1413 }
8929744Claude1414`;
1415
1416const admFlagsStyles = `
eed4684Claude1417 .adm-flags-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1418
1419 .adm-flags-hero {
1420 position: relative;
1421 margin-bottom: var(--space-5);
1422 padding: var(--space-5) var(--space-6);
1423 background: var(--bg-elevated);
1424 border: 1px solid var(--border);
1425 border-radius: 16px;
1426 overflow: hidden;
1427 }
1428 .adm-flags-hero::before {
1429 content: '';
1430 position: absolute;
1431 top: 0; left: 0; right: 0;
1432 height: 2px;
6fd5915Claude1433 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1434 opacity: 0.7;
1435 pointer-events: none;
1436 }
1437 .adm-flags-hero-inner {
1438 position: relative;
1439 z-index: 1;
1440 display: flex;
1441 align-items: flex-end;
1442 justify-content: space-between;
1443 gap: var(--space-4);
1444 flex-wrap: wrap;
1445 }
1446 .adm-flags-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1447 .adm-flags-eyebrow {
1448 font-size: 12px;
1449 color: var(--text-muted);
1450 margin-bottom: var(--space-2);
1451 letter-spacing: 0.02em;
1452 display: inline-flex;
1453 align-items: center;
1454 gap: 8px;
1455 }
1456 .adm-flags-eyebrow-pill {
1457 display: inline-flex;
1458 align-items: center;
1459 justify-content: center;
1460 width: 22px; height: 22px;
1461 border-radius: 6px;
6fd5915Claude1462 background: rgba(91,110,232,0.14);
1463 color: #5b6ee8;
1464 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1465 }
1466 .adm-flags-title {
1467 font-size: clamp(28px, 4vw, 40px);
1468 font-family: var(--font-display);
1469 font-weight: 800;
1470 letter-spacing: -0.028em;
1471 line-height: 1.05;
1472 margin: 0 0 var(--space-2);
1473 color: var(--text-strong);
1474 }
1475 .adm-flags-title-grad {
8cfb00eClaude1476 color: var(--text-strong);
1477 background: none;
1478 -webkit-background-clip: unset;
1479 background-clip: unset;
8929744Claude1480 }
1481 .adm-flags-sub {
1482 font-size: 15px;
1483 color: var(--text-muted);
1484 margin: 0;
1485 line-height: 1.5;
1486 max-width: 620px;
1487 }
1488 .adm-flags-sub code {
1489 font-family: var(--font-mono);
1490 font-size: 13px;
1491 background: var(--bg-tertiary);
1492 padding: 1px 5px;
1493 border-radius: 4px;
1494 color: var(--text);
1495 }
1496 .adm-flags-back {
1497 display: inline-flex;
1498 align-items: center;
1499 gap: 6px;
1500 padding: 7px 12px;
1501 font-size: 12.5px;
1502 color: var(--text-muted);
1503 background: rgba(255,255,255,0.02);
1504 border: 1px solid var(--border);
1505 border-radius: 8px;
1506 text-decoration: none;
1507 font-weight: 500;
1508 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1509 }
1510 .adm-flags-back:hover {
1511 border-color: var(--border-strong);
1512 color: var(--text-strong);
1513 background: rgba(255,255,255,0.04);
1514 }
1515
1516 .adm-flags-card {
1517 background: var(--bg-elevated);
1518 border: 1px solid var(--border);
1519 border-radius: 14px;
1520 overflow: hidden;
1521 }
1522 .adm-flags-card-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1523 .adm-flags-card-foot {
1524 padding: var(--space-3) var(--space-5);
1525 border-top: 1px solid var(--border);
1526 background: rgba(255,255,255,0.012);
1527 display: flex;
1528 justify-content: flex-end;
1529 gap: var(--space-2);
1530 align-items: center;
1531 flex-wrap: wrap;
1532 }
1533 .adm-flags-foot-hint {
1534 margin-right: auto;
1535 font-size: 12.5px;
1536 color: var(--text-muted);
1537 }
1538
1539 .adm-flags-field {
1540 display: flex;
1541 flex-direction: column;
1542 gap: 6px;
1543 padding: var(--space-3);
1544 border: 1px solid var(--border-subtle);
1545 border-radius: 12px;
1546 background: rgba(255,255,255,0.015);
1547 transition: border-color 120ms ease, background 120ms ease;
1548 }
1549 .adm-flags-field:hover { border-color: var(--border); background: rgba(255,255,255,0.025); }
1550 .adm-flags-field-head {
1551 display: flex;
1552 align-items: center;
1553 gap: 8px;
1554 }
1555 .adm-flags-key {
1556 font-family: var(--font-mono);
1557 font-size: 12.5px;
1558 font-weight: 600;
1559 color: var(--text-strong);
1560 letter-spacing: -0.005em;
1561 }
1562 .adm-flags-mono {
1563 font-family: var(--font-mono);
1564 font-size: 10.5px;
1565 color: var(--text-muted);
1566 background: rgba(255,255,255,0.04);
1567 padding: 1px 6px;
1568 border-radius: 4px;
1569 border: 1px solid var(--border-subtle);
1570 }
1571 .adm-flags-input {
1572 width: 100%;
1573 padding: 9px 12px;
1574 font-size: 13.5px;
1575 color: var(--text);
1576 background: var(--bg);
1577 border: 1px solid var(--border-strong);
1578 border-radius: 8px;
1579 outline: none;
1580 font-family: var(--font-mono);
1581 transition: border-color 120ms ease, box-shadow 120ms ease;
1582 box-sizing: border-box;
1583 }
1584 .adm-flags-input:focus {
1585 border-color: var(--border-focus);
6fd5915Claude1586 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude1587 }
1588 .adm-flags-hint {
1589 font-size: 11.5px;
1590 color: var(--text-muted);
1591 margin-top: 2px;
1592 line-height: 1.45;
1593 }
1594 .adm-flags-hint code {
1595 font-family: var(--font-mono);
1596 font-size: 11.5px;
1597 background: var(--bg-tertiary);
1598 padding: 1px 5px;
1599 border-radius: 4px;
1600 color: var(--text);
1601 }
1602
1603 .adm-flags-btn {
1604 display: inline-flex;
1605 align-items: center;
1606 gap: 6px;
1607 padding: 9px 16px;
1608 border-radius: 10px;
1609 font-size: 13px;
1610 font-weight: 600;
1611 text-decoration: none;
1612 border: 1px solid transparent;
1613 cursor: pointer;
1614 font: inherit;
1615 line-height: 1;
1616 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1617 }
1618 .adm-flags-btn-primary {
6fd5915Claude1619 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude1620 color: #fff;
6fd5915Claude1621 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude1622 }
6fd5915Claude1623 .adm-flags-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
f1dc7c7Claude1624
1625 @media (max-width: 720px) {
1626 .adm-flags-wrap { padding: var(--space-4) var(--space-3); }
1627 .adm-flags-hero { padding: var(--space-4); }
1628 }
8929744Claude1629`;
1630
1631const admDigestsStyles = `
eed4684Claude1632 .adm-digests-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1633
1634 .adm-digests-hero {
1635 position: relative;
1636 margin-bottom: var(--space-5);
1637 padding: var(--space-5) var(--space-6);
1638 background: var(--bg-elevated);
1639 border: 1px solid var(--border);
1640 border-radius: 16px;
1641 overflow: hidden;
1642 }
1643 .adm-digests-hero::before {
1644 content: '';
1645 position: absolute;
1646 top: 0; left: 0; right: 0;
1647 height: 2px;
6fd5915Claude1648 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1649 opacity: 0.7;
1650 pointer-events: none;
1651 }
1652 .adm-digests-hero-inner {
1653 position: relative;
1654 z-index: 1;
1655 display: flex;
1656 align-items: flex-end;
1657 justify-content: space-between;
1658 gap: var(--space-4);
1659 flex-wrap: wrap;
1660 }
1661 .adm-digests-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1662 .adm-digests-eyebrow {
1663 font-size: 12px;
1664 color: var(--text-muted);
1665 margin-bottom: var(--space-2);
1666 letter-spacing: 0.02em;
1667 display: inline-flex;
1668 align-items: center;
1669 gap: 8px;
1670 }
1671 .adm-digests-eyebrow-pill {
1672 display: inline-flex;
1673 align-items: center;
1674 justify-content: center;
1675 width: 22px; height: 22px;
1676 border-radius: 6px;
6fd5915Claude1677 background: rgba(91,110,232,0.14);
1678 color: #5b6ee8;
1679 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1680 }
1681 .adm-digests-title {
1682 font-size: clamp(28px, 4vw, 40px);
1683 font-family: var(--font-display);
1684 font-weight: 800;
1685 letter-spacing: -0.028em;
1686 line-height: 1.05;
1687 margin: 0 0 var(--space-2);
1688 color: var(--text-strong);
1689 }
1690 .adm-digests-title-grad {
8cfb00eClaude1691 color: var(--text-strong);
1692 background: none;
1693 -webkit-background-clip: unset;
1694 background-clip: unset;
8929744Claude1695 }
1696 .adm-digests-sub {
1697 font-size: 15px;
1698 color: var(--text-muted);
1699 margin: 0;
1700 line-height: 1.5;
1701 max-width: 620px;
1702 }
1703 .adm-digests-back {
1704 display: inline-flex;
1705 align-items: center;
1706 gap: 6px;
1707 padding: 7px 12px;
1708 font-size: 12.5px;
1709 color: var(--text-muted);
1710 background: rgba(255,255,255,0.02);
1711 border: 1px solid var(--border);
1712 border-radius: 8px;
1713 text-decoration: none;
1714 font-weight: 500;
1715 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1716 }
1717 .adm-digests-back:hover {
1718 border-color: var(--border-strong);
1719 color: var(--text-strong);
1720 background: rgba(255,255,255,0.04);
1721 }
1722
1723 .adm-digests-banner {
1724 margin-bottom: var(--space-4);
1725 padding: 10px 14px;
1726 border-radius: 10px;
1727 font-size: 13.5px;
1728 border: 1px solid var(--border);
1729 background: rgba(255,255,255,0.025);
1730 color: var(--text);
1731 }
1732 .adm-digests-banner.is-ok {
1733 border-color: rgba(52,211,153,0.40);
1734 background: rgba(52,211,153,0.08);
1735 color: #bbf7d0;
1736 }
1737 .adm-digests-banner.is-error {
1738 border-color: rgba(248,113,113,0.40);
1739 background: rgba(248,113,113,0.08);
1740 color: #fecaca;
1741 }
1742
1743 .adm-digests-pills {
1744 display: inline-flex;
1745 gap: 6px;
1746 flex-wrap: wrap;
1747 margin-bottom: var(--space-4);
1748 }
1749 .adm-digests-pill {
1750 display: inline-flex;
1751 align-items: center;
1752 gap: 6px;
1753 padding: 4px 10px;
1754 border-radius: 9999px;
1755 font-size: 11.5px;
1756 font-weight: 600;
1757 background: rgba(255,255,255,0.04);
1758 color: var(--text-muted);
1759 box-shadow: inset 0 0 0 1px var(--border);
1760 }
1761 .adm-digests-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1762 .adm-digests-pill.is-on {
1763 background: rgba(52,211,153,0.14);
1764 color: #6ee7b7;
1765 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1766 }
1767
1768 .adm-digests-section {
1769 margin-bottom: var(--space-5);
1770 background: var(--bg-elevated);
1771 border: 1px solid var(--border);
1772 border-radius: 14px;
1773 overflow: hidden;
1774 }
1775 .adm-digests-section-head {
1776 padding: var(--space-4) var(--space-5);
1777 border-bottom: 1px solid var(--border);
1778 display: flex;
1779 align-items: center;
1780 gap: 10px;
1781 flex-wrap: wrap;
1782 }
1783 .adm-digests-section-icon {
1784 display: inline-flex;
1785 align-items: center;
1786 justify-content: center;
1787 width: 26px; height: 26px;
1788 border-radius: 8px;
6fd5915Claude1789 background: rgba(91,110,232,0.12);
1790 color: #5b6ee8;
1791 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1792 flex-shrink: 0;
1793 }
1794 .adm-digests-section-title {
1795 margin: 0;
1796 font-family: var(--font-display);
1797 font-size: 17px;
1798 font-weight: 700;
1799 letter-spacing: -0.018em;
1800 color: var(--text-strong);
1801 }
1802 .adm-digests-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
1803 .adm-digests-section-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1804
1805 .adm-digests-input {
1806 width: 100%;
1807 padding: 9px 12px;
1808 font-size: 13.5px;
1809 color: var(--text);
1810 background: var(--bg);
1811 border: 1px solid var(--border-strong);
1812 border-radius: 8px;
1813 outline: none;
1814 font-family: var(--font-mono);
1815 transition: border-color 120ms ease, box-shadow 120ms ease;
1816 box-sizing: border-box;
1817 max-width: 280px;
1818 }
1819 .adm-digests-input:focus {
1820 border-color: var(--border-focus);
6fd5915Claude1821 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude1822 }
1823 .adm-digests-form-row {
1824 display: flex;
1825 gap: 8px;
1826 align-items: center;
1827 flex-wrap: wrap;
1828 }
1829
1830 .adm-digests-btn {
1831 display: inline-flex;
1832 align-items: center;
1833 gap: 6px;
1834 padding: 9px 16px;
1835 border-radius: 10px;
1836 font-size: 13px;
1837 font-weight: 600;
1838 text-decoration: none;
1839 border: 1px solid var(--border-strong);
1840 background: rgba(255,255,255,0.02);
1841 color: var(--text);
1842 cursor: pointer;
1843 font: inherit;
1844 line-height: 1;
1845 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1846 }
6fd5915Claude1847 .adm-digests-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude1848 .adm-digests-btn-primary {
6fd5915Claude1849 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude1850 color: #fff;
1851 border-color: transparent;
6fd5915Claude1852 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude1853 }
6fd5915Claude1854 .adm-digests-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude1855
1856 .adm-digests-section-divider {
1857 border-top: 1px solid var(--border-subtle);
1858 padding-top: var(--space-3);
1859 margin-top: var(--space-2);
1860 }
1861 .adm-digests-divider-hint {
1862 font-size: 12.5px;
1863 color: var(--text-muted);
1864 margin-bottom: 8px;
1865 }
1866
1867 .adm-digests-h3 {
1868 display: flex;
1869 align-items: baseline;
1870 justify-content: space-between;
1871 gap: var(--space-3);
1872 margin: var(--space-5) 0 var(--space-3);
1873 }
1874 .adm-digests-h3 h3 {
1875 font-family: var(--font-display);
1876 font-size: 16px;
1877 font-weight: 700;
1878 letter-spacing: -0.014em;
1879 margin: 0;
1880 color: var(--text-strong);
1881 }
1882 .adm-digests-h3-meta { font-size: 12px; color: var(--text-muted); }
1883
1884 .adm-digests-grid {
1885 display: grid;
1886 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1887 gap: var(--space-3);
1888 }
1889 .adm-digests-card {
1890 padding: var(--space-3) var(--space-4);
1891 background: var(--bg-elevated);
1892 border: 1px solid var(--border);
1893 border-radius: 12px;
1894 display: flex;
1895 align-items: center;
1896 gap: 12px;
1897 transition: transform 160ms ease, border-color 160ms ease;
1898 }
1899 .adm-digests-card:hover { transform: translateY(-1px); border-color: var(--border-strong); }
1900 .adm-digests-avatar {
1901 width: 36px; height: 36px;
1902 border-radius: 9999px;
6fd5915Claude1903 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
8929744Claude1904 color: var(--text-strong);
1905 display: inline-flex;
1906 align-items: center;
1907 justify-content: center;
1908 font-size: 13px;
1909 font-weight: 700;
6fd5915Claude1910 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
8929744Claude1911 flex-shrink: 0;
1912 text-transform: uppercase;
1913 }
1914 .adm-digests-card-text { min-width: 0; flex: 1; }
1915 .adm-digests-card-name {
1916 font-size: 13.5px;
1917 font-weight: 600;
1918 color: var(--text-strong);
1919 text-decoration: none;
1920 }
1921 .adm-digests-card-name:hover { color: var(--accent-hover, var(--accent)); }
1922 .adm-digests-card-sent {
1923 margin-top: 2px;
1924 font-size: 11.5px;
1925 color: var(--text-muted);
1926 font-family: var(--font-mono);
1927 }
1928
1929 .adm-digests-empty {
1930 position: relative;
1931 padding: var(--space-12) var(--space-6);
1932 border: 1px dashed var(--border);
1933 border-radius: 16px;
1934 background: var(--bg-elevated);
1935 text-align: center;
1936 overflow: hidden;
1937 }
1938 .adm-digests-empty-orb {
1939 position: absolute;
1940 inset: 50% auto auto 50%;
1941 transform: translate(-50%, -50%);
1942 width: 320px; height: 320px;
6fd5915Claude1943 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude1944 filter: blur(60px);
1945 pointer-events: none;
1946 z-index: 0;
1947 }
1948 .adm-digests-empty-inner { position: relative; z-index: 1; }
1949 .adm-digests-empty-icon {
1950 display: inline-flex;
1951 align-items: center;
1952 justify-content: center;
1953 width: 56px; height: 56px;
1954 border-radius: 16px;
6fd5915Claude1955 background: rgba(91,110,232,0.10);
1956 color: #5b6ee8;
1957 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1958 margin-bottom: var(--space-3);
1959 }
1960 .adm-digests-empty-icon svg { width: 24px; height: 24px; }
1961 .adm-digests-empty-title {
1962 font-family: var(--font-display);
1963 font-size: 20px;
1964 font-weight: 700;
1965 letter-spacing: -0.015em;
1966 color: var(--text-strong);
1967 margin-bottom: 6px;
1968 }
1969 .adm-digests-empty-sub {
1970 color: var(--text-muted);
1971 font-size: 13.5px;
1972 line-height: 1.5;
1973 }
f1dc7c7Claude1974
1975 @media (max-width: 720px) {
1976 .adm-digests-wrap { padding: var(--space-4) var(--space-3); }
1977 .adm-digests-hero { padding: var(--space-4); }
1978 .adm-digests-grid { grid-template-columns: 1fr; }
1979 }
8929744Claude1980`;
1981
1982const admAutopilotStyles = `
eed4684Claude1983 .adm-autopilot-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1984
1985 .adm-autopilot-hero {
1986 position: relative;
1987 margin-bottom: var(--space-5);
1988 padding: var(--space-5) var(--space-6);
1989 background: var(--bg-elevated);
1990 border: 1px solid var(--border);
1991 border-radius: 16px;
1992 overflow: hidden;
1993 }
1994 .adm-autopilot-hero::before {
1995 content: '';
1996 position: absolute;
1997 top: 0; left: 0; right: 0;
1998 height: 2px;
6fd5915Claude1999 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude2000 opacity: 0.7;
2001 pointer-events: none;
2002 }
2003 .adm-autopilot-hero-inner {
2004 position: relative;
2005 z-index: 1;
2006 display: flex;
2007 align-items: flex-end;
2008 justify-content: space-between;
2009 gap: var(--space-4);
2010 flex-wrap: wrap;
2011 }
2012 .adm-autopilot-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2013 .adm-autopilot-eyebrow {
2014 font-size: 12px;
2015 color: var(--text-muted);
2016 margin-bottom: var(--space-2);
2017 letter-spacing: 0.02em;
2018 display: inline-flex;
2019 align-items: center;
2020 gap: 8px;
2021 }
2022 .adm-autopilot-eyebrow-pill {
2023 display: inline-flex;
2024 align-items: center;
2025 justify-content: center;
2026 width: 22px; height: 22px;
2027 border-radius: 6px;
6fd5915Claude2028 background: rgba(91,110,232,0.14);
2029 color: #5b6ee8;
2030 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude2031 }
2032 .adm-autopilot-title {
2033 font-size: clamp(28px, 4vw, 40px);
2034 font-family: var(--font-display);
2035 font-weight: 800;
2036 letter-spacing: -0.028em;
2037 line-height: 1.05;
2038 margin: 0 0 var(--space-2);
2039 color: var(--text-strong);
2040 }
2041 .adm-autopilot-title-grad {
8cfb00eClaude2042 color: var(--text-strong);
2043 background: none;
2044 -webkit-background-clip: unset;
2045 background-clip: unset;
8929744Claude2046 }
2047 .adm-autopilot-sub {
2048 font-size: 15px;
2049 color: var(--text-muted);
2050 margin: 0;
2051 line-height: 1.5;
2052 max-width: 620px;
2053 }
2054 .adm-autopilot-back {
2055 display: inline-flex;
2056 align-items: center;
2057 gap: 6px;
2058 padding: 7px 12px;
2059 font-size: 12.5px;
2060 color: var(--text-muted);
2061 background: rgba(255,255,255,0.02);
2062 border: 1px solid var(--border);
2063 border-radius: 8px;
2064 text-decoration: none;
2065 font-weight: 500;
2066 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2067 }
2068 .adm-autopilot-back:hover {
2069 border-color: var(--border-strong);
2070 color: var(--text-strong);
2071 background: rgba(255,255,255,0.04);
2072 }
2073
2074 .adm-autopilot-banner {
2075 margin-bottom: var(--space-4);
2076 padding: 10px 14px;
2077 border-radius: 10px;
2078 font-size: 13.5px;
2079 border: 1px solid var(--border);
2080 background: rgba(255,255,255,0.025);
2081 color: var(--text);
2082 }
2083 .adm-autopilot-banner.is-ok {
2084 border-color: rgba(52,211,153,0.40);
2085 background: rgba(52,211,153,0.08);
2086 color: #bbf7d0;
2087 }
2088 .adm-autopilot-banner.is-error {
2089 border-color: rgba(248,113,113,0.40);
2090 background: rgba(248,113,113,0.08);
2091 color: #fecaca;
2092 }
2093
2094 .adm-autopilot-statgrid {
2095 display: grid;
2096 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2097 gap: var(--space-3);
2098 margin-bottom: var(--space-5);
2099 }
2100 .adm-autopilot-stat {
2101 position: relative;
2102 padding: var(--space-4);
2103 background: var(--bg-elevated);
2104 border: 1px solid var(--border);
2105 border-radius: 14px;
2106 overflow: hidden;
2107 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
2108 }
2109 .adm-autopilot-stat:hover {
2110 transform: translateY(-2px);
2111 border-color: var(--border-strong);
2112 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
2113 }
2114 .adm-autopilot-stat-head {
2115 display: flex;
2116 align-items: center;
2117 justify-content: space-between;
2118 margin-bottom: var(--space-2);
2119 }
2120 .adm-autopilot-stat-label {
2121 font-size: 11px;
2122 font-weight: 600;
2123 letter-spacing: 0.08em;
2124 text-transform: uppercase;
2125 color: var(--text-muted);
2126 }
2127 .adm-autopilot-stat-icon {
2128 display: inline-flex;
2129 align-items: center;
2130 justify-content: center;
2131 width: 26px; height: 26px;
2132 border-radius: 8px;
6fd5915Claude2133 background: rgba(91,110,232,0.12);
2134 color: #5b6ee8;
2135 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude2136 }
2137 .adm-autopilot-stat-value {
2138 font-family: var(--font-display);
2139 font-size: 32px;
2140 font-weight: 800;
2141 letter-spacing: -0.028em;
2142 line-height: 1;
2143 color: var(--text-strong);
2144 }
2145 .adm-autopilot-stat-value.is-mono {
2146 font-family: var(--font-mono);
2147 font-size: 13px;
2148 line-height: 1.3;
2149 word-break: break-all;
2150 }
2151 .adm-autopilot-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
2152 .adm-autopilot-pill {
2153 display: inline-flex;
2154 align-items: center;
2155 gap: 6px;
2156 padding: 2px 8px;
2157 border-radius: 9999px;
2158 font-size: 10.5px;
2159 font-weight: 600;
2160 letter-spacing: 0.04em;
2161 text-transform: uppercase;
2162 }
2163 .adm-autopilot-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
2164 .adm-autopilot-pill.is-on {
2165 background: rgba(52,211,153,0.14);
2166 color: #6ee7b7;
2167 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2168 }
2169 .adm-autopilot-pill.is-off {
2170 background: rgba(255,255,255,0.04);
2171 color: var(--text-muted);
2172 box-shadow: inset 0 0 0 1px var(--border);
2173 }
2174
2175 .adm-autopilot-actions {
2176 display: flex;
2177 align-items: center;
2178 gap: 12px;
2179 flex-wrap: wrap;
2180 margin-bottom: var(--space-5);
2181 }
2182 .adm-autopilot-actions form { margin: 0; }
2183 .adm-autopilot-action-hint {
2184 color: var(--text-muted);
2185 font-size: 13px;
2186 }
2187
2188 .adm-autopilot-btn {
2189 display: inline-flex;
2190 align-items: center;
2191 gap: 6px;
2192 padding: 9px 16px;
2193 border-radius: 10px;
2194 font-size: 13px;
2195 font-weight: 600;
2196 text-decoration: none;
2197 border: 1px solid transparent;
2198 cursor: pointer;
2199 font: inherit;
2200 line-height: 1;
2201 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
2202 }
2203 .adm-autopilot-btn-primary {
6fd5915Claude2204 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude2205 color: #fff;
6fd5915Claude2206 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude2207 }
6fd5915Claude2208 .adm-autopilot-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude2209
2210 .adm-autopilot-h3 {
2211 display: flex;
2212 align-items: baseline;
2213 justify-content: space-between;
2214 gap: var(--space-3);
2215 margin: 0 0 var(--space-3);
2216 }
2217 .adm-autopilot-h3 h3 {
2218 font-family: var(--font-display);
2219 font-size: 16px;
2220 font-weight: 700;
2221 letter-spacing: -0.014em;
2222 margin: 0;
2223 color: var(--text-strong);
2224 }
2225 .adm-autopilot-h3-meta { font-size: 12px; color: var(--text-muted); }
2226
2227 .adm-autopilot-tasks {
2228 display: grid;
2229 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2230 gap: var(--space-3);
2231 }
2232 .adm-autopilot-task {
2233 padding: var(--space-3) var(--space-4);
2234 background: var(--bg-elevated);
2235 border: 1px solid var(--border);
2236 border-radius: 12px;
2237 display: flex;
2238 flex-direction: column;
2239 gap: 8px;
2240 transition: transform 160ms ease, border-color 160ms ease;
2241 }
2242 .adm-autopilot-task:hover { transform: translateY(-1px); border-color: var(--border-strong); }
2243 .adm-autopilot-task.is-ok { border-color: rgba(52,211,153,0.28); }
2244 .adm-autopilot-task.is-fail { border-color: rgba(248,113,113,0.32); }
2245 .adm-autopilot-task-head {
2246 display: flex;
2247 align-items: center;
2248 gap: 10px;
2249 }
2250 .adm-autopilot-task-light {
2251 flex-shrink: 0;
2252 width: 10px; height: 10px;
2253 border-radius: 9999px;
2254 background: #6b7280;
2255 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
2256 }
2257 .adm-autopilot-task-light.is-ok {
2258 background: #34d399;
2259 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
2260 }
2261 .adm-autopilot-task-light.is-fail {
2262 background: #f87171;
2263 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
2264 animation: admApPulse 1.8s ease-in-out infinite;
2265 }
2266 @keyframes admApPulse {
2267 0%, 100% { opacity: 1; transform: scale(1); }
2268 50% { opacity: 0.7; transform: scale(0.92); }
2269 }
2270 @media (prefers-reduced-motion: reduce) {
2271 .adm-autopilot-task-light.is-fail { animation: none; }
2272 }
2273 .adm-autopilot-task-name {
2274 font-family: var(--font-mono);
2275 font-size: 12.5px;
2276 font-weight: 600;
2277 color: var(--text-strong);
2278 word-break: break-word;
2279 flex: 1;
2280 min-width: 0;
2281 }
2282 .adm-autopilot-task-status {
2283 display: inline-flex;
2284 align-items: center;
2285 gap: 4px;
2286 padding: 2px 8px;
2287 border-radius: 9999px;
2288 font-size: 10.5px;
2289 font-weight: 600;
2290 letter-spacing: 0.04em;
2291 text-transform: uppercase;
2292 flex-shrink: 0;
2293 }
2294 .adm-autopilot-task-status.is-ok {
2295 background: rgba(52,211,153,0.14);
2296 color: #6ee7b7;
2297 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2298 }
2299 .adm-autopilot-task-status.is-fail {
2300 background: rgba(248,113,113,0.12);
2301 color: #fecaca;
2302 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
2303 }
2304 .adm-autopilot-task-meta {
2305 display: flex;
2306 align-items: center;
2307 justify-content: space-between;
2308 gap: 8px;
2309 font-size: 11.5px;
2310 color: var(--text-muted);
2311 font-family: var(--font-mono);
2312 }
2313 .adm-autopilot-task-err {
2314 font-size: 11.5px;
2315 color: #fecaca;
2316 line-height: 1.5;
2317 background: rgba(248,113,113,0.06);
2318 border: 1px solid rgba(248,113,113,0.20);
2319 padding: 6px 8px;
2320 border-radius: 6px;
2321 word-break: break-word;
2322 }
2323
2324 .adm-autopilot-empty {
2325 position: relative;
2326 padding: var(--space-12) var(--space-6);
2327 border: 1px dashed var(--border);
2328 border-radius: 16px;
2329 background: var(--bg-elevated);
2330 text-align: center;
2331 overflow: hidden;
2332 }
2333 .adm-autopilot-empty-orb {
2334 position: absolute;
2335 inset: 50% auto auto 50%;
2336 transform: translate(-50%, -50%);
2337 width: 320px; height: 320px;
6fd5915Claude2338 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude2339 filter: blur(60px);
2340 pointer-events: none;
2341 z-index: 0;
2342 }
2343 .adm-autopilot-empty-inner { position: relative; z-index: 1; }
2344 .adm-autopilot-empty-icon {
2345 display: inline-flex;
2346 align-items: center;
2347 justify-content: center;
2348 width: 56px; height: 56px;
2349 border-radius: 16px;
6fd5915Claude2350 background: rgba(91,110,232,0.10);
2351 color: #5b6ee8;
2352 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude2353 margin-bottom: var(--space-3);
2354 }
2355 .adm-autopilot-empty-icon svg { width: 24px; height: 24px; }
2356 .adm-autopilot-empty-title {
2357 font-family: var(--font-display);
2358 font-size: 20px;
2359 font-weight: 700;
2360 letter-spacing: -0.015em;
2361 color: var(--text-strong);
2362 margin-bottom: 6px;
2363 }
2364 .adm-autopilot-empty-sub {
2365 color: var(--text-muted);
2366 font-size: 13.5px;
2367 line-height: 1.5;
2368 }
2369
2370 .adm-autopilot-foot {
2371 margin-top: var(--space-5);
2372 padding: var(--space-3) var(--space-4);
2373 border: 1px solid var(--border-subtle);
2374 background: rgba(255,255,255,0.015);
2375 border-radius: 10px;
2376 color: var(--text-muted);
2377 font-size: 12.5px;
2378 }
2379 .adm-autopilot-foot code {
2380 font-family: var(--font-mono);
2381 font-size: 12px;
2382 background: var(--bg-tertiary);
2383 padding: 1px 5px;
2384 border-radius: 4px;
2385 color: var(--text);
2386 }
f1dc7c7Claude2387
2388 @media (max-width: 720px) {
2389 .adm-autopilot-wrap { padding: var(--space-4) var(--space-3); }
2390 .adm-autopilot-hero { padding: var(--space-4); }
2391 .adm-autopilot-statgrid { grid-template-columns: 1fr 1fr; }
2392 .adm-autopilot-tasks { grid-template-columns: 1fr; }
2393 }
8929744Claude2394`;
2395
b218e63Claude2396const admAnalyticsStyles = `
2397 .adm-analytics-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
2398
2399 .adm-analytics-hero {
2400 position: relative;
2401 margin-bottom: var(--space-5);
2402 padding: var(--space-5) var(--space-6);
2403 background: var(--bg-elevated);
2404 border: 1px solid var(--border);
2405 border-radius: 16px;
2406 overflow: hidden;
2407 }
2408 .adm-analytics-hero::before {
2409 content: '';
2410 position: absolute;
2411 top: 0; left: 0; right: 0;
2412 height: 2px;
6fd5915Claude2413 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b218e63Claude2414 opacity: 0.7;
2415 pointer-events: none;
2416 }
2417 .adm-analytics-hero-inner {
2418 position: relative; z-index: 1;
2419 display: flex; align-items: flex-end; justify-content: space-between;
2420 gap: var(--space-4); flex-wrap: wrap;
2421 }
2422 .adm-analytics-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2423 .adm-analytics-eyebrow {
2424 font-size: 12px; color: var(--text-muted); margin-bottom: var(--space-2);
2425 letter-spacing: 0.02em; display: inline-flex; align-items: center; gap: 8px;
2426 }
2427 .adm-analytics-eyebrow-pill {
2428 display: inline-flex; align-items: center; justify-content: center;
2429 width: 22px; height: 22px; border-radius: 6px;
6fd5915Claude2430 background: rgba(91,110,232,0.14); color: #5b6ee8;
2431 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
b218e63Claude2432 }
2433 .adm-analytics-title {
2434 font-size: clamp(28px, 4vw, 36px); font-family: var(--font-display);
2435 font-weight: 800; letter-spacing: -0.028em; line-height: 1.05;
2436 margin: 0 0 var(--space-2); color: var(--text-strong);
2437 }
2438 .adm-analytics-title-grad {
8cfb00eClaude2439 color: var(--text-strong);
2440 background: none;
2441 -webkit-background-clip: unset;
2442 background-clip: unset;
b218e63Claude2443 }
2444 .adm-analytics-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
2445 .adm-analytics-back {
2446 display: inline-flex; align-items: center; gap: 6px;
2447 padding: 7px 12px; font-size: 12.5px; color: var(--text-muted);
2448 background: rgba(255,255,255,0.02); border: 1px solid var(--border);
2449 border-radius: 8px; text-decoration: none; font-weight: 500;
2450 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2451 }
2452 .adm-analytics-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
2453
2454 .adm-analytics-statgrid {
2455 display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2456 gap: var(--space-3); margin-bottom: var(--space-5);
2457 }
2458 .adm-analytics-stat {
2459 padding: var(--space-4); background: var(--bg-elevated);
2460 border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
2461 }
2462 .adm-analytics-stat-label {
2463 font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
2464 text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
2465 }
2466 .adm-analytics-stat-value {
2467 font-family: var(--font-display); font-size: 28px; font-weight: 800;
2468 letter-spacing: -0.024em; line-height: 1; color: var(--text-strong);
2469 }
2470 .adm-analytics-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-faint); }
2471
2472 .adm-analytics-h3 {
2473 display: flex; align-items: baseline; justify-content: space-between;
2474 gap: var(--space-3); margin: var(--space-5) 0 var(--space-3);
2475 }
2476 .adm-analytics-h3 h3 {
2477 font-family: var(--font-display); font-size: 16px; font-weight: 700;
2478 letter-spacing: -0.014em; margin: 0; color: var(--text-strong);
2479 }
2480 .adm-analytics-h3-meta { font-size: 12px; color: var(--text-muted); }
2481
2482 .adm-analytics-table {
2483 width: 100%; border-collapse: collapse;
2484 background: var(--bg-elevated); border: 1px solid var(--border);
2485 border-radius: 14px; overflow: hidden;
2486 }
2487 .adm-analytics-table thead th {
2488 text-align: left; font-size: 11px; font-weight: 600;
2489 letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
2490 padding: 10px 16px; background: rgba(255,255,255,0.015);
2491 border-bottom: 1px solid var(--border);
2492 }
2493 .adm-analytics-table thead th:not(:first-child) { text-align: right; }
2494 .adm-analytics-table tbody td {
2495 padding: 10px 16px; border-bottom: 1px solid var(--border-subtle);
2496 font-size: 13px; color: var(--text); vertical-align: middle;
2497 }
2498 .adm-analytics-table tbody td:not(:first-child) { text-align: right; font-family: var(--font-mono); font-size: 12px; }
2499 .adm-analytics-table tbody tr:last-child td { border-bottom: none; }
2500 .adm-analytics-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
2501 .adm-analytics-table code { font-family: var(--font-mono); font-size: 12px; color: var(--text-strong); }
2502 .adm-analytics-empty {
2503 padding: var(--space-6); text-align: center; color: var(--text-muted);
2504 font-size: 13.5px; background: var(--bg-elevated);
2505 border: 1px dashed var(--border); border-radius: 14px;
2506 }
2507
2508 /* bar chart cells */
2509 .adm-analytics-bar-cell { display: flex; align-items: center; gap: 8px; }
2510 .adm-analytics-bar-track {
2511 flex: 1; height: 6px; background: var(--bg-tertiary);
2512 border-radius: 9999px; overflow: hidden; min-width: 60px;
2513 }
2514 .adm-analytics-bar-fill {
2515 height: 100%; border-radius: 9999px;
6fd5915Claude2516 background: linear-gradient(90deg, #5b6ee8, #5f8fa0);
b218e63Claude2517 }
2518 .adm-analytics-bar-label { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
2519
2520 .adm-analytics-pill {
2521 display: inline-flex; align-items: center; gap: 4px;
2522 padding: 2px 8px; border-radius: 9999px; font-size: 10.5px;
2523 font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
6fd5915Claude2524 background: rgba(91,110,232,0.12); color: #c5b3ff;
2525 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
b218e63Claude2526 }
2527 .adm-analytics-pill.is-ok { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28); }
2528 .adm-analytics-pill.is-err { background: rgba(248,113,113,0.12); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.28); }
2529
2530 @media (max-width: 720px) {
2531 .adm-analytics-wrap { padding: var(--space-4) var(--space-3); }
2532 .adm-analytics-hero { padding: var(--space-4); }
2533 .adm-analytics-statgrid { grid-template-columns: 1fr 1fr; }
2534 .adm-analytics-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
2535 }
2536`;
2537
07f4b70Claude2538/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
2539const Icons = {
2540 shield: (
2541 <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">
2542 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2543 </svg>
2544 ),
2545 users: (
2546 <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">
2547 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
2548 <circle cx="9" cy="7" r="4" />
2549 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
2550 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
2551 </svg>
2552 ),
2553 repo: (
2554 <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">
2555 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
2556 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
2557 </svg>
2558 ),
2559 starShield: (
2560 <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">
2561 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2562 <path d="m9 12 2 2 4-4" />
2563 </svg>
2564 ),
2565 ops: (
2566 <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">
2567 <circle cx="12" cy="12" r="3" />
2568 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
2569 </svg>
2570 ),
2571 pulse: (
2572 <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">
2573 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
2574 </svg>
2575 ),
2576 flag: (
2577 <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">
2578 <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
2579 <line x1="4" y1="22" x2="4" y2="15" />
2580 </svg>
2581 ),
2582 mail: (
2583 <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">
2584 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
2585 <polyline points="22,6 12,13 2,6" />
2586 </svg>
2587 ),
2588 google: (
2589 <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">
2590 <circle cx="12" cy="12" r="10" />
2591 <path d="M12 8v8" /><path d="M8 12h8" />
2592 </svg>
2593 ),
2594 github: (
2595 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2596 <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.28-.01-1.02-.02-2-3.2.69-3.88-1.54-3.88-1.54-.52-1.33-1.28-1.68-1.28-1.68-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.74.4-1.26.73-1.55-2.55-.29-5.23-1.28-5.23-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.51-1.47.11-3.06 0 0 .97-.31 3.18 1.18a11 11 0 0 1 2.9-.39c.98 0 1.97.13 2.9.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.77.11 3.06.74.81 1.19 1.84 1.19 3.1 0 4.42-2.69 5.39-5.25 5.68.41.35.78 1.04.78 2.1 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
2597 </svg>
2598 ),
2599 sso: (
2600 <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">
2601 <rect x="3" y="11" width="18" height="11" rx="2" />
2602 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2603 </svg>
2604 ),
2605 bot: (
2606 <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">
2607 <rect x="3" y="7" width="18" height="13" rx="2" />
2608 <circle cx="9" cy="13" r="1" />
2609 <circle cx="15" cy="13" r="1" />
2610 <path d="M12 3v4" />
2611 </svg>
2612 ),
2613 refresh: (
2614 <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">
2615 <polyline points="23 4 23 10 17 10" />
2616 <polyline points="1 20 1 14 7 14" />
2617 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
2618 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
2619 </svg>
2620 ),
2621 arrowLeft: (
2622 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2623 <line x1="19" y1="12" x2="5" y2="12" />
2624 <polyline points="12 19 5 12 12 5" />
2625 </svg>
2626 ),
b218e63Claude2627 dollarSign: (
2628 <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">
2629 <line x1="12" y1="1" x2="12" y2="23" />
2630 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
2631 </svg>
2632 ),
2633 trendingUp: (
2634 <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">
2635 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
2636 <polyline points="17 6 23 6 23 12" />
2637 </svg>
2638 ),
509c376Claude2639 key: (
2640 <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">
2641 <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" />
2642 </svg>
2643 ),
8cfb00eClaude2644 gitMerge: (
2645 <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">
2646 <circle cx="18" cy="18" r="3" />
2647 <circle cx="6" cy="6" r="3" />
2648 <path d="M6 21V9a9 9 0 0 0 9 9" />
2649 </svg>
2650 ),
2651 issue: (
2652 <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">
2653 <circle cx="12" cy="12" r="10" />
2654 <line x1="12" y1="8" x2="12" y2="12" />
2655 <line x1="12" y1="16" x2="12.01" y2="16" />
2656 </svg>
2657 ),
07f4b70Claude2658};
2659
2660/** First-letter avatar helper. */
2661function initials(s: string): string {
2662 return (s || "?").trim().charAt(0).toUpperCase() || "?";
2663}
2664
8f50ed0Claude2665async function gate(c: any): Promise<{ user: any } | Response> {
2666 const user = c.get("user");
2667 if (!user) return c.redirect("/login?next=/admin");
2668 if (!(await isSiteAdmin(user.id))) {
2669 return c.html(
2670 <Layout title="Forbidden" user={user}>
07f4b70Claude2671 <div class="admin-403">
8f50ed0Claude2672 <h2>403 — Not a site admin</h2>
2673 <p>You don't have permission to view this page.</p>
2674 </div>
07f4b70Claude2675 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2676 </Layout>,
2677 403
2678 );
2679 }
2680 return { user };
2681}
2682
2683admin.get("/admin", async (c) => {
2684 const g = await gate(c);
2685 if (g instanceof Response) return g;
2686 const { user } = g;
2687
6dec21bClaude2688 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
2689
8cfb00eClaude2690 const [[uc], [rc], [prc], [ic], recent, admins] = await Promise.all([
6dec21bClaude2691 db.select({ n: sql<number>`count(*)::int` }).from(users),
2692 db.select({ n: sql<number>`count(*)::int` }).from(repositories),
8cfb00eClaude2693 db
2694 .select({ n: sql<number>`count(*)::int` })
2695 .from(pullRequests)
2696 .where(eq(pullRequests.state, "open"))
2697 .catch(() => [{ n: 0 }] as { n: number }[]),
2698 db
2699 .select({ n: sql<number>`count(*)::int` })
2700 .from(issues)
2701 .where(eq(issues.state, "open"))
2702 .catch(() => [{ n: 0 }] as { n: number }[]),
6dec21bClaude2703 db
2704 .select({
2705 id: users.id,
2706 username: users.username,
2707 createdAt: users.createdAt,
2708 })
2709 .from(users)
2710 .orderBy(desc(users.createdAt))
2711 .limit(10),
2712 listSiteAdmins(),
2713 ]);
2714
2715 // Automation activity stats (last 24h) — wrapped in try/catch so a missing
2716 // table never breaks the dashboard.
2717 let aiReviewsCount = 0;
2718 let autoMergesCount = 0;
2719 let issueTriagedCount = 0;
2720 let ciAutofixCount = 0;
2721 try {
2722 const [aiReviewsRow] = await db
2723 .select({ n: sql<number>`count(*)::int` })
2724 .from(prComments)
2725 .where(
2726 sql`${prComments.isAiReview} = true AND ${prComments.createdAt} > ${since24h}`
2727 );
2728 const [autoMergesRow] = await db
2729 .select({ n: sql<number>`count(*)::int` })
2730 .from(auditLog)
2731 .where(
2732 sql`${auditLog.action} = 'auto_merge.merged' AND ${auditLog.createdAt} > ${since24h}`
2733 );
2734 const [issueTriagedRow] = await db
2735 .select({ n: sql<number>`count(*)::int` })
2736 .from(issueComments)
2737 .where(
2738 sql`${issueComments.body} LIKE '%gluecron:issue-triage%' AND ${issueComments.createdAt} > ${since24h}`
2739 );
2740 const [ciAutofixRow] = await db
2741 .select({ n: sql<number>`count(*)::int` })
2742 .from(auditLog)
2743 .where(
2744 sql`${auditLog.action} LIKE 'ci_autofix%' AND ${auditLog.createdAt} > ${since24h}`
2745 );
2746 aiReviewsCount = Number(aiReviewsRow?.n || 0);
2747 autoMergesCount = Number(autoMergesRow?.n || 0);
2748 issueTriagedCount = Number(issueTriagedRow?.n || 0);
2749 ciAutofixCount = Number(ciAutofixRow?.n || 0);
2750 } catch (_) {
2751 // Stats unavailable — defaults (0) already set above
2752 }
8f50ed0Claude2753
988380aClaude2754 const msg = c.req.query("result") || c.req.query("error");
2755 const isErr = !!c.req.query("error");
2756
07f4b70Claude2757 const userCount = Number(uc?.n || 0);
2758 const repoCount = Number(rc?.n || 0);
8cfb00eClaude2759 const openPrCount = Number((prc as { n: number }[])[0]?.n || 0);
2760 const openIssueCount = Number((ic as { n: number }[])[0]?.n || 0);
07f4b70Claude2761 const adminCount = admins.length;
2762
8f50ed0Claude2763 return c.html(
2764 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude2765 <div class="admin-wrap">
2766 <section class="admin-hero">
8cfb00eClaude2767 <div class="admin-hero-bg" aria-hidden="true" />
07f4b70Claude2768 <div class="admin-hero-inner">
2769 <div class="admin-hero-eyebrow">
2770 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
2771 Site administration ·{" "}
2772 <span class="admin-who">{user.username}</span>
2773 </div>
2774 <h2 class="admin-hero-title">
2775 <span class="admin-hero-title-grad">Site admin</span>.
2776 </h2>
2777 <p class="admin-hero-sub">
2778 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
2779 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
2780 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
2781 Operations, flags, digests, and autopilot — all in one place.
2782 </p>
2783 </div>
2784 </section>
8f50ed0Claude2785
07f4b70Claude2786 {msg && (
2787 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
2788 {decodeURIComponent(msg)}
2789 </div>
2790 )}
988380aClaude2791
07f4b70Claude2792 <div class="admin-stat-grid">
2793 <div class="admin-stat">
2794 <div class="admin-stat-head">
2795 <span class="admin-stat-label">Users</span>
2796 <span class="admin-stat-icon">{Icons.users}</span>
2797 </div>
2798 <div class="admin-stat-value">{userCount}</div>
2799 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude2800 </div>
07f4b70Claude2801 <div class="admin-stat">
2802 <div class="admin-stat-head">
2803 <span class="admin-stat-label">Repos</span>
2804 <span class="admin-stat-icon">{Icons.repo}</span>
2805 </div>
2806 <div class="admin-stat-value">{repoCount}</div>
2807 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude2808 </div>
07f4b70Claude2809 <div class="admin-stat">
2810 <div class="admin-stat-head">
2811 <span class="admin-stat-label">Admins</span>
2812 <span class="admin-stat-icon">{Icons.starShield}</span>
2813 </div>
2814 <div class="admin-stat-value">{adminCount}</div>
2815 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude2816 </div>
8cfb00eClaude2817 <div class="admin-stat">
2818 <div class="admin-stat-head">
2819 <span class="admin-stat-label">Open PRs</span>
2820 <span class="admin-stat-icon">{Icons.gitMerge}</span>
2821 </div>
2822 <div class="admin-stat-value">{openPrCount}</div>
2823 <div class="admin-stat-hint">Platform-wide</div>
2824 </div>
2825 <div class="admin-stat">
2826 <div class="admin-stat-head">
2827 <span class="admin-stat-label">Open Issues</span>
2828 <span class="admin-stat-icon">{Icons.issue}</span>
2829 </div>
2830 <div class="admin-stat-value">{openIssueCount}</div>
2831 <div class="admin-stat-hint">Platform-wide</div>
2832 </div>
8f50ed0Claude2833 </div>
2834
07f4b70Claude2835 <div class="admin-actions">
f9814d2Claude2836 <a href="/admin/command" class="admin-action is-primary" style="grid-column:1/-1;background:var(--accent-gradient);color:#fff;border-color:transparent" title="Unified live ops view — gates, deploys, AI, auth anomalies, audit events">
2837 <span class="admin-action-icon">⚡</span>
2838 Command Center — live ops view
2839 </a>
07f4b70Claude2840 <a href="/admin/ops" class="admin-action is-primary">
2841 <span class="admin-action-icon">{Icons.ops}</span>
2842 Operations
2843 </a>
509c376Claude2844 <a href="/admin/integrations" class="admin-action is-primary">
2845 <span class="admin-action-icon">{Icons.key}</span>
2846 Integrations
2847 </a>
cf793f9Claude2848 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2849 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2850 Health (traffic lights)
2851 </a>
2852 <a href="/admin/deploys" class="admin-action is-primary">
2853 <span class="admin-action-icon">{Icons.ops}</span>
2854 Deploys
2855 </a>
8cfb00eClaude2856 <a href="/admin/env-health" class="admin-action">
cf793f9Claude2857 <span class="admin-action-icon">{Icons.pulse}</span>
8cfb00eClaude2858 Env health
cf793f9Claude2859 </a>
2860 <a href="/admin/self-host" class="admin-action">
2861 <span class="admin-action-icon">{Icons.ops}</span>
2862 Self-host status
2863 </a>
2864 <a href="/admin/status" class="admin-action">
2865 <span class="admin-action-icon">{Icons.pulse}</span>
8cfb00eClaude2866 Platform monitor
07f4b70Claude2867 </a>
2868 <a href="/admin/users" class="admin-action">
2869 <span class="admin-action-icon">{Icons.users}</span>
2870 Manage users
2871 </a>
2872 <a href="/admin/repos" class="admin-action">
2873 <span class="admin-action-icon">{Icons.repo}</span>
2874 Manage repos
2875 </a>
2876 <a href="/admin/flags" class="admin-action">
2877 <span class="admin-action-icon">{Icons.flag}</span>
2878 Site flags
2879 </a>
2880 <a href="/admin/digests" class="admin-action">
2881 <span class="admin-action-icon">{Icons.mail}</span>
2882 Email digests
2883 </a>
2884 <a href="/admin/google-oauth" class="admin-action">
2885 <span class="admin-action-icon">{Icons.google}</span>
2886 Sign in with Google
2887 </a>
2888 <a href="/admin/github-oauth" class="admin-action">
2889 <span class="admin-action-icon">{Icons.github}</span>
2890 Sign in with GitHub
2891 </a>
2892 <a href="/admin/sso" class="admin-action">
2893 <span class="admin-action-icon">{Icons.sso}</span>
2894 Enterprise SSO
2895 </a>
b218e63Claude2896 <a href="/admin/ai-costs" class="admin-action is-primary">
2897 <span class="admin-action-icon">{Icons.dollarSign}</span>
2898 AI cost breakdown
2899 </a>
2900 <a href="/admin/growth" class="admin-action is-primary">
2901 <span class="admin-action-icon">{Icons.trendingUp}</span>
2902 User growth
2903 </a>
c6018a5Claude2904 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
07f4b70Claude2905 <span class="admin-action-icon">{Icons.bot}</span>
2906 Autopilot
2907 </a>
c6018a5Claude2908 <a href="/admin/diagnose" class="admin-action" title="Live status of the AI CI healer, patch generator, and proactive monitor">
2909 <span class="admin-action-icon">{Icons.bot}</span>
2910 AI background tasks
2911 </a>
662ce86Claude2912 <a href="/connect/claude" class="admin-action is-primary">
2913 <span class="admin-action-icon">{Icons.bot}</span>
2914 Connect Claude
2915 </a>
7293c67Claude2916 <a href="/admin/deletions" class="admin-action">
2917 <span class="admin-action-icon">{Icons.flag}</span>
2918 GDPR Deletions
2919 </a>
2920 <a href="/admin/stripe" class="admin-action">
2921 <span class="admin-action-icon">{Icons.key}</span>
2922 Stripe Sync
2923 </a>
07f4b70Claude2924 <form
2925 method="post"
2926 action="/admin/demo/reseed"
2927 class="admin-action-form"
2928 >
2929 <button
2930 class="admin-action"
2931 type="submit"
2932 title="Idempotently (re)create demo user + 3 sample repos"
2933 >
2934 <span class="admin-action-icon">{Icons.refresh}</span>
2935 Reseed demo
2936 </button>
2937 </form>
2938 </div>
8f50ed0Claude2939
6dec21bClaude2940 <div class="admin-h3">
2941 <h3>System Health</h3>
2942 <span class="admin-h3-meta">Ops &amp; security pages</span>
2943 </div>
2944 <div class="admin-actions" style="margin-bottom:var(--space-5)">
2945 <a href="/admin/env-health" class="admin-action is-primary" title="Check which features are enabled via env vars">
2946 <span class="admin-action-icon">{Icons.pulse}</span>
2947 Env health
2948 </a>
2949 <a href="/admin/advancement" class="admin-action" title="Platform advancement tracker">
2950 <span class="admin-action-icon">{Icons.trendingUp}</span>
2951 Advancement
2952 </a>
2953 <a href="/admin/security" class="admin-action" title="Security overview">
2954 <span class="admin-action-icon">{Icons.starShield}</span>
2955 Security
2956 </a>
2957 </div>
2958
2959 <div class="admin-automation-tile">
2960 <div class="admin-automation-heading">
2961 <span class="admin-automation-heading-icon" aria-hidden="true">{Icons.bot}</span>
2962 <span class="admin-automation-heading-label">Automation Activity</span>
2963 <span class="admin-automation-heading-sub">last 24h</span>
2964 </div>
2965 <div class="admin-automation-grid">
2966 <div class="admin-automation-stat">
2967 <div class="admin-automation-stat-label">AI Reviews</div>
2968 <div class="admin-automation-stat-value">{aiReviewsCount}</div>
2969 <div class="admin-automation-stat-hint">PR comments posted</div>
2970 </div>
2971 <div class="admin-automation-stat">
2972 <div class="admin-automation-stat-label">Auto-merges</div>
2973 <div class="admin-automation-stat-value">{autoMergesCount}</div>
2974 <div class="admin-automation-stat-hint">PRs auto-merged</div>
2975 </div>
2976 <div class="admin-automation-stat">
2977 <div class="admin-automation-stat-label">Issues Triaged</div>
2978 <div class="admin-automation-stat-value">{issueTriagedCount}</div>
2979 <div class="admin-automation-stat-hint">AI triage comments</div>
2980 </div>
2981 <div class="admin-automation-stat">
2982 <div class="admin-automation-stat-label">CI Autofixes</div>
2983 <div class="admin-automation-stat-value">{ciAutofixCount}</div>
2984 <div class="admin-automation-stat-hint">Autofix runs</div>
2985 </div>
2986 </div>
2987 </div>
2988
07f4b70Claude2989 <div class="admin-h3">
2990 <h3>Recent signups</h3>
2991 <span class="admin-h3-meta">
2992 {recent.length} most-recent
2993 </span>
2994 </div>
2995 <div class="admin-list" style="margin-bottom:20px">
2996 {recent.length === 0 ? (
2997 <div class="admin-list-empty">No users yet.</div>
2998 ) : (
2999 recent.map((u) => (
3000 <div class="admin-list-row">
3001 <div class="admin-list-main">
3002 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
3003 <div class="admin-row-text">
3004 <a href={`/${u.username}`} class="admin-row-title">
3005 {u.username}
3006 </a>
3007 <div class="admin-row-sub">
3008 <span>Joined</span>
3009 <span>
3010 {u.createdAt
3011 ? new Date(u.createdAt as unknown as string).toLocaleString()
3012 : ""}
3013 </span>
3014 </div>
3015 </div>
3016 </div>
3017 </div>
3018 ))
3019 )}
3020 </div>
8f50ed0Claude3021
07f4b70Claude3022 <div class="admin-h3">
3023 <h3>Site admins</h3>
3024 <span class="admin-h3-meta">
3025 {adminCount} active
3026 </span>
3027 </div>
3028 <div class="admin-list">
3029 {admins.length === 0 ? (
3030 <div class="admin-list-empty">
3031 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude3032 </div>
07f4b70Claude3033 ) : (
3034 admins.map((a) => (
3035 <div class="admin-list-row">
3036 <div class="admin-list-main">
3037 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
3038 <div class="admin-row-text">
3039 <a href={`/${a.username}`} class="admin-row-title">
3040 {a.username}
3041 </a>
3042 <div class="admin-row-sub">
3043 <span class="admin-pill is-admin">
3044 <span class="dot" aria-hidden="true" /> Site admin
3045 </span>
3046 <span>
3047 Granted{" "}
3048 {a.grantedAt
3049 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
3050 : "—"}
3051 </span>
3052 </div>
3053 </div>
3054 </div>
3055 </div>
3056 ))
3057 )}
3058 </div>
8f50ed0Claude3059 </div>
07f4b70Claude3060 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude3061 </Layout>
3062 );
3063});
3064
3065// ----- Users -----
3066
3067admin.get("/admin/users", async (c) => {
3068 const g = await gate(c);
3069 if (g instanceof Response) return g;
3070 const { user } = g;
3071 const q = c.req.query("q") || "";
3072 const rows = await db
3073 .select({
3074 id: users.id,
3075 username: users.username,
3076 email: users.email,
3077 createdAt: users.createdAt,
3078 })
3079 .from(users)
3080 .where(
3081 q
3082 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
3083 : sql`1=1`
3084 )
3085 .orderBy(desc(users.createdAt))
3086 .limit(200);
3087
3088 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude3089 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude3090
3091 return c.html(
3092 <Layout title="Admin — Users" user={user}>
8929744Claude3093 <div class="adm-users-wrap">
3094 <section class="adm-users-hero">
3095 <div class="adm-users-hero-inner">
3096 <div class="adm-users-hero-text">
3097 <div class="adm-users-eyebrow">
3098 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
3099 Site admin · Users
3100 </div>
3101 <h1 class="adm-users-title">
3102 <span class="adm-users-title-grad">Users</span>.
3103 </h1>
3104 <p class="adm-users-sub">
3105 Search, audit, and grant or revoke the site-admin flag.
3106 Showing up to 200 accounts ordered by signup recency.
3107 </p>
3108 </div>
3109 <a href="/admin" class="adm-users-back">
07f4b70Claude3110 {Icons.arrowLeft} Back
3111 </a>
3112 </div>
3113 </section>
3114
8929744Claude3115 <div class="adm-users-filterbar">
3116 <form method="get" action="/admin/users" class="adm-users-search">
3117 <span class="adm-users-search-ico" aria-hidden="true">
3118 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
3119 <circle cx="11" cy="11" r="8" />
3120 <line x1="21" y1="21" x2="16.65" y2="16.65" />
3121 </svg>
3122 </span>
3123 <input
3124 type="text"
3125 name="q"
3126 value={q}
3127 placeholder="Search username or email"
3128 aria-label="Search username or email"
3129 class="adm-users-input"
3130 />
3131 <button type="submit" class="adm-users-btn">Search</button>
3132 {q && (
3133 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
3134 )}
3135 </form>
3136 <div class="adm-users-pills">
3137 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3138 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
3139 </div>
3140 </div>
07f4b70Claude3141
8929744Claude3142 {rows.length === 0 ? (
3143 <div class="adm-users-empty">
3144 <div class="adm-users-empty-orb" aria-hidden="true" />
3145 <div class="adm-users-empty-inner">
3146 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
3147 <div class="adm-users-empty-title">No users found</div>
3148 <div class="adm-users-empty-sub">
3149 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
3150 </div>
3151 </div>
3152 </div>
3153 ) : (
3154 <div class="adm-users-grid">
3155 {rows.map((u) => {
07f4b70Claude3156 const isAdmin = adminIds.has(u.id);
3157 return (
8929744Claude3158 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
3159 <div class="adm-users-card-head">
3160 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude3161 {initials(u.username)}
8f50ed0Claude3162 </span>
8929744Claude3163 <div class="adm-users-card-id">
3164 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
3165 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude3166 </div>
8929744Claude3167 {isAdmin && (
3168 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
3169 )}
3170 </div>
3171 <div class="adm-users-card-meta">
3172 <span class="adm-users-meta-item">
3173 <span class="adm-users-meta-key">Email</span>
3174 <span class="adm-users-meta-val">{u.email}</span>
3175 </span>
3176 {u.createdAt && (
3177 <span class="adm-users-meta-item">
3178 <span class="adm-users-meta-key">Joined</span>
3179 <span class="adm-users-meta-val">
3180 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
3181 </span>
3182 </span>
3183 )}
3184 </div>
3185 <div class="adm-users-card-actions">
3186 <form
3187 method="post"
3188 action={`/admin/users/${u.id}/admin`}
3189 onsubmit={
3190 isAdmin
3191 ? "return confirm('Revoke site admin?')"
3192 : "return confirm('Grant site admin?')"
3193 }
3194 >
3195 <button
3196 type="submit"
3197 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
3198 >
3199 {isAdmin ? "Revoke admin" : "Grant admin"}
3200 </button>
3201 </form>
3202 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
3203 View profile
3204 </a>
07f4b70Claude3205 </div>
8f50ed0Claude3206 </div>
07f4b70Claude3207 );
8929744Claude3208 })}
3209 </div>
3210 )}
8f50ed0Claude3211 </div>
07f4b70Claude3212 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3213 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude3214 </Layout>
3215 );
3216});
3217
3218admin.post("/admin/users/:id/admin", async (c) => {
3219 const g = await gate(c);
3220 if (g instanceof Response) return g;
3221 const { user } = g;
3222 const id = c.req.param("id");
3223 const admins = await listSiteAdmins();
3224 const isAlready = admins.some((a) => a.userId === id);
3225 if (isAlready) {
3226 await revokeSiteAdmin(id);
3227 await audit({
3228 userId: user.id,
3229 action: "site_admin.revoke",
3230 targetType: "user",
3231 targetId: id,
3232 });
3233 } else {
3234 await grantSiteAdmin(id, user.id);
3235 await audit({
3236 userId: user.id,
3237 action: "site_admin.grant",
3238 targetType: "user",
3239 targetId: id,
3240 });
3241 }
3242 return c.redirect("/admin/users");
3243});
3244
3245// ----- Repos -----
3246
3247admin.get("/admin/repos", async (c) => {
3248 const g = await gate(c);
3249 if (g instanceof Response) return g;
3250 const { user } = g;
3251 const rows = await db
3252 .select({
3253 id: repositories.id,
3254 name: repositories.name,
3255 ownerUsername: users.username,
0316dbbClaude3256 isPrivate: repositories.isPrivate,
8f50ed0Claude3257 createdAt: repositories.createdAt,
3258 starCount: repositories.starCount,
3259 })
3260 .from(repositories)
3261 .innerJoin(users, eq(repositories.ownerId, users.id))
3262 .orderBy(desc(repositories.createdAt))
3263 .limit(200);
3264
8929744Claude3265 const privateCount = rows.filter((r) => r.isPrivate).length;
3266 const publicCount = rows.length - privateCount;
3267
8f50ed0Claude3268 return c.html(
3269 <Layout title="Admin — Repos" user={user}>
8929744Claude3270 <div class="adm-repos-wrap">
3271 <section class="adm-repos-hero">
3272 <div class="adm-repos-hero-inner">
3273 <div class="adm-repos-hero-text">
3274 <div class="adm-repos-eyebrow">
3275 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3276 Site admin · Repositories
3277 </div>
3278 <h1 class="adm-repos-title">
3279 <span class="adm-repos-title-grad">Repositories</span>.
3280 </h1>
3281 <p class="adm-repos-sub">
3282 Every repository on the platform — public and private.
3283 Delete is irreversible and audit-logged.
3284 </p>
3285 </div>
3286 <a href="/admin" class="adm-repos-back">
07f4b70Claude3287 {Icons.arrowLeft} Back
3288 </a>
3289 </div>
3290 </section>
3291
8929744Claude3292 <div class="adm-repos-pills">
3293 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3294 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3295 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3296 </div>
3297
3298 {rows.length === 0 ? (
3299 <div class="adm-repos-empty">
3300 <div class="adm-repos-empty-orb" aria-hidden="true" />
3301 <div class="adm-repos-empty-inner">
3302 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3303 <div class="adm-repos-empty-title">No repositories yet</div>
3304 <div class="adm-repos-empty-sub">
3305 When users create their first repos, they'll appear here.
3306 </div>
3307 </div>
3308 </div>
3309 ) : (
3310 <div class="adm-repos-grid">
3311 {rows.map((r) => (
3312 <div class="adm-repos-card">
3313 <div class="adm-repos-card-head">
3314 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3315 <div class="adm-repos-card-title">
07f4b70Claude3316 <a
3317 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3318 class="adm-repos-card-name"
07f4b70Claude3319 >
3320 {r.ownerUsername}/{r.name}
3321 </a>
8929744Claude3322 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3323 </div>
8929744Claude3324 <span
3325 class={
3326 "adm-repos-pill " +
3327 (r.isPrivate ? "is-private" : "is-public")
3328 }
3329 style="margin-left:auto"
3330 >
3331 <span class="dot" aria-hidden="true" />
3332 {r.isPrivate ? "private" : "public"}
3333 </span>
3334 </div>
3335 <div class="adm-repos-card-meta">
3336 <span class="adm-repos-meta-item">
3337 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3338 <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
3339 </svg>
3340 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3341 </span>
3342 {r.createdAt && (
3343 <span class="adm-repos-meta-item">
3344 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3345 <rect x="3" y="4" width="18" height="18" rx="2" />
3346 <line x1="16" y1="2" x2="16" y2="6" />
3347 <line x1="8" y1="2" x2="8" y2="6" />
3348 <line x1="3" y1="10" x2="21" y2="10" />
3349 </svg>
3350 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3351 </span>
3352 )}
3353 </div>
3354 <div class="adm-repos-card-actions">
3355 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3356 Open repo
3357 </a>
3358 <form
3359 method="post"
3360 action={`/admin/repos/${r.id}/delete`}
3361 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3362 >
3363 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3364 Delete
3365 </button>
3366 </form>
8f50ed0Claude3367 </div>
3368 </div>
8929744Claude3369 ))}
3370 </div>
3371 )}
8f50ed0Claude3372 </div>
07f4b70Claude3373 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3374 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3375 </Layout>
3376 );
3377});
3378
3379admin.post("/admin/repos/:id/delete", async (c) => {
3380 const g = await gate(c);
3381 if (g instanceof Response) return g;
3382 const { user } = g;
3383 const id = c.req.param("id");
3384 try {
3385 await db.delete(repositories).where(eq(repositories.id, id));
3386 } catch (err) {
3387 console.error("[admin] repo delete:", err);
3388 }
3389 await audit({
3390 userId: user.id,
3391 action: "admin.repo.delete",
3392 targetType: "repository",
3393 targetId: id,
3394 });
3395 return c.redirect("/admin/repos");
3396});
3397
3398// ----- Flags -----
3399
3400admin.get("/admin/flags", async (c) => {
3401 const g = await gate(c);
3402 if (g instanceof Response) return g;
3403 const { user } = g;
3404
3405 const existing = await listFlags();
3406 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3407 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3408
3409 return c.html(
3410 <Layout title="Admin — Flags" user={user}>
8929744Claude3411 <div class="adm-flags-wrap">
3412 <section class="adm-flags-hero">
3413 <div class="adm-flags-hero-inner">
3414 <div class="adm-flags-hero-text">
3415 <div class="adm-flags-eyebrow">
3416 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3417 Site admin · Feature flags
3418 </div>
3419 <h1 class="adm-flags-title">
3420 <span class="adm-flags-title-grad">Site flags</span>.
3421 </h1>
3422 <p class="adm-flags-sub">
3423 Runtime feature flags surfaced to the rest of the app via{" "}
3424 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3425 </p>
3426 </div>
3427 <a href="/admin" class="adm-flags-back">
07f4b70Claude3428 {Icons.arrowLeft} Back
3429 </a>
3430 </div>
3431 </section>
3432
8929744Claude3433 <form method="post" action="/admin/flags" class="adm-flags-card">
3434 <div class="adm-flags-card-body">
07f4b70Claude3435 {keys.map((k) => {
3436 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3437 const isOverridden =
3438 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3439 return (
8929744Claude3440 <div class="adm-flags-field">
3441 <div class="adm-flags-field-head">
3442 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3443 {isOverridden && (
3444 <span class="adm-flags-mono">overridden</span>
3445 )}
3446 </div>
07f4b70Claude3447 <input
8929744Claude3448 id={`flag-${k}`}
07f4b70Claude3449 type="text"
3450 name={k}
3451 value={current}
3452 aria-label={k}
8929744Claude3453 class="adm-flags-input"
07f4b70Claude3454 />
8929744Claude3455 <div class="adm-flags-hint">
07f4b70Claude3456 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3457 </div>
3458 </div>
3459 );
3460 })}
3461 </div>
8929744Claude3462 <div class="adm-flags-card-foot">
3463 <span class="adm-flags-foot-hint">
07f4b70Claude3464 Saved values overwrite the defaults at runtime.
3465 </span>
8929744Claude3466 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3467 Save changes
07f4b70Claude3468 </button>
3469 </div>
3470 </form>
8f50ed0Claude3471 </div>
07f4b70Claude3472 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3473 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3474 </Layout>
3475 );
3476});
3477
3478admin.post("/admin/flags", async (c) => {
3479 const g = await gate(c);
3480 if (g instanceof Response) return g;
3481 const { user } = g;
3482 const body = await c.req.parseBody();
3483 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3484 for (const k of keys) {
3485 const v = String(body[k] ?? "");
3486 await setFlag(k, v, user.id);
3487 }
3488 await audit({ userId: user.id, action: "admin.flags.save" });
3489 return c.redirect("/admin/flags");
3490});
3491
08420cdClaude3492// ----- Email digests (Block I7) -----
3493
3494admin.get("/admin/digests", async (c) => {
3495 const g = await gate(c);
3496 if (g instanceof Response) return g;
3497 const { user } = g;
3498
3499 const [optedRow] = await db
3500 .select({ n: sql<number>`count(*)::int` })
3501 .from(users)
3502 .where(eq(users.notifyEmailDigestWeekly, true));
3503 const opted = Number(optedRow?.n || 0);
3504
3505 const recentlySent = await db
3506 .select({
3507 id: users.id,
3508 username: users.username,
3509 lastDigestSentAt: users.lastDigestSentAt,
3510 })
3511 .from(users)
3512 .where(sql`${users.lastDigestSentAt} is not null`)
3513 .orderBy(desc(users.lastDigestSentAt))
3514 .limit(20);
3515
3516 const result = c.req.query("result");
3517 const error = c.req.query("error");
3518
3519 return c.html(
3520 <Layout title="Admin — Digests" user={user}>
8929744Claude3521 <div class="adm-digests-wrap">
3522 <section class="adm-digests-hero">
3523 <div class="adm-digests-hero-inner">
3524 <div class="adm-digests-hero-text">
3525 <div class="adm-digests-eyebrow">
3526 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3527 Site admin · Email
3528 </div>
3529 <h1 class="adm-digests-title">
3530 <span class="adm-digests-title-grad">Email digests</span>.
3531 </h1>
3532 <p class="adm-digests-sub">
3533 Manually trigger the weekly digest for every opted-in user
3534 or preview the email for a single account.
3535 </p>
3536 </div>
3537 <a href="/admin" class="adm-digests-back">
07f4b70Claude3538 {Icons.arrowLeft} Back
3539 </a>
3540 </div>
3541 </section>
08420cdClaude3542
07f4b70Claude3543 {result && (
8929744Claude3544 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3545 )}
3546 {error && (
8929744Claude3547 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3548 )}
08420cdClaude3549
8929744Claude3550 <div class="adm-digests-pills">
3551 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3552 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3553 </div>
3554
3555 <section class="adm-digests-section">
3556 <header class="adm-digests-section-head">
3557 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3558 <div>
3559 <h3 class="adm-digests-section-title">Send digests</h3>
3560 <p class="adm-digests-section-sub">
3561 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3562 </p>
08420cdClaude3563 </div>
8929744Claude3564 </header>
3565 <div class="adm-digests-section-body">
3566 <form method="post" action="/admin/digests/run">
07f4b70Claude3567 <button
3568 type="submit"
8929744Claude3569 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3570 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3571 >
8929744Claude3572 {Icons.mail}
07f4b70Claude3573 Send digests now
3574 </button>
3575 </form>
8929744Claude3576 <div class="adm-digests-section-divider">
3577 <div class="adm-digests-divider-hint">
07f4b70Claude3578 Preview / one-off — send the digest to a single user.
3579 </div>
3580 <form
3581 method="post"
3582 action="/admin/digests/preview"
8929744Claude3583 class="adm-digests-form-row"
07f4b70Claude3584 >
3585 <input
3586 type="text"
3587 name="username"
3588 placeholder="username"
3589 required
3590 aria-label="Username"
8929744Claude3591 class="adm-digests-input"
07f4b70Claude3592 />
8929744Claude3593 <button type="submit" class="adm-digests-btn">
07f4b70Claude3594 Send to one user
3595 </button>
3596 </form>
3597 </div>
3598 </div>
8929744Claude3599 </section>
07f4b70Claude3600
8929744Claude3601 <div class="adm-digests-h3">
07f4b70Claude3602 <h3>Recently sent</h3>
8929744Claude3603 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3604 </div>
8929744Claude3605 {recentlySent.length === 0 ? (
3606 <div class="adm-digests-empty">
3607 <div class="adm-digests-empty-orb" aria-hidden="true" />
3608 <div class="adm-digests-empty-inner">
3609 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3610 <div class="adm-digests-empty-title">No digests sent yet</div>
3611 <div class="adm-digests-empty-sub">
3612 When the weekly digest fires, sent recipients will appear here.
3613 </div>
3614 </div>
3615 </div>
3616 ) : (
3617 <div class="adm-digests-grid">
3618 {recentlySent.map((u) => (
3619 <div class="adm-digests-card">
3620 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3621 <div class="adm-digests-card-text">
3622 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3623 <div class="adm-digests-card-sent">
3624 sent {u.lastDigestSentAt
3625 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3626 : "—"}
07f4b70Claude3627 </div>
3628 </div>
3629 </div>
8929744Claude3630 ))}
3631 </div>
3632 )}
08420cdClaude3633 </div>
07f4b70Claude3634 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3635 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3636 </Layout>
3637 );
3638});
3639
3640admin.post("/admin/digests/run", async (c) => {
3641 const g = await gate(c);
3642 if (g instanceof Response) return g;
3643 const { user } = g;
3644 const results = await sendDigestsToAll();
3645 const sent = results.filter((r) => r.ok).length;
3646 const skipped = results.length - sent;
3647 await audit({
3648 userId: user.id,
3649 action: "admin.digests.run",
3650 metadata: { sent, skipped, total: results.length },
3651 });
3652 return c.redirect(
3653 `/admin/digests?result=${encodeURIComponent(
3654 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3655 )}`
3656 );
3657});
3658
3659admin.post("/admin/digests/preview", async (c) => {
3660 const g = await gate(c);
3661 if (g instanceof Response) return g;
3662 const { user } = g;
3663 const body = await c.req.parseBody();
3664 const username = String(body.username || "").trim();
3665 if (!username) {
3666 return c.redirect("/admin/digests?error=Username+required");
3667 }
3668 const [target] = await db
3669 .select({ id: users.id, username: users.username })
3670 .from(users)
3671 .where(eq(users.username, username))
3672 .limit(1);
3673 if (!target) {
3674 return c.redirect("/admin/digests?error=User+not+found");
3675 }
3676 const result = await sendDigestForUser(target.id);
3677 await audit({
3678 userId: user.id,
3679 action: "admin.digests.preview",
3680 targetType: "user",
3681 targetId: target.id,
3682 metadata: {
3683 ok: result.ok,
3684 skipped: "skipped" in result ? result.skipped : null,
3685 },
3686 });
3687 if (result.ok) {
3688 return c.redirect(
3689 `/admin/digests?result=${encodeURIComponent(
3690 `Digest sent to ${target.username}.`
3691 )}`
3692 );
3693 }
3694 return c.redirect(
3695 `/admin/digests?error=${encodeURIComponent(
3696 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3697 )}`
3698 );
3699});
3700
b5dd694Claude3701const AUTOPILOT_TASK_CATALOG = [
3702 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3703 { name: "merge-queue", desc: "Advance serialised merge queues" },
3704 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3705 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3706 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3707 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3708 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3709 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3710 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3711 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
f65f600Claude3712 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
b5dd694Claude3713] as const;
3714
8e9f1d9Claude3715admin.get("/admin/autopilot", async (c) => {
3716 const g = await gate(c);
3717 if (g instanceof Response) return g;
3718 const { user } = g;
3719 const tick = getLastTick();
3720 const total = getTickCount();
3721 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3722 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3723 const intervalMs =
3724 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3725 ? Number(intervalRaw)
3726 : 5 * 60 * 1000;
3727 const msg = c.req.query("result") || c.req.query("error");
3728 const isErr = !!c.req.query("error");
3729 return c.html(
3730 <Layout title="Autopilot — admin" user={user}>
8929744Claude3731 <div class="adm-autopilot-wrap">
3732 <section class="adm-autopilot-hero">
3733 <div class="adm-autopilot-hero-inner">
3734 <div class="adm-autopilot-hero-text">
3735 <div class="adm-autopilot-eyebrow">
3736 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3737 Site admin · Maintenance loop
3738 </div>
3739 <h1 class="adm-autopilot-title">
3740 <span class="adm-autopilot-title-grad">Autopilot</span>.
3741 </h1>
3742 <p class="adm-autopilot-sub">
3743 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3744 progress, weekly digests, advisory rescans, wait-timer release,
3745 scheduled workflows (cron), AI-gated auto-merge sweep, and
3746 ai:build issue → PR dispatch.
8929744Claude3747 </p>
3748 </div>
3749 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3750 {Icons.arrowLeft} Back
3751 </a>
3752 </div>
3753 </section>
3754
8e9f1d9Claude3755 {msg && (
8929744Claude3756 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3757 {decodeURIComponent(msg)}
3758 </div>
3759 )}
07f4b70Claude3760
8929744Claude3761 <div class="adm-autopilot-statgrid">
3762 <div class="adm-autopilot-stat">
3763 <div class="adm-autopilot-stat-head">
3764 <span class="adm-autopilot-stat-label">Status</span>
3765 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3766 <span class="dot" aria-hidden="true" />
3767 {disabled ? "disabled" : "running"}
3768 </span>
3769 </div>
8929744Claude3770 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3771 {disabled ? "disabled" : "running"}
3772 </div>
8929744Claude3773 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3774 </div>
8929744Claude3775 <div class="adm-autopilot-stat">
3776 <div class="adm-autopilot-stat-head">
3777 <span class="adm-autopilot-stat-label">Interval</span>
3778 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3779 </div>
8929744Claude3780 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3781 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3782 </div>
8929744Claude3783 <div class="adm-autopilot-stat">
3784 <div class="adm-autopilot-stat-head">
3785 <span class="adm-autopilot-stat-label">Ticks this process</span>
3786 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3787 </div>
8929744Claude3788 <div class="adm-autopilot-stat-value">{total}</div>
3789 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3790 </div>
8929744Claude3791 <div class="adm-autopilot-stat">
3792 <div class="adm-autopilot-stat-head">
3793 <span class="adm-autopilot-stat-label">Last tick</span>
3794 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3795 </div>
8929744Claude3796 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3797 {tick ? tick.finishedAt : "never"}
3798 </div>
3799 </div>
3800 </div>
07f4b70Claude3801
8929744Claude3802 <div class="adm-autopilot-actions">
3803 <form method="post" action="/admin/autopilot/run">
3804 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3805 {Icons.bot}
3806 Run tick now
3807 </button>
3808 </form>
3809 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3810 Executes all sub-tasks synchronously and records the result.
3811 </span>
8929744Claude3812 </div>
07f4b70Claude3813
8929744Claude3814 <div class="adm-autopilot-h3">
07f4b70Claude3815 <h3>Last tick tasks</h3>
3816 {tick && (
8929744Claude3817 <span class="adm-autopilot-h3-meta">
07f4b70Claude3818 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3819 </span>
3820 )}
3821 </div>
8e9f1d9Claude3822 {tick ? (
8929744Claude3823 <div class="adm-autopilot-tasks">
3824 {tick.tasks.map((t) => (
3825 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3826 <div class="adm-autopilot-task-head">
3827 <span
3828 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3829 aria-label={t.ok ? "ok" : "failed"}
3830 />
3831 <span class="adm-autopilot-task-name">{t.name}</span>
3832 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3833 {t.ok ? "ok" : "failed"}
8929744Claude3834 </span>
3835 </div>
3836 <div class="adm-autopilot-task-meta">
3837 <span>duration</span>
3838 <span>{t.durationMs}ms</span>
3839 </div>
3840 {t.error && (
3841 <div class="adm-autopilot-task-err">{t.error}</div>
3842 )}
3843 </div>
3844 ))}
3845 </div>
8e9f1d9Claude3846 ) : (
8929744Claude3847 <div class="adm-autopilot-empty">
3848 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3849 <div class="adm-autopilot-empty-inner">
3850 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3851 <div class="adm-autopilot-empty-title">No ticks yet</div>
3852 <div class="adm-autopilot-empty-sub">
3853 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3854 </div>
3855 </div>
07f4b70Claude3856 </div>
8e9f1d9Claude3857 )}
b5dd694Claude3858 <div class="adm-autopilot-h3" style="margin-top:28px">
3859 <h3>Configured tasks</h3>
44ed968Claude3860 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3861 </div>
3862 <div class="adm-autopilot-tasks">
3863 {AUTOPILOT_TASK_CATALOG.map((t) => {
3864 const last = tick?.tasks.find((r) => r.name === t.name);
3865 return (
3866 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3867 <div class="adm-autopilot-task-head">
3868 <span
3869 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3870 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3871 />
3872 <span class="adm-autopilot-task-name">{t.name}</span>
3873 {last && (
3874 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3875 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3876 </span>
3877 )}
3878 </div>
3879 <div class="adm-autopilot-task-meta">
3880 <span>{t.desc}</span>
3881 </div>
3882 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3883 </div>
3884 );
3885 })}
3886 </div>
3887
8929744Claude3888 <p class="adm-autopilot-foot">
8e9f1d9Claude3889 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3890 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3891 </p>
3892 </div>
07f4b70Claude3893 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3894 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3895 </Layout>
3896 );
3897});
3898
988380aClaude3899admin.post("/admin/demo/reseed", async (c) => {
3900 const g = await gate(c);
3901 if (g instanceof Response) return g;
3902 const { user } = g;
3903 try {
3904 const result = await ensureDemoContent({ force: true });
3905 const summary = `Demo reseed: user=${result.created.user ? "created" : "existed"}, repos=${result.created.repos.length}, issues=${result.created.issues}, prs=${result.created.prs}${result.errors.length ? `, errors=${result.errors.length}` : ""}`;
3906 await audit({
3907 userId: user.id,
3908 action: "admin.demo.reseed",
3909 targetType: "user",
3910 targetId: result.demoUser?.id ?? "demo",
3911 metadata: {
3912 createdUser: result.created.user,
3913 createdRepos: result.created.repos,
3914 createdIssues: result.created.issues,
3915 createdPrs: result.created.prs,
3916 errors: result.errors.slice(0, 5),
3917 },
3918 });
3919 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3920 } catch (err) {
3921 const message = err instanceof Error ? err.message : String(err);
3922 return c.redirect(
3923 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3924 );
3925 }
3926});
3927
3928// Public jump-to-demo — redirects to the first demo repo if present,
3929// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3930admin.get("/demo", (c) => {
3931 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3932});
3933
8e9f1d9Claude3934admin.post("/admin/autopilot/run", async (c) => {
3935 const g = await gate(c);
3936 if (g instanceof Response) return g;
3937 const { user } = g;
3938 let summary = "";
3939 try {
3940 const result = await runAutopilotTick();
3941 const ok = result.tasks.filter((t) => t.ok).length;
3942 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3943 await audit({
3944 userId: user.id,
3945 action: "admin.autopilot.run",
3946 targetType: "system",
3947 targetId: "autopilot",
3948 metadata: { ok, total: result.tasks.length },
3949 });
3950 return c.redirect(
3951 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3952 );
3953 } catch (err) {
3954 const message = err instanceof Error ? err.message : String(err);
3955 return c.redirect(
3956 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3957 );
3958 }
3959});
3960
b218e63Claude3961// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
3962
3963admin.get("/admin/ai-costs", async (c) => {
3964 const g = await gate(c);
3965 if (g instanceof Response) return g;
3966 const { user } = g;
3967
3968 // Start of current month (UTC)
3969 const now = new Date();
3970 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
3971
3972 // Total spend this month
3973 const [totalRow] = await db
3974 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
3975 .from(aiCostEvents)
3976 .where(gte(aiCostEvents.occurredAt, monthStart));
3977 const totalCents = Number(totalRow?.total ?? 0);
3978
3979 // Breakdown by category
3980 const byCategory = await db
3981 .select({
3982 category: aiCostEvents.category,
3983 cents: sql<number>`sum(cents_estimate)::int`,
3984 calls: sql<number>`count(*)::int`,
3985 })
3986 .from(aiCostEvents)
3987 .where(gte(aiCostEvents.occurredAt, monthStart))
3988 .groupBy(aiCostEvents.category)
3989 .orderBy(sql`sum(cents_estimate) desc`);
3990
3991 // Top 10 spenders (join users)
3992 const topSpenders = await db
3993 .select({
3994 username: users.username,
3995 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
3996 calls: sql<number>`count(*)::int`,
3997 })
3998 .from(aiCostEvents)
3999 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
4000 .where(gte(aiCostEvents.occurredAt, monthStart))
4001 .groupBy(users.username)
4002 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
4003 .limit(10);
4004
4005 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
4006 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
4007
4008 function fmtCents(c: number): string {
4009 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
4010 return `${c}¢`;
4011 }
4012
4013 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
4014
4015 return c.html(
4016 <Layout title="Admin — AI Costs" user={user}>
4017 <div class="adm-analytics-wrap">
4018 <section class="adm-analytics-hero">
4019 <div class="adm-analytics-hero-inner">
4020 <div class="adm-analytics-hero-text">
4021 <div class="adm-analytics-eyebrow">
4022 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
4023 Site admin · Analytics
4024 </div>
4025 <h1 class="adm-analytics-title">
4026 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
4027 </h1>
4028 <p class="adm-analytics-sub">
4029 Per-call AI spend for {monthName} — by feature category and top spenders.
4030 </p>
4031 </div>
4032 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4033 </div>
4034 </section>
4035
4036 <div class="adm-analytics-statgrid">
4037 <div class="adm-analytics-stat">
4038 <div class="adm-analytics-stat-label">Total spend this month</div>
4039 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
4040 <div class="adm-analytics-stat-hint">{monthName}</div>
4041 </div>
4042 <div class="adm-analytics-stat">
4043 <div class="adm-analytics-stat-label">Categories active</div>
4044 <div class="adm-analytics-stat-value">{byCategory.length}</div>
4045 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
4046 </div>
4047 <div class="adm-analytics-stat">
4048 <div class="adm-analytics-stat-label">Total AI calls</div>
4049 <div class="adm-analytics-stat-value">
4050 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
4051 </div>
4052 <div class="adm-analytics-stat-hint">recorded events</div>
4053 </div>
4054 </div>
4055
4056 <div class="adm-analytics-h3">
4057 <h3>By category</h3>
4058 <span class="adm-analytics-h3-meta">{monthName}</span>
4059 </div>
4060 {byCategory.length === 0 ? (
4061 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
4062 ) : (
4063 <table class="adm-analytics-table">
4064 <thead>
4065 <tr>
4066 <th>Category</th>
4067 <th>Spend</th>
4068 <th>Calls</th>
4069 <th style="width:200px">Distribution</th>
4070 </tr>
4071 </thead>
4072 <tbody>
4073 {byCategory.map((row) => {
4074 const pct = Math.round((Number(row.cents) / maxCents) * 100);
4075 return (
4076 <tr>
4077 <td><code>{row.category ?? "other"}</code></td>
4078 <td>{fmtCents(Number(row.cents))}</td>
4079 <td>{Number(row.calls).toLocaleString()}</td>
4080 <td>
4081 <div class="adm-analytics-bar-cell">
4082 <div class="adm-analytics-bar-track">
4083 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4084 </div>
4085 <span class="adm-analytics-bar-label">{pct}%</span>
4086 </div>
4087 </td>
4088 </tr>
4089 );
4090 })}
4091 </tbody>
4092 </table>
4093 )}
4094
4095 <div class="adm-analytics-h3">
4096 <h3>Top 10 spenders</h3>
4097 <span class="adm-analytics-h3-meta">{monthName}</span>
4098 </div>
4099 {topSpenders.length === 0 ? (
4100 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4101 ) : (
4102 <table class="adm-analytics-table">
4103 <thead>
4104 <tr>
4105 <th>User</th>
4106 <th>Spend</th>
4107 <th>Calls</th>
4108 <th style="width:200px">Share</th>
4109 </tr>
4110 </thead>
4111 <tbody>
4112 {topSpenders.map((row, i) => {
4113 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4114 return (
4115 <tr>
4116 <td>
4117 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4118 #{i + 1} {row.username}
4119 </a>
4120 </td>
4121 <td>{fmtCents(Number(row.cents))}</td>
4122 <td>{Number(row.calls).toLocaleString()}</td>
4123 <td>
4124 <div class="adm-analytics-bar-cell">
4125 <div class="adm-analytics-bar-track">
4126 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4127 </div>
4128 <span class="adm-analytics-bar-label">{pct}%</span>
4129 </div>
4130 </td>
4131 </tr>
4132 );
4133 })}
4134 </tbody>
4135 </table>
4136 )}
4137 </div>
4138 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4139 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4140 </Layout>
4141 );
4142});
4143
4144// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4145
4146const ALL_AUTOPILOT_TASKS = [
4147 "mirror-sync",
4148 "merge-queue",
4149 "weekly-digest",
4150 "advisory-rescan",
4151 "wait-timer-release",
4152 "scheduled-workflows",
4153 "auto-merge-sweep",
4154 "ai-build-from-issues",
4155 "sleep-mode-digest",
4156 "stale-pr-sweep",
4157] as const;
4158
4159admin.get("/admin/autopilot/health", async (c) => {
4160 const g = await gate(c);
4161 if (g instanceof Response) return g;
4162 const { user } = g;
4163
4164 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4165
4166 // Pull the last 200 audit log rows that match autopilot task patterns
4167 const logRows = await db
4168 .select({
4169 action: auditLog.action,
4170 createdAt: auditLog.createdAt,
4171 metadata: auditLog.metadata,
4172 })
4173 .from(auditLog)
4174 .where(gte(auditLog.createdAt, since24h))
4175 .orderBy(desc(auditLog.createdAt))
4176 .limit(500);
4177
4178 // Build per-task health from the last autopilot tick (in-memory)
4179 const tick = getLastTick();
4180
4181 type TaskHealth = {
4182 name: string;
4183 lastRun: string | null;
4184 lastDurationMs: number | null;
4185 lastOk: boolean | null;
4186 lastError: string | null;
4187 successCount24h: number;
4188 errorCount24h: number;
4189 };
4190
4191 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4192 // Count audit events in last 24h that look like this task
4193 const successCount24h = logRows.filter(
4194 (r) => r.action === `autopilot.task.ok.${taskName}`
4195 ).length;
4196 const errorCount24h = logRows.filter(
4197 (r) => r.action === `autopilot.task.error.${taskName}`
4198 ).length;
4199
4200 // Get last tick result for this task (in-memory)
4201 const last = tick?.tasks.find((t) => t.name === taskName);
4202
4203 return {
4204 name: taskName,
4205 lastRun: tick?.finishedAt ?? null,
4206 lastDurationMs: last?.durationMs ?? null,
4207 lastOk: last?.ok ?? null,
4208 lastError: last?.error ?? null,
4209 successCount24h,
4210 errorCount24h,
4211 };
4212 });
4213
4214 const total = getTickCount();
4215 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4216
4217 return c.html(
4218 <Layout title="Autopilot Health — admin" user={user}>
4219 <div class="adm-analytics-wrap">
4220 <section class="adm-analytics-hero">
4221 <div class="adm-analytics-hero-inner">
4222 <div class="adm-analytics-hero-text">
4223 <div class="adm-analytics-eyebrow">
4224 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4225 Site admin · Autopilot
4226 </div>
4227 <h1 class="adm-analytics-title">
4228 <span class="adm-analytics-title-grad">Autopilot health</span>.
4229 </h1>
4230 <p class="adm-analytics-sub">
4231 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4232 </p>
4233 </div>
4234 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4235 </div>
4236 </section>
4237
4238 <div class="adm-analytics-statgrid">
4239 <div class="adm-analytics-stat">
4240 <div class="adm-analytics-stat-label">Status</div>
4241 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4242 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4243 </div>
4244 <div class="adm-analytics-stat">
4245 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4246 <div class="adm-analytics-stat-value">{total}</div>
4247 <div class="adm-analytics-stat-hint">since boot</div>
4248 </div>
4249 <div class="adm-analytics-stat">
4250 <div class="adm-analytics-stat-label">Last tick</div>
4251 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4252 {tick?.finishedAt ?? "—"}
4253 </div>
4254 </div>
4255 <div class="adm-analytics-stat">
4256 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4257 <div class="adm-analytics-stat-value">
4258 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4259 </div>
4260 </div>
4261 </div>
4262
4263 <div class="adm-analytics-h3">
4264 <h3>Per-task health</h3>
4265 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4266 </div>
4267 <table class="adm-analytics-table">
4268 <thead>
4269 <tr>
4270 <th>Task</th>
4271 <th>Last status</th>
4272 <th>Duration (last)</th>
4273 <th>OK (24h)</th>
4274 <th>Errors (24h)</th>
4275 </tr>
4276 </thead>
4277 <tbody>
4278 {health.map((t) => (
4279 <tr>
4280 <td><code>{t.name}</code></td>
4281 <td>
4282 {t.lastOk === null ? (
4283 <span style="color:var(--text-muted)">—</span>
4284 ) : t.lastOk ? (
4285 <span class="adm-analytics-pill is-ok">ok</span>
4286 ) : (
4287 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4288 )}
4289 </td>
4290 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4291 <td>
4292 {t.successCount24h > 0 ? (
4293 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4294 ) : (
4295 <span style="color:var(--text-muted)">—</span>
4296 )}
4297 </td>
4298 <td>
4299 {t.errorCount24h > 0 ? (
4300 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4301 ) : (
4302 <span style="color:var(--text-muted)">0</span>
4303 )}
4304 </td>
4305 </tr>
4306 ))}
4307 </tbody>
4308 </table>
4309
4310 {tick?.tasks.some((t) => !t.ok && t.error) && (
4311 <>
4312 <div class="adm-analytics-h3" style="margin-top:28px">
4313 <h3>Last-tick errors</h3>
4314 </div>
4315 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4316 <div style="margin-bottom:12px;padding:12px 14px;background:rgba(248,113,113,0.06);border:1px solid rgba(248,113,113,0.20);border-radius:10px;font-size:12.5px">
4317 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4318 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4319 </div>
4320 ))}
4321 </>
4322 )}
4323
4324 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4325 The 24h OK/error counts reflect <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">audit_log</code> rows written by autopilot task wrappers. If those rows are absent, counts will show — (not yet instrumented).
4326 </p>
4327 </div>
4328 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4329 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4330 </Layout>
4331 );
4332});
4333
4334// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4335
4336admin.get("/admin/growth", async (c) => {
4337 const g = await gate(c);
4338 if (g instanceof Response) return g;
4339 const { user } = g;
4340
4341 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4342
4343 // Daily signups last 30 days
4344 const dailySignups = await db
4345 .select({
4346 day: sql<string>`date_trunc('day', created_at)::date::text`,
4347 count: sql<number>`count(*)::int`,
4348 })
4349 .from(users)
4350 .where(gte(users.createdAt, since30d))
4351 .groupBy(sql`date_trunc('day', created_at)`)
4352 .orderBy(sql`date_trunc('day', created_at)`);
4353
4354 // Activation rate: users who created at least 1 repo (last 30d signups)
4355 const activatedRows = await db
4356 .select({
4357 userId: users.id,
4358 })
4359 .from(users)
4360 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4361 .where(gte(users.createdAt, since30d))
4362 .groupBy(users.id);
4363
4364 // Total signups in window
4365 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4366 const activated = activatedRows.length;
4367 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4368
4369 // All-time user count
4370 const [allTimeRow] = await db
4371 .select({ n: sql<number>`count(*)::int` })
4372 .from(users);
4373 const allTime = Number(allTimeRow?.n ?? 0);
4374
4375 // Build a 30-slot array (fill missing days with 0)
4376 const dayMap = new Map<string, number>();
4377 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4378
4379 const slots: { label: string; count: number }[] = [];
4380 for (let i = 29; i >= 0; i--) {
4381 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4382 const key = d.toISOString().slice(0, 10);
4383 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4384 slots.push({ label, count: dayMap.get(key) ?? 0 });
4385 }
4386
4387 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4388
4389 return c.html(
4390 <Layout title="Admin — User Growth" user={user}>
4391 <div class="adm-analytics-wrap">
4392 <section class="adm-analytics-hero">
4393 <div class="adm-analytics-hero-inner">
4394 <div class="adm-analytics-hero-text">
4395 <div class="adm-analytics-eyebrow">
4396 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4397 Site admin · Analytics
4398 </div>
4399 <h1 class="adm-analytics-title">
4400 <span class="adm-analytics-title-grad">User growth</span>.
4401 </h1>
4402 <p class="adm-analytics-sub">
4403 Daily signups and activation rate over the last 30 days.
4404 </p>
4405 </div>
4406 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4407 </div>
4408 </section>
4409
4410 <div class="adm-analytics-statgrid">
4411 <div class="adm-analytics-stat">
4412 <div class="adm-analytics-stat-label">Total users</div>
4413 <div class="adm-analytics-stat-value">{allTime}</div>
4414 <div class="adm-analytics-stat-hint">all time</div>
4415 </div>
4416 <div class="adm-analytics-stat">
4417 <div class="adm-analytics-stat-label">New signups (30d)</div>
4418 <div class="adm-analytics-stat-value">{totalSignups}</div>
4419 <div class="adm-analytics-stat-hint">last 30 days</div>
4420 </div>
4421 <div class="adm-analytics-stat">
4422 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4423 <div class="adm-analytics-stat-value">{activationRate}%</div>
4424 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4425 </div>
4426 <div class="adm-analytics-stat">
4427 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4428 <div class="adm-analytics-stat-value">{activated}</div>
4429 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4430 </div>
4431 </div>
4432
4433 <div class="adm-analytics-h3">
4434 <h3>Daily signups — last 30 days</h3>
4435 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4436 </div>
4437
4438 {totalSignups === 0 ? (
4439 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4440 ) : (
4441 <table class="adm-analytics-table">
4442 <thead>
4443 <tr>
4444 <th>Date</th>
4445 <th>Signups</th>
4446 <th style="width:280px">Bar</th>
4447 </tr>
4448 </thead>
4449 <tbody>
4450 {slots.filter((s) => s.count > 0 || true).map((s) => {
4451 const pct = Math.round((s.count / maxCount) * 100);
4452 return (
4453 <tr>
4454 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4455 <td>{s.count}</td>
4456 <td>
4457 {s.count > 0 ? (
4458 <div class="adm-analytics-bar-cell">
4459 <div class="adm-analytics-bar-track">
4460 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4461 </div>
4462 <span class="adm-analytics-bar-label">{s.count}</span>
4463 </div>
4464 ) : (
4465 <span style="color:var(--text-faint);font-size:11px">—</span>
4466 )}
4467 </td>
4468 </tr>
4469 );
4470 })}
4471 </tbody>
4472 </table>
4473 )}
4474
4475 <div class="adm-analytics-h3" style="margin-top:28px">
4476 <h3>Activation breakdown</h3>
4477 <span class="adm-analytics-h3-meta">30d cohort</span>
4478 </div>
4479 <table class="adm-analytics-table" style="max-width:500px">
4480 <thead>
4481 <tr>
4482 <th>Segment</th>
4483 <th>Count</th>
4484 <th style="width:200px">Rate</th>
4485 </tr>
4486 </thead>
4487 <tbody>
4488 <tr>
4489 <td>New signups (30d)</td>
4490 <td>{totalSignups}</td>
4491 <td>100%</td>
4492 </tr>
4493 <tr>
4494 <td>Activated (created ≥1 repo)</td>
4495 <td>{activated}</td>
4496 <td>
4497 <div class="adm-analytics-bar-cell">
4498 <div class="adm-analytics-bar-track">
4499 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4500 </div>
4501 <span class="adm-analytics-bar-label">{activationRate}%</span>
4502 </div>
4503 </td>
4504 </tr>
4505 <tr>
4506 <td>Not activated</td>
4507 <td>{totalSignups - activated}</td>
4508 <td>{100 - activationRate}%</td>
4509 </tr>
4510 </tbody>
4511 </table>
4512 </div>
4513 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4514 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4515 </Layout>
4516 );
4517});
4518
8f50ed0Claude4519export default admin;