Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

admin.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.tsxBlame4515 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">
2836 <a href="/admin/ops" class="admin-action is-primary">
2837 <span class="admin-action-icon">{Icons.ops}</span>
2838 Operations
2839 </a>
509c376Claude2840 <a href="/admin/integrations" class="admin-action is-primary">
2841 <span class="admin-action-icon">{Icons.key}</span>
2842 Integrations
2843 </a>
cf793f9Claude2844 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2845 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2846 Health (traffic lights)
2847 </a>
2848 <a href="/admin/deploys" class="admin-action is-primary">
2849 <span class="admin-action-icon">{Icons.ops}</span>
2850 Deploys
2851 </a>
8cfb00eClaude2852 <a href="/admin/env-health" class="admin-action">
cf793f9Claude2853 <span class="admin-action-icon">{Icons.pulse}</span>
8cfb00eClaude2854 Env health
cf793f9Claude2855 </a>
2856 <a href="/admin/self-host" class="admin-action">
2857 <span class="admin-action-icon">{Icons.ops}</span>
2858 Self-host status
2859 </a>
2860 <a href="/admin/status" class="admin-action">
2861 <span class="admin-action-icon">{Icons.pulse}</span>
8cfb00eClaude2862 Platform monitor
07f4b70Claude2863 </a>
2864 <a href="/admin/users" class="admin-action">
2865 <span class="admin-action-icon">{Icons.users}</span>
2866 Manage users
2867 </a>
2868 <a href="/admin/repos" class="admin-action">
2869 <span class="admin-action-icon">{Icons.repo}</span>
2870 Manage repos
2871 </a>
2872 <a href="/admin/flags" class="admin-action">
2873 <span class="admin-action-icon">{Icons.flag}</span>
2874 Site flags
2875 </a>
2876 <a href="/admin/digests" class="admin-action">
2877 <span class="admin-action-icon">{Icons.mail}</span>
2878 Email digests
2879 </a>
2880 <a href="/admin/google-oauth" class="admin-action">
2881 <span class="admin-action-icon">{Icons.google}</span>
2882 Sign in with Google
2883 </a>
2884 <a href="/admin/github-oauth" class="admin-action">
2885 <span class="admin-action-icon">{Icons.github}</span>
2886 Sign in with GitHub
2887 </a>
2888 <a href="/admin/sso" class="admin-action">
2889 <span class="admin-action-icon">{Icons.sso}</span>
2890 Enterprise SSO
2891 </a>
b218e63Claude2892 <a href="/admin/ai-costs" class="admin-action is-primary">
2893 <span class="admin-action-icon">{Icons.dollarSign}</span>
2894 AI cost breakdown
2895 </a>
2896 <a href="/admin/growth" class="admin-action is-primary">
2897 <span class="admin-action-icon">{Icons.trendingUp}</span>
2898 User growth
2899 </a>
c6018a5Claude2900 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
07f4b70Claude2901 <span class="admin-action-icon">{Icons.bot}</span>
2902 Autopilot
2903 </a>
c6018a5Claude2904 <a href="/admin/diagnose" class="admin-action" title="Live status of the AI CI healer, patch generator, and proactive monitor">
2905 <span class="admin-action-icon">{Icons.bot}</span>
2906 AI background tasks
2907 </a>
662ce86Claude2908 <a href="/connect/claude" class="admin-action is-primary">
2909 <span class="admin-action-icon">{Icons.bot}</span>
2910 Connect Claude
2911 </a>
7293c67Claude2912 <a href="/admin/deletions" class="admin-action">
2913 <span class="admin-action-icon">{Icons.flag}</span>
2914 GDPR Deletions
2915 </a>
2916 <a href="/admin/stripe" class="admin-action">
2917 <span class="admin-action-icon">{Icons.key}</span>
2918 Stripe Sync
2919 </a>
07f4b70Claude2920 <form
2921 method="post"
2922 action="/admin/demo/reseed"
2923 class="admin-action-form"
2924 >
2925 <button
2926 class="admin-action"
2927 type="submit"
2928 title="Idempotently (re)create demo user + 3 sample repos"
2929 >
2930 <span class="admin-action-icon">{Icons.refresh}</span>
2931 Reseed demo
2932 </button>
2933 </form>
2934 </div>
8f50ed0Claude2935
6dec21bClaude2936 <div class="admin-h3">
2937 <h3>System Health</h3>
2938 <span class="admin-h3-meta">Ops &amp; security pages</span>
2939 </div>
2940 <div class="admin-actions" style="margin-bottom:var(--space-5)">
2941 <a href="/admin/env-health" class="admin-action is-primary" title="Check which features are enabled via env vars">
2942 <span class="admin-action-icon">{Icons.pulse}</span>
2943 Env health
2944 </a>
2945 <a href="/admin/advancement" class="admin-action" title="Platform advancement tracker">
2946 <span class="admin-action-icon">{Icons.trendingUp}</span>
2947 Advancement
2948 </a>
2949 <a href="/admin/security" class="admin-action" title="Security overview">
2950 <span class="admin-action-icon">{Icons.starShield}</span>
2951 Security
2952 </a>
2953 </div>
2954
2955 <div class="admin-automation-tile">
2956 <div class="admin-automation-heading">
2957 <span class="admin-automation-heading-icon" aria-hidden="true">{Icons.bot}</span>
2958 <span class="admin-automation-heading-label">Automation Activity</span>
2959 <span class="admin-automation-heading-sub">last 24h</span>
2960 </div>
2961 <div class="admin-automation-grid">
2962 <div class="admin-automation-stat">
2963 <div class="admin-automation-stat-label">AI Reviews</div>
2964 <div class="admin-automation-stat-value">{aiReviewsCount}</div>
2965 <div class="admin-automation-stat-hint">PR comments posted</div>
2966 </div>
2967 <div class="admin-automation-stat">
2968 <div class="admin-automation-stat-label">Auto-merges</div>
2969 <div class="admin-automation-stat-value">{autoMergesCount}</div>
2970 <div class="admin-automation-stat-hint">PRs auto-merged</div>
2971 </div>
2972 <div class="admin-automation-stat">
2973 <div class="admin-automation-stat-label">Issues Triaged</div>
2974 <div class="admin-automation-stat-value">{issueTriagedCount}</div>
2975 <div class="admin-automation-stat-hint">AI triage comments</div>
2976 </div>
2977 <div class="admin-automation-stat">
2978 <div class="admin-automation-stat-label">CI Autofixes</div>
2979 <div class="admin-automation-stat-value">{ciAutofixCount}</div>
2980 <div class="admin-automation-stat-hint">Autofix runs</div>
2981 </div>
2982 </div>
2983 </div>
2984
07f4b70Claude2985 <div class="admin-h3">
2986 <h3>Recent signups</h3>
2987 <span class="admin-h3-meta">
2988 {recent.length} most-recent
2989 </span>
2990 </div>
2991 <div class="admin-list" style="margin-bottom:20px">
2992 {recent.length === 0 ? (
2993 <div class="admin-list-empty">No users yet.</div>
2994 ) : (
2995 recent.map((u) => (
2996 <div class="admin-list-row">
2997 <div class="admin-list-main">
2998 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
2999 <div class="admin-row-text">
3000 <a href={`/${u.username}`} class="admin-row-title">
3001 {u.username}
3002 </a>
3003 <div class="admin-row-sub">
3004 <span>Joined</span>
3005 <span>
3006 {u.createdAt
3007 ? new Date(u.createdAt as unknown as string).toLocaleString()
3008 : ""}
3009 </span>
3010 </div>
3011 </div>
3012 </div>
3013 </div>
3014 ))
3015 )}
3016 </div>
8f50ed0Claude3017
07f4b70Claude3018 <div class="admin-h3">
3019 <h3>Site admins</h3>
3020 <span class="admin-h3-meta">
3021 {adminCount} active
3022 </span>
3023 </div>
3024 <div class="admin-list">
3025 {admins.length === 0 ? (
3026 <div class="admin-list-empty">
3027 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude3028 </div>
07f4b70Claude3029 ) : (
3030 admins.map((a) => (
3031 <div class="admin-list-row">
3032 <div class="admin-list-main">
3033 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
3034 <div class="admin-row-text">
3035 <a href={`/${a.username}`} class="admin-row-title">
3036 {a.username}
3037 </a>
3038 <div class="admin-row-sub">
3039 <span class="admin-pill is-admin">
3040 <span class="dot" aria-hidden="true" /> Site admin
3041 </span>
3042 <span>
3043 Granted{" "}
3044 {a.grantedAt
3045 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
3046 : "—"}
3047 </span>
3048 </div>
3049 </div>
3050 </div>
3051 </div>
3052 ))
3053 )}
3054 </div>
8f50ed0Claude3055 </div>
07f4b70Claude3056 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude3057 </Layout>
3058 );
3059});
3060
3061// ----- Users -----
3062
3063admin.get("/admin/users", async (c) => {
3064 const g = await gate(c);
3065 if (g instanceof Response) return g;
3066 const { user } = g;
3067 const q = c.req.query("q") || "";
3068 const rows = await db
3069 .select({
3070 id: users.id,
3071 username: users.username,
3072 email: users.email,
3073 createdAt: users.createdAt,
3074 })
3075 .from(users)
3076 .where(
3077 q
3078 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
3079 : sql`1=1`
3080 )
3081 .orderBy(desc(users.createdAt))
3082 .limit(200);
3083
3084 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude3085 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude3086
3087 return c.html(
3088 <Layout title="Admin — Users" user={user}>
8929744Claude3089 <div class="adm-users-wrap">
3090 <section class="adm-users-hero">
3091 <div class="adm-users-hero-inner">
3092 <div class="adm-users-hero-text">
3093 <div class="adm-users-eyebrow">
3094 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
3095 Site admin · Users
3096 </div>
3097 <h1 class="adm-users-title">
3098 <span class="adm-users-title-grad">Users</span>.
3099 </h1>
3100 <p class="adm-users-sub">
3101 Search, audit, and grant or revoke the site-admin flag.
3102 Showing up to 200 accounts ordered by signup recency.
3103 </p>
3104 </div>
3105 <a href="/admin" class="adm-users-back">
07f4b70Claude3106 {Icons.arrowLeft} Back
3107 </a>
3108 </div>
3109 </section>
3110
8929744Claude3111 <div class="adm-users-filterbar">
3112 <form method="get" action="/admin/users" class="adm-users-search">
3113 <span class="adm-users-search-ico" aria-hidden="true">
3114 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
3115 <circle cx="11" cy="11" r="8" />
3116 <line x1="21" y1="21" x2="16.65" y2="16.65" />
3117 </svg>
3118 </span>
3119 <input
3120 type="text"
3121 name="q"
3122 value={q}
3123 placeholder="Search username or email"
3124 aria-label="Search username or email"
3125 class="adm-users-input"
3126 />
3127 <button type="submit" class="adm-users-btn">Search</button>
3128 {q && (
3129 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
3130 )}
3131 </form>
3132 <div class="adm-users-pills">
3133 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3134 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
3135 </div>
3136 </div>
07f4b70Claude3137
8929744Claude3138 {rows.length === 0 ? (
3139 <div class="adm-users-empty">
3140 <div class="adm-users-empty-orb" aria-hidden="true" />
3141 <div class="adm-users-empty-inner">
3142 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
3143 <div class="adm-users-empty-title">No users found</div>
3144 <div class="adm-users-empty-sub">
3145 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
3146 </div>
3147 </div>
3148 </div>
3149 ) : (
3150 <div class="adm-users-grid">
3151 {rows.map((u) => {
07f4b70Claude3152 const isAdmin = adminIds.has(u.id);
3153 return (
8929744Claude3154 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
3155 <div class="adm-users-card-head">
3156 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude3157 {initials(u.username)}
8f50ed0Claude3158 </span>
8929744Claude3159 <div class="adm-users-card-id">
3160 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
3161 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude3162 </div>
8929744Claude3163 {isAdmin && (
3164 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
3165 )}
3166 </div>
3167 <div class="adm-users-card-meta">
3168 <span class="adm-users-meta-item">
3169 <span class="adm-users-meta-key">Email</span>
3170 <span class="adm-users-meta-val">{u.email}</span>
3171 </span>
3172 {u.createdAt && (
3173 <span class="adm-users-meta-item">
3174 <span class="adm-users-meta-key">Joined</span>
3175 <span class="adm-users-meta-val">
3176 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
3177 </span>
3178 </span>
3179 )}
3180 </div>
3181 <div class="adm-users-card-actions">
3182 <form
3183 method="post"
3184 action={`/admin/users/${u.id}/admin`}
3185 onsubmit={
3186 isAdmin
3187 ? "return confirm('Revoke site admin?')"
3188 : "return confirm('Grant site admin?')"
3189 }
3190 >
3191 <button
3192 type="submit"
3193 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
3194 >
3195 {isAdmin ? "Revoke admin" : "Grant admin"}
3196 </button>
3197 </form>
3198 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
3199 View profile
3200 </a>
07f4b70Claude3201 </div>
8f50ed0Claude3202 </div>
07f4b70Claude3203 );
8929744Claude3204 })}
3205 </div>
3206 )}
8f50ed0Claude3207 </div>
07f4b70Claude3208 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3209 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude3210 </Layout>
3211 );
3212});
3213
3214admin.post("/admin/users/:id/admin", async (c) => {
3215 const g = await gate(c);
3216 if (g instanceof Response) return g;
3217 const { user } = g;
3218 const id = c.req.param("id");
3219 const admins = await listSiteAdmins();
3220 const isAlready = admins.some((a) => a.userId === id);
3221 if (isAlready) {
3222 await revokeSiteAdmin(id);
3223 await audit({
3224 userId: user.id,
3225 action: "site_admin.revoke",
3226 targetType: "user",
3227 targetId: id,
3228 });
3229 } else {
3230 await grantSiteAdmin(id, user.id);
3231 await audit({
3232 userId: user.id,
3233 action: "site_admin.grant",
3234 targetType: "user",
3235 targetId: id,
3236 });
3237 }
3238 return c.redirect("/admin/users");
3239});
3240
3241// ----- Repos -----
3242
3243admin.get("/admin/repos", async (c) => {
3244 const g = await gate(c);
3245 if (g instanceof Response) return g;
3246 const { user } = g;
3247 const rows = await db
3248 .select({
3249 id: repositories.id,
3250 name: repositories.name,
3251 ownerUsername: users.username,
0316dbbClaude3252 isPrivate: repositories.isPrivate,
8f50ed0Claude3253 createdAt: repositories.createdAt,
3254 starCount: repositories.starCount,
3255 })
3256 .from(repositories)
3257 .innerJoin(users, eq(repositories.ownerId, users.id))
3258 .orderBy(desc(repositories.createdAt))
3259 .limit(200);
3260
8929744Claude3261 const privateCount = rows.filter((r) => r.isPrivate).length;
3262 const publicCount = rows.length - privateCount;
3263
8f50ed0Claude3264 return c.html(
3265 <Layout title="Admin — Repos" user={user}>
8929744Claude3266 <div class="adm-repos-wrap">
3267 <section class="adm-repos-hero">
3268 <div class="adm-repos-hero-inner">
3269 <div class="adm-repos-hero-text">
3270 <div class="adm-repos-eyebrow">
3271 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3272 Site admin · Repositories
3273 </div>
3274 <h1 class="adm-repos-title">
3275 <span class="adm-repos-title-grad">Repositories</span>.
3276 </h1>
3277 <p class="adm-repos-sub">
3278 Every repository on the platform — public and private.
3279 Delete is irreversible and audit-logged.
3280 </p>
3281 </div>
3282 <a href="/admin" class="adm-repos-back">
07f4b70Claude3283 {Icons.arrowLeft} Back
3284 </a>
3285 </div>
3286 </section>
3287
8929744Claude3288 <div class="adm-repos-pills">
3289 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3290 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3291 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3292 </div>
3293
3294 {rows.length === 0 ? (
3295 <div class="adm-repos-empty">
3296 <div class="adm-repos-empty-orb" aria-hidden="true" />
3297 <div class="adm-repos-empty-inner">
3298 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3299 <div class="adm-repos-empty-title">No repositories yet</div>
3300 <div class="adm-repos-empty-sub">
3301 When users create their first repos, they'll appear here.
3302 </div>
3303 </div>
3304 </div>
3305 ) : (
3306 <div class="adm-repos-grid">
3307 {rows.map((r) => (
3308 <div class="adm-repos-card">
3309 <div class="adm-repos-card-head">
3310 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3311 <div class="adm-repos-card-title">
07f4b70Claude3312 <a
3313 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3314 class="adm-repos-card-name"
07f4b70Claude3315 >
3316 {r.ownerUsername}/{r.name}
3317 </a>
8929744Claude3318 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3319 </div>
8929744Claude3320 <span
3321 class={
3322 "adm-repos-pill " +
3323 (r.isPrivate ? "is-private" : "is-public")
3324 }
3325 style="margin-left:auto"
3326 >
3327 <span class="dot" aria-hidden="true" />
3328 {r.isPrivate ? "private" : "public"}
3329 </span>
3330 </div>
3331 <div class="adm-repos-card-meta">
3332 <span class="adm-repos-meta-item">
3333 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3334 <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" />
3335 </svg>
3336 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3337 </span>
3338 {r.createdAt && (
3339 <span class="adm-repos-meta-item">
3340 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3341 <rect x="3" y="4" width="18" height="18" rx="2" />
3342 <line x1="16" y1="2" x2="16" y2="6" />
3343 <line x1="8" y1="2" x2="8" y2="6" />
3344 <line x1="3" y1="10" x2="21" y2="10" />
3345 </svg>
3346 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3347 </span>
3348 )}
3349 </div>
3350 <div class="adm-repos-card-actions">
3351 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3352 Open repo
3353 </a>
3354 <form
3355 method="post"
3356 action={`/admin/repos/${r.id}/delete`}
3357 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3358 >
3359 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3360 Delete
3361 </button>
3362 </form>
8f50ed0Claude3363 </div>
3364 </div>
8929744Claude3365 ))}
3366 </div>
3367 )}
8f50ed0Claude3368 </div>
07f4b70Claude3369 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3370 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3371 </Layout>
3372 );
3373});
3374
3375admin.post("/admin/repos/:id/delete", async (c) => {
3376 const g = await gate(c);
3377 if (g instanceof Response) return g;
3378 const { user } = g;
3379 const id = c.req.param("id");
3380 try {
3381 await db.delete(repositories).where(eq(repositories.id, id));
3382 } catch (err) {
3383 console.error("[admin] repo delete:", err);
3384 }
3385 await audit({
3386 userId: user.id,
3387 action: "admin.repo.delete",
3388 targetType: "repository",
3389 targetId: id,
3390 });
3391 return c.redirect("/admin/repos");
3392});
3393
3394// ----- Flags -----
3395
3396admin.get("/admin/flags", async (c) => {
3397 const g = await gate(c);
3398 if (g instanceof Response) return g;
3399 const { user } = g;
3400
3401 const existing = await listFlags();
3402 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3403 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3404
3405 return c.html(
3406 <Layout title="Admin — Flags" user={user}>
8929744Claude3407 <div class="adm-flags-wrap">
3408 <section class="adm-flags-hero">
3409 <div class="adm-flags-hero-inner">
3410 <div class="adm-flags-hero-text">
3411 <div class="adm-flags-eyebrow">
3412 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3413 Site admin · Feature flags
3414 </div>
3415 <h1 class="adm-flags-title">
3416 <span class="adm-flags-title-grad">Site flags</span>.
3417 </h1>
3418 <p class="adm-flags-sub">
3419 Runtime feature flags surfaced to the rest of the app via{" "}
3420 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3421 </p>
3422 </div>
3423 <a href="/admin" class="adm-flags-back">
07f4b70Claude3424 {Icons.arrowLeft} Back
3425 </a>
3426 </div>
3427 </section>
3428
8929744Claude3429 <form method="post" action="/admin/flags" class="adm-flags-card">
3430 <div class="adm-flags-card-body">
07f4b70Claude3431 {keys.map((k) => {
3432 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3433 const isOverridden =
3434 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3435 return (
8929744Claude3436 <div class="adm-flags-field">
3437 <div class="adm-flags-field-head">
3438 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3439 {isOverridden && (
3440 <span class="adm-flags-mono">overridden</span>
3441 )}
3442 </div>
07f4b70Claude3443 <input
8929744Claude3444 id={`flag-${k}`}
07f4b70Claude3445 type="text"
3446 name={k}
3447 value={current}
3448 aria-label={k}
8929744Claude3449 class="adm-flags-input"
07f4b70Claude3450 />
8929744Claude3451 <div class="adm-flags-hint">
07f4b70Claude3452 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3453 </div>
3454 </div>
3455 );
3456 })}
3457 </div>
8929744Claude3458 <div class="adm-flags-card-foot">
3459 <span class="adm-flags-foot-hint">
07f4b70Claude3460 Saved values overwrite the defaults at runtime.
3461 </span>
8929744Claude3462 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3463 Save changes
07f4b70Claude3464 </button>
3465 </div>
3466 </form>
8f50ed0Claude3467 </div>
07f4b70Claude3468 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3469 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3470 </Layout>
3471 );
3472});
3473
3474admin.post("/admin/flags", async (c) => {
3475 const g = await gate(c);
3476 if (g instanceof Response) return g;
3477 const { user } = g;
3478 const body = await c.req.parseBody();
3479 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3480 for (const k of keys) {
3481 const v = String(body[k] ?? "");
3482 await setFlag(k, v, user.id);
3483 }
3484 await audit({ userId: user.id, action: "admin.flags.save" });
3485 return c.redirect("/admin/flags");
3486});
3487
08420cdClaude3488// ----- Email digests (Block I7) -----
3489
3490admin.get("/admin/digests", async (c) => {
3491 const g = await gate(c);
3492 if (g instanceof Response) return g;
3493 const { user } = g;
3494
3495 const [optedRow] = await db
3496 .select({ n: sql<number>`count(*)::int` })
3497 .from(users)
3498 .where(eq(users.notifyEmailDigestWeekly, true));
3499 const opted = Number(optedRow?.n || 0);
3500
3501 const recentlySent = await db
3502 .select({
3503 id: users.id,
3504 username: users.username,
3505 lastDigestSentAt: users.lastDigestSentAt,
3506 })
3507 .from(users)
3508 .where(sql`${users.lastDigestSentAt} is not null`)
3509 .orderBy(desc(users.lastDigestSentAt))
3510 .limit(20);
3511
3512 const result = c.req.query("result");
3513 const error = c.req.query("error");
3514
3515 return c.html(
3516 <Layout title="Admin — Digests" user={user}>
8929744Claude3517 <div class="adm-digests-wrap">
3518 <section class="adm-digests-hero">
3519 <div class="adm-digests-hero-inner">
3520 <div class="adm-digests-hero-text">
3521 <div class="adm-digests-eyebrow">
3522 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3523 Site admin · Email
3524 </div>
3525 <h1 class="adm-digests-title">
3526 <span class="adm-digests-title-grad">Email digests</span>.
3527 </h1>
3528 <p class="adm-digests-sub">
3529 Manually trigger the weekly digest for every opted-in user
3530 or preview the email for a single account.
3531 </p>
3532 </div>
3533 <a href="/admin" class="adm-digests-back">
07f4b70Claude3534 {Icons.arrowLeft} Back
3535 </a>
3536 </div>
3537 </section>
08420cdClaude3538
07f4b70Claude3539 {result && (
8929744Claude3540 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3541 )}
3542 {error && (
8929744Claude3543 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3544 )}
08420cdClaude3545
8929744Claude3546 <div class="adm-digests-pills">
3547 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3548 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3549 </div>
3550
3551 <section class="adm-digests-section">
3552 <header class="adm-digests-section-head">
3553 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3554 <div>
3555 <h3 class="adm-digests-section-title">Send digests</h3>
3556 <p class="adm-digests-section-sub">
3557 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3558 </p>
08420cdClaude3559 </div>
8929744Claude3560 </header>
3561 <div class="adm-digests-section-body">
3562 <form method="post" action="/admin/digests/run">
07f4b70Claude3563 <button
3564 type="submit"
8929744Claude3565 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3566 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3567 >
8929744Claude3568 {Icons.mail}
07f4b70Claude3569 Send digests now
3570 </button>
3571 </form>
8929744Claude3572 <div class="adm-digests-section-divider">
3573 <div class="adm-digests-divider-hint">
07f4b70Claude3574 Preview / one-off — send the digest to a single user.
3575 </div>
3576 <form
3577 method="post"
3578 action="/admin/digests/preview"
8929744Claude3579 class="adm-digests-form-row"
07f4b70Claude3580 >
3581 <input
3582 type="text"
3583 name="username"
3584 placeholder="username"
3585 required
3586 aria-label="Username"
8929744Claude3587 class="adm-digests-input"
07f4b70Claude3588 />
8929744Claude3589 <button type="submit" class="adm-digests-btn">
07f4b70Claude3590 Send to one user
3591 </button>
3592 </form>
3593 </div>
3594 </div>
8929744Claude3595 </section>
07f4b70Claude3596
8929744Claude3597 <div class="adm-digests-h3">
07f4b70Claude3598 <h3>Recently sent</h3>
8929744Claude3599 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3600 </div>
8929744Claude3601 {recentlySent.length === 0 ? (
3602 <div class="adm-digests-empty">
3603 <div class="adm-digests-empty-orb" aria-hidden="true" />
3604 <div class="adm-digests-empty-inner">
3605 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3606 <div class="adm-digests-empty-title">No digests sent yet</div>
3607 <div class="adm-digests-empty-sub">
3608 When the weekly digest fires, sent recipients will appear here.
3609 </div>
3610 </div>
3611 </div>
3612 ) : (
3613 <div class="adm-digests-grid">
3614 {recentlySent.map((u) => (
3615 <div class="adm-digests-card">
3616 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3617 <div class="adm-digests-card-text">
3618 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3619 <div class="adm-digests-card-sent">
3620 sent {u.lastDigestSentAt
3621 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3622 : "—"}
07f4b70Claude3623 </div>
3624 </div>
3625 </div>
8929744Claude3626 ))}
3627 </div>
3628 )}
08420cdClaude3629 </div>
07f4b70Claude3630 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3631 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3632 </Layout>
3633 );
3634});
3635
3636admin.post("/admin/digests/run", async (c) => {
3637 const g = await gate(c);
3638 if (g instanceof Response) return g;
3639 const { user } = g;
3640 const results = await sendDigestsToAll();
3641 const sent = results.filter((r) => r.ok).length;
3642 const skipped = results.length - sent;
3643 await audit({
3644 userId: user.id,
3645 action: "admin.digests.run",
3646 metadata: { sent, skipped, total: results.length },
3647 });
3648 return c.redirect(
3649 `/admin/digests?result=${encodeURIComponent(
3650 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3651 )}`
3652 );
3653});
3654
3655admin.post("/admin/digests/preview", async (c) => {
3656 const g = await gate(c);
3657 if (g instanceof Response) return g;
3658 const { user } = g;
3659 const body = await c.req.parseBody();
3660 const username = String(body.username || "").trim();
3661 if (!username) {
3662 return c.redirect("/admin/digests?error=Username+required");
3663 }
3664 const [target] = await db
3665 .select({ id: users.id, username: users.username })
3666 .from(users)
3667 .where(eq(users.username, username))
3668 .limit(1);
3669 if (!target) {
3670 return c.redirect("/admin/digests?error=User+not+found");
3671 }
3672 const result = await sendDigestForUser(target.id);
3673 await audit({
3674 userId: user.id,
3675 action: "admin.digests.preview",
3676 targetType: "user",
3677 targetId: target.id,
3678 metadata: {
3679 ok: result.ok,
3680 skipped: "skipped" in result ? result.skipped : null,
3681 },
3682 });
3683 if (result.ok) {
3684 return c.redirect(
3685 `/admin/digests?result=${encodeURIComponent(
3686 `Digest sent to ${target.username}.`
3687 )}`
3688 );
3689 }
3690 return c.redirect(
3691 `/admin/digests?error=${encodeURIComponent(
3692 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3693 )}`
3694 );
3695});
3696
b5dd694Claude3697const AUTOPILOT_TASK_CATALOG = [
3698 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3699 { name: "merge-queue", desc: "Advance serialised merge queues" },
3700 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3701 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3702 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3703 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3704 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3705 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3706 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3707 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
f65f600Claude3708 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
b5dd694Claude3709] as const;
3710
8e9f1d9Claude3711admin.get("/admin/autopilot", async (c) => {
3712 const g = await gate(c);
3713 if (g instanceof Response) return g;
3714 const { user } = g;
3715 const tick = getLastTick();
3716 const total = getTickCount();
3717 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3718 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3719 const intervalMs =
3720 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3721 ? Number(intervalRaw)
3722 : 5 * 60 * 1000;
3723 const msg = c.req.query("result") || c.req.query("error");
3724 const isErr = !!c.req.query("error");
3725 return c.html(
3726 <Layout title="Autopilot — admin" user={user}>
8929744Claude3727 <div class="adm-autopilot-wrap">
3728 <section class="adm-autopilot-hero">
3729 <div class="adm-autopilot-hero-inner">
3730 <div class="adm-autopilot-hero-text">
3731 <div class="adm-autopilot-eyebrow">
3732 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3733 Site admin · Maintenance loop
3734 </div>
3735 <h1 class="adm-autopilot-title">
3736 <span class="adm-autopilot-title-grad">Autopilot</span>.
3737 </h1>
3738 <p class="adm-autopilot-sub">
3739 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3740 progress, weekly digests, advisory rescans, wait-timer release,
3741 scheduled workflows (cron), AI-gated auto-merge sweep, and
3742 ai:build issue → PR dispatch.
8929744Claude3743 </p>
3744 </div>
3745 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3746 {Icons.arrowLeft} Back
3747 </a>
3748 </div>
3749 </section>
3750
8e9f1d9Claude3751 {msg && (
8929744Claude3752 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3753 {decodeURIComponent(msg)}
3754 </div>
3755 )}
07f4b70Claude3756
8929744Claude3757 <div class="adm-autopilot-statgrid">
3758 <div class="adm-autopilot-stat">
3759 <div class="adm-autopilot-stat-head">
3760 <span class="adm-autopilot-stat-label">Status</span>
3761 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3762 <span class="dot" aria-hidden="true" />
3763 {disabled ? "disabled" : "running"}
3764 </span>
3765 </div>
8929744Claude3766 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3767 {disabled ? "disabled" : "running"}
3768 </div>
8929744Claude3769 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3770 </div>
8929744Claude3771 <div class="adm-autopilot-stat">
3772 <div class="adm-autopilot-stat-head">
3773 <span class="adm-autopilot-stat-label">Interval</span>
3774 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3775 </div>
8929744Claude3776 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3777 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3778 </div>
8929744Claude3779 <div class="adm-autopilot-stat">
3780 <div class="adm-autopilot-stat-head">
3781 <span class="adm-autopilot-stat-label">Ticks this process</span>
3782 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3783 </div>
8929744Claude3784 <div class="adm-autopilot-stat-value">{total}</div>
3785 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3786 </div>
8929744Claude3787 <div class="adm-autopilot-stat">
3788 <div class="adm-autopilot-stat-head">
3789 <span class="adm-autopilot-stat-label">Last tick</span>
3790 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3791 </div>
8929744Claude3792 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3793 {tick ? tick.finishedAt : "never"}
3794 </div>
3795 </div>
3796 </div>
07f4b70Claude3797
8929744Claude3798 <div class="adm-autopilot-actions">
3799 <form method="post" action="/admin/autopilot/run">
3800 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3801 {Icons.bot}
3802 Run tick now
3803 </button>
3804 </form>
3805 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3806 Executes all sub-tasks synchronously and records the result.
3807 </span>
8929744Claude3808 </div>
07f4b70Claude3809
8929744Claude3810 <div class="adm-autopilot-h3">
07f4b70Claude3811 <h3>Last tick tasks</h3>
3812 {tick && (
8929744Claude3813 <span class="adm-autopilot-h3-meta">
07f4b70Claude3814 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3815 </span>
3816 )}
3817 </div>
8e9f1d9Claude3818 {tick ? (
8929744Claude3819 <div class="adm-autopilot-tasks">
3820 {tick.tasks.map((t) => (
3821 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3822 <div class="adm-autopilot-task-head">
3823 <span
3824 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3825 aria-label={t.ok ? "ok" : "failed"}
3826 />
3827 <span class="adm-autopilot-task-name">{t.name}</span>
3828 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3829 {t.ok ? "ok" : "failed"}
8929744Claude3830 </span>
3831 </div>
3832 <div class="adm-autopilot-task-meta">
3833 <span>duration</span>
3834 <span>{t.durationMs}ms</span>
3835 </div>
3836 {t.error && (
3837 <div class="adm-autopilot-task-err">{t.error}</div>
3838 )}
3839 </div>
3840 ))}
3841 </div>
8e9f1d9Claude3842 ) : (
8929744Claude3843 <div class="adm-autopilot-empty">
3844 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3845 <div class="adm-autopilot-empty-inner">
3846 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3847 <div class="adm-autopilot-empty-title">No ticks yet</div>
3848 <div class="adm-autopilot-empty-sub">
3849 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3850 </div>
3851 </div>
07f4b70Claude3852 </div>
8e9f1d9Claude3853 )}
b5dd694Claude3854 <div class="adm-autopilot-h3" style="margin-top:28px">
3855 <h3>Configured tasks</h3>
44ed968Claude3856 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3857 </div>
3858 <div class="adm-autopilot-tasks">
3859 {AUTOPILOT_TASK_CATALOG.map((t) => {
3860 const last = tick?.tasks.find((r) => r.name === t.name);
3861 return (
3862 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3863 <div class="adm-autopilot-task-head">
3864 <span
3865 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3866 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3867 />
3868 <span class="adm-autopilot-task-name">{t.name}</span>
3869 {last && (
3870 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3871 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3872 </span>
3873 )}
3874 </div>
3875 <div class="adm-autopilot-task-meta">
3876 <span>{t.desc}</span>
3877 </div>
3878 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3879 </div>
3880 );
3881 })}
3882 </div>
3883
8929744Claude3884 <p class="adm-autopilot-foot">
8e9f1d9Claude3885 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3886 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3887 </p>
3888 </div>
07f4b70Claude3889 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3890 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3891 </Layout>
3892 );
3893});
3894
988380aClaude3895admin.post("/admin/demo/reseed", async (c) => {
3896 const g = await gate(c);
3897 if (g instanceof Response) return g;
3898 const { user } = g;
3899 try {
3900 const result = await ensureDemoContent({ force: true });
3901 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}` : ""}`;
3902 await audit({
3903 userId: user.id,
3904 action: "admin.demo.reseed",
3905 targetType: "user",
3906 targetId: result.demoUser?.id ?? "demo",
3907 metadata: {
3908 createdUser: result.created.user,
3909 createdRepos: result.created.repos,
3910 createdIssues: result.created.issues,
3911 createdPrs: result.created.prs,
3912 errors: result.errors.slice(0, 5),
3913 },
3914 });
3915 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3916 } catch (err) {
3917 const message = err instanceof Error ? err.message : String(err);
3918 return c.redirect(
3919 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3920 );
3921 }
3922});
3923
3924// Public jump-to-demo — redirects to the first demo repo if present,
3925// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3926admin.get("/demo", (c) => {
3927 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3928});
3929
8e9f1d9Claude3930admin.post("/admin/autopilot/run", async (c) => {
3931 const g = await gate(c);
3932 if (g instanceof Response) return g;
3933 const { user } = g;
3934 let summary = "";
3935 try {
3936 const result = await runAutopilotTick();
3937 const ok = result.tasks.filter((t) => t.ok).length;
3938 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3939 await audit({
3940 userId: user.id,
3941 action: "admin.autopilot.run",
3942 targetType: "system",
3943 targetId: "autopilot",
3944 metadata: { ok, total: result.tasks.length },
3945 });
3946 return c.redirect(
3947 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3948 );
3949 } catch (err) {
3950 const message = err instanceof Error ? err.message : String(err);
3951 return c.redirect(
3952 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3953 );
3954 }
3955});
3956
b218e63Claude3957// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
3958
3959admin.get("/admin/ai-costs", async (c) => {
3960 const g = await gate(c);
3961 if (g instanceof Response) return g;
3962 const { user } = g;
3963
3964 // Start of current month (UTC)
3965 const now = new Date();
3966 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
3967
3968 // Total spend this month
3969 const [totalRow] = await db
3970 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
3971 .from(aiCostEvents)
3972 .where(gte(aiCostEvents.occurredAt, monthStart));
3973 const totalCents = Number(totalRow?.total ?? 0);
3974
3975 // Breakdown by category
3976 const byCategory = await db
3977 .select({
3978 category: aiCostEvents.category,
3979 cents: sql<number>`sum(cents_estimate)::int`,
3980 calls: sql<number>`count(*)::int`,
3981 })
3982 .from(aiCostEvents)
3983 .where(gte(aiCostEvents.occurredAt, monthStart))
3984 .groupBy(aiCostEvents.category)
3985 .orderBy(sql`sum(cents_estimate) desc`);
3986
3987 // Top 10 spenders (join users)
3988 const topSpenders = await db
3989 .select({
3990 username: users.username,
3991 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
3992 calls: sql<number>`count(*)::int`,
3993 })
3994 .from(aiCostEvents)
3995 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
3996 .where(gte(aiCostEvents.occurredAt, monthStart))
3997 .groupBy(users.username)
3998 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
3999 .limit(10);
4000
4001 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
4002 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
4003
4004 function fmtCents(c: number): string {
4005 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
4006 return `${c}¢`;
4007 }
4008
4009 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
4010
4011 return c.html(
4012 <Layout title="Admin — AI Costs" user={user}>
4013 <div class="adm-analytics-wrap">
4014 <section class="adm-analytics-hero">
4015 <div class="adm-analytics-hero-inner">
4016 <div class="adm-analytics-hero-text">
4017 <div class="adm-analytics-eyebrow">
4018 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
4019 Site admin · Analytics
4020 </div>
4021 <h1 class="adm-analytics-title">
4022 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
4023 </h1>
4024 <p class="adm-analytics-sub">
4025 Per-call AI spend for {monthName} — by feature category and top spenders.
4026 </p>
4027 </div>
4028 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4029 </div>
4030 </section>
4031
4032 <div class="adm-analytics-statgrid">
4033 <div class="adm-analytics-stat">
4034 <div class="adm-analytics-stat-label">Total spend this month</div>
4035 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
4036 <div class="adm-analytics-stat-hint">{monthName}</div>
4037 </div>
4038 <div class="adm-analytics-stat">
4039 <div class="adm-analytics-stat-label">Categories active</div>
4040 <div class="adm-analytics-stat-value">{byCategory.length}</div>
4041 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
4042 </div>
4043 <div class="adm-analytics-stat">
4044 <div class="adm-analytics-stat-label">Total AI calls</div>
4045 <div class="adm-analytics-stat-value">
4046 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
4047 </div>
4048 <div class="adm-analytics-stat-hint">recorded events</div>
4049 </div>
4050 </div>
4051
4052 <div class="adm-analytics-h3">
4053 <h3>By category</h3>
4054 <span class="adm-analytics-h3-meta">{monthName}</span>
4055 </div>
4056 {byCategory.length === 0 ? (
4057 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
4058 ) : (
4059 <table class="adm-analytics-table">
4060 <thead>
4061 <tr>
4062 <th>Category</th>
4063 <th>Spend</th>
4064 <th>Calls</th>
4065 <th style="width:200px">Distribution</th>
4066 </tr>
4067 </thead>
4068 <tbody>
4069 {byCategory.map((row) => {
4070 const pct = Math.round((Number(row.cents) / maxCents) * 100);
4071 return (
4072 <tr>
4073 <td><code>{row.category ?? "other"}</code></td>
4074 <td>{fmtCents(Number(row.cents))}</td>
4075 <td>{Number(row.calls).toLocaleString()}</td>
4076 <td>
4077 <div class="adm-analytics-bar-cell">
4078 <div class="adm-analytics-bar-track">
4079 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4080 </div>
4081 <span class="adm-analytics-bar-label">{pct}%</span>
4082 </div>
4083 </td>
4084 </tr>
4085 );
4086 })}
4087 </tbody>
4088 </table>
4089 )}
4090
4091 <div class="adm-analytics-h3">
4092 <h3>Top 10 spenders</h3>
4093 <span class="adm-analytics-h3-meta">{monthName}</span>
4094 </div>
4095 {topSpenders.length === 0 ? (
4096 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4097 ) : (
4098 <table class="adm-analytics-table">
4099 <thead>
4100 <tr>
4101 <th>User</th>
4102 <th>Spend</th>
4103 <th>Calls</th>
4104 <th style="width:200px">Share</th>
4105 </tr>
4106 </thead>
4107 <tbody>
4108 {topSpenders.map((row, i) => {
4109 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4110 return (
4111 <tr>
4112 <td>
4113 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4114 #{i + 1} {row.username}
4115 </a>
4116 </td>
4117 <td>{fmtCents(Number(row.cents))}</td>
4118 <td>{Number(row.calls).toLocaleString()}</td>
4119 <td>
4120 <div class="adm-analytics-bar-cell">
4121 <div class="adm-analytics-bar-track">
4122 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4123 </div>
4124 <span class="adm-analytics-bar-label">{pct}%</span>
4125 </div>
4126 </td>
4127 </tr>
4128 );
4129 })}
4130 </tbody>
4131 </table>
4132 )}
4133 </div>
4134 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4135 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4136 </Layout>
4137 );
4138});
4139
4140// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4141
4142const ALL_AUTOPILOT_TASKS = [
4143 "mirror-sync",
4144 "merge-queue",
4145 "weekly-digest",
4146 "advisory-rescan",
4147 "wait-timer-release",
4148 "scheduled-workflows",
4149 "auto-merge-sweep",
4150 "ai-build-from-issues",
4151 "sleep-mode-digest",
4152 "stale-pr-sweep",
4153] as const;
4154
4155admin.get("/admin/autopilot/health", async (c) => {
4156 const g = await gate(c);
4157 if (g instanceof Response) return g;
4158 const { user } = g;
4159
4160 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4161
4162 // Pull the last 200 audit log rows that match autopilot task patterns
4163 const logRows = await db
4164 .select({
4165 action: auditLog.action,
4166 createdAt: auditLog.createdAt,
4167 metadata: auditLog.metadata,
4168 })
4169 .from(auditLog)
4170 .where(gte(auditLog.createdAt, since24h))
4171 .orderBy(desc(auditLog.createdAt))
4172 .limit(500);
4173
4174 // Build per-task health from the last autopilot tick (in-memory)
4175 const tick = getLastTick();
4176
4177 type TaskHealth = {
4178 name: string;
4179 lastRun: string | null;
4180 lastDurationMs: number | null;
4181 lastOk: boolean | null;
4182 lastError: string | null;
4183 successCount24h: number;
4184 errorCount24h: number;
4185 };
4186
4187 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4188 // Count audit events in last 24h that look like this task
4189 const successCount24h = logRows.filter(
4190 (r) => r.action === `autopilot.task.ok.${taskName}`
4191 ).length;
4192 const errorCount24h = logRows.filter(
4193 (r) => r.action === `autopilot.task.error.${taskName}`
4194 ).length;
4195
4196 // Get last tick result for this task (in-memory)
4197 const last = tick?.tasks.find((t) => t.name === taskName);
4198
4199 return {
4200 name: taskName,
4201 lastRun: tick?.finishedAt ?? null,
4202 lastDurationMs: last?.durationMs ?? null,
4203 lastOk: last?.ok ?? null,
4204 lastError: last?.error ?? null,
4205 successCount24h,
4206 errorCount24h,
4207 };
4208 });
4209
4210 const total = getTickCount();
4211 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4212
4213 return c.html(
4214 <Layout title="Autopilot Health — admin" user={user}>
4215 <div class="adm-analytics-wrap">
4216 <section class="adm-analytics-hero">
4217 <div class="adm-analytics-hero-inner">
4218 <div class="adm-analytics-hero-text">
4219 <div class="adm-analytics-eyebrow">
4220 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4221 Site admin · Autopilot
4222 </div>
4223 <h1 class="adm-analytics-title">
4224 <span class="adm-analytics-title-grad">Autopilot health</span>.
4225 </h1>
4226 <p class="adm-analytics-sub">
4227 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4228 </p>
4229 </div>
4230 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4231 </div>
4232 </section>
4233
4234 <div class="adm-analytics-statgrid">
4235 <div class="adm-analytics-stat">
4236 <div class="adm-analytics-stat-label">Status</div>
4237 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4238 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4239 </div>
4240 <div class="adm-analytics-stat">
4241 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4242 <div class="adm-analytics-stat-value">{total}</div>
4243 <div class="adm-analytics-stat-hint">since boot</div>
4244 </div>
4245 <div class="adm-analytics-stat">
4246 <div class="adm-analytics-stat-label">Last tick</div>
4247 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4248 {tick?.finishedAt ?? "—"}
4249 </div>
4250 </div>
4251 <div class="adm-analytics-stat">
4252 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4253 <div class="adm-analytics-stat-value">
4254 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4255 </div>
4256 </div>
4257 </div>
4258
4259 <div class="adm-analytics-h3">
4260 <h3>Per-task health</h3>
4261 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4262 </div>
4263 <table class="adm-analytics-table">
4264 <thead>
4265 <tr>
4266 <th>Task</th>
4267 <th>Last status</th>
4268 <th>Duration (last)</th>
4269 <th>OK (24h)</th>
4270 <th>Errors (24h)</th>
4271 </tr>
4272 </thead>
4273 <tbody>
4274 {health.map((t) => (
4275 <tr>
4276 <td><code>{t.name}</code></td>
4277 <td>
4278 {t.lastOk === null ? (
4279 <span style="color:var(--text-muted)">—</span>
4280 ) : t.lastOk ? (
4281 <span class="adm-analytics-pill is-ok">ok</span>
4282 ) : (
4283 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4284 )}
4285 </td>
4286 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4287 <td>
4288 {t.successCount24h > 0 ? (
4289 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4290 ) : (
4291 <span style="color:var(--text-muted)">—</span>
4292 )}
4293 </td>
4294 <td>
4295 {t.errorCount24h > 0 ? (
4296 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4297 ) : (
4298 <span style="color:var(--text-muted)">0</span>
4299 )}
4300 </td>
4301 </tr>
4302 ))}
4303 </tbody>
4304 </table>
4305
4306 {tick?.tasks.some((t) => !t.ok && t.error) && (
4307 <>
4308 <div class="adm-analytics-h3" style="margin-top:28px">
4309 <h3>Last-tick errors</h3>
4310 </div>
4311 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4312 <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">
4313 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4314 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4315 </div>
4316 ))}
4317 </>
4318 )}
4319
4320 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4321 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).
4322 </p>
4323 </div>
4324 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4325 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4326 </Layout>
4327 );
4328});
4329
4330// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4331
4332admin.get("/admin/growth", async (c) => {
4333 const g = await gate(c);
4334 if (g instanceof Response) return g;
4335 const { user } = g;
4336
4337 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4338
4339 // Daily signups last 30 days
4340 const dailySignups = await db
4341 .select({
4342 day: sql<string>`date_trunc('day', created_at)::date::text`,
4343 count: sql<number>`count(*)::int`,
4344 })
4345 .from(users)
4346 .where(gte(users.createdAt, since30d))
4347 .groupBy(sql`date_trunc('day', created_at)`)
4348 .orderBy(sql`date_trunc('day', created_at)`);
4349
4350 // Activation rate: users who created at least 1 repo (last 30d signups)
4351 const activatedRows = await db
4352 .select({
4353 userId: users.id,
4354 })
4355 .from(users)
4356 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4357 .where(gte(users.createdAt, since30d))
4358 .groupBy(users.id);
4359
4360 // Total signups in window
4361 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4362 const activated = activatedRows.length;
4363 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4364
4365 // All-time user count
4366 const [allTimeRow] = await db
4367 .select({ n: sql<number>`count(*)::int` })
4368 .from(users);
4369 const allTime = Number(allTimeRow?.n ?? 0);
4370
4371 // Build a 30-slot array (fill missing days with 0)
4372 const dayMap = new Map<string, number>();
4373 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4374
4375 const slots: { label: string; count: number }[] = [];
4376 for (let i = 29; i >= 0; i--) {
4377 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4378 const key = d.toISOString().slice(0, 10);
4379 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4380 slots.push({ label, count: dayMap.get(key) ?? 0 });
4381 }
4382
4383 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4384
4385 return c.html(
4386 <Layout title="Admin — User Growth" user={user}>
4387 <div class="adm-analytics-wrap">
4388 <section class="adm-analytics-hero">
4389 <div class="adm-analytics-hero-inner">
4390 <div class="adm-analytics-hero-text">
4391 <div class="adm-analytics-eyebrow">
4392 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4393 Site admin · Analytics
4394 </div>
4395 <h1 class="adm-analytics-title">
4396 <span class="adm-analytics-title-grad">User growth</span>.
4397 </h1>
4398 <p class="adm-analytics-sub">
4399 Daily signups and activation rate over the last 30 days.
4400 </p>
4401 </div>
4402 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4403 </div>
4404 </section>
4405
4406 <div class="adm-analytics-statgrid">
4407 <div class="adm-analytics-stat">
4408 <div class="adm-analytics-stat-label">Total users</div>
4409 <div class="adm-analytics-stat-value">{allTime}</div>
4410 <div class="adm-analytics-stat-hint">all time</div>
4411 </div>
4412 <div class="adm-analytics-stat">
4413 <div class="adm-analytics-stat-label">New signups (30d)</div>
4414 <div class="adm-analytics-stat-value">{totalSignups}</div>
4415 <div class="adm-analytics-stat-hint">last 30 days</div>
4416 </div>
4417 <div class="adm-analytics-stat">
4418 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4419 <div class="adm-analytics-stat-value">{activationRate}%</div>
4420 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4421 </div>
4422 <div class="adm-analytics-stat">
4423 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4424 <div class="adm-analytics-stat-value">{activated}</div>
4425 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4426 </div>
4427 </div>
4428
4429 <div class="adm-analytics-h3">
4430 <h3>Daily signups — last 30 days</h3>
4431 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4432 </div>
4433
4434 {totalSignups === 0 ? (
4435 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4436 ) : (
4437 <table class="adm-analytics-table">
4438 <thead>
4439 <tr>
4440 <th>Date</th>
4441 <th>Signups</th>
4442 <th style="width:280px">Bar</th>
4443 </tr>
4444 </thead>
4445 <tbody>
4446 {slots.filter((s) => s.count > 0 || true).map((s) => {
4447 const pct = Math.round((s.count / maxCount) * 100);
4448 return (
4449 <tr>
4450 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4451 <td>{s.count}</td>
4452 <td>
4453 {s.count > 0 ? (
4454 <div class="adm-analytics-bar-cell">
4455 <div class="adm-analytics-bar-track">
4456 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4457 </div>
4458 <span class="adm-analytics-bar-label">{s.count}</span>
4459 </div>
4460 ) : (
4461 <span style="color:var(--text-faint);font-size:11px">—</span>
4462 )}
4463 </td>
4464 </tr>
4465 );
4466 })}
4467 </tbody>
4468 </table>
4469 )}
4470
4471 <div class="adm-analytics-h3" style="margin-top:28px">
4472 <h3>Activation breakdown</h3>
4473 <span class="adm-analytics-h3-meta">30d cohort</span>
4474 </div>
4475 <table class="adm-analytics-table" style="max-width:500px">
4476 <thead>
4477 <tr>
4478 <th>Segment</th>
4479 <th>Count</th>
4480 <th style="width:200px">Rate</th>
4481 </tr>
4482 </thead>
4483 <tbody>
4484 <tr>
4485 <td>New signups (30d)</td>
4486 <td>{totalSignups}</td>
4487 <td>100%</td>
4488 </tr>
4489 <tr>
4490 <td>Activated (created ≥1 repo)</td>
4491 <td>{activated}</td>
4492 <td>
4493 <div class="adm-analytics-bar-cell">
4494 <div class="adm-analytics-bar-track">
4495 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4496 </div>
4497 <span class="adm-analytics-bar-label">{activationRate}%</span>
4498 </div>
4499 </td>
4500 </tr>
4501 <tr>
4502 <td>Not activated</td>
4503 <td>{totalSignups - activated}</td>
4504 <td>{100 - activationRate}%</td>
4505 </tr>
4506 </tbody>
4507 </table>
4508 </div>
4509 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4510 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4511 </Layout>
4512 );
4513});
4514
8f50ed0Claude4515export default admin;