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.tsxBlame4485 lines · 2 contributors
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);
8ed88f2ccantynz-alt2759 const openPrCount = Number(prc?.n || 0);
2760 const openIssueCount = Number(ic?.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
4da1a3cccantynz-alt2835 {/* Featured: live ops view */}
2836 <a href="/admin/command" class="admin-action is-primary" style="grid-column:1/-1;display:flex;background:var(--accent-gradient);color:#fff;border-color:transparent;margin-bottom:var(--space-5)" title="Unified live ops view — gates, deploys, AI, auth anomalies, audit events">
2837 <span class="admin-action-icon">⚡</span>
2838 Command Center — live ops view
2839 </a>
2840
2841 {/* Grouped admin surface — one labeled section per concern, deduped.
2842 Was a flat ~25-link dump plus a duplicate "System Health" grid. */}
2843 {[
2844 {
2845 title: "Operations & health",
2846 items: [
2847 { href: "/admin/ops", icon: Icons.ops, label: "Operations" },
2848 { href: "/admin/health", icon: Icons.pulse, label: "Health (traffic lights)" },
2849 { href: "/admin/env-health", icon: Icons.pulse, label: "Env health" },
2850 { href: "/admin/status", icon: Icons.pulse, label: "Platform monitor" },
2851 { href: "/admin/deploys", icon: Icons.ops, label: "Deploys" },
2852 { href: "/admin/self-host", icon: Icons.ops, label: "Self-host status" },
2853 { href: "/admin/diagnose", icon: Icons.bot, label: "AI background tasks" },
2854 ],
2855 },
2856 {
2857 title: "Users & access",
2858 items: [
2859 { href: "/admin/users", icon: Icons.users, label: "Manage users" },
2860 { href: "/admin/repos", icon: Icons.repo, label: "Manage repos" },
2861 { href: "/admin/security", icon: Icons.starShield, label: "Security" },
2862 { href: "/admin/deletions", icon: Icons.flag, label: "GDPR deletions" },
2863 ],
2864 },
2865 {
2866 title: "Sign-in & SSO",
2867 items: [
2868 { href: "/admin/google-oauth", icon: Icons.google, label: "Sign in with Google" },
2869 { href: "/admin/github-oauth", icon: Icons.github, label: "Sign in with GitHub" },
2870 { href: "/admin/sso", icon: Icons.sso, label: "Enterprise SSO" },
2871 ],
2872 },
2873 {
2874 title: "AI & automation",
2875 items: [
2876 { href: "/admin/autopilot", icon: Icons.bot, label: "Autopilot" },
2877 { href: "/admin/ai-costs", icon: Icons.dollarSign, label: "AI cost breakdown" },
2878 { href: "/connect/claude", icon: Icons.bot, label: "Connect Claude" },
2879 ],
2880 },
2881 {
2882 title: "Growth & billing",
2883 items: [
2884 { href: "/admin/growth", icon: Icons.trendingUp, label: "User growth" },
2885 { href: "/admin/stripe", icon: Icons.key, label: "Stripe sync" },
2886 { href: "/admin/digests", icon: Icons.mail, label: "Email digests" },
2887 ],
2888 },
2889 {
2890 title: "Platform config",
2891 items: [
2892 { href: "/admin/flags", icon: Icons.flag, label: "Site flags" },
2893 { href: "/admin/integrations", icon: Icons.key, label: "Integrations" },
2894 { href: "/admin/advancement", icon: Icons.trendingUp, label: "Advancement" },
2895 ],
2896 },
2897 ].map((group) => (
2898 <>
2899 <div class="admin-h3">
2900 <h3>{group.title}</h3>
2901 </div>
2902 <div class="admin-actions" style="margin-bottom:var(--space-5)">
2903 {group.items.map((it) => (
2904 <a href={it.href} class="admin-action">
2905 <span class="admin-action-icon">{it.icon}</span>
2906 {it.label}
2907 </a>
2908 ))}
2909 </div>
2910 </>
2911 ))}
8f50ed0Claude2912
4da1a3cccantynz-alt2913 {/* Maintenance — destructive/idempotent one-offs */}
6dec21bClaude2914 <div class="admin-h3">
4da1a3cccantynz-alt2915 <h3>Maintenance</h3>
6dec21bClaude2916 </div>
2917 <div class="admin-actions" style="margin-bottom:var(--space-5)">
4da1a3cccantynz-alt2918 <form method="post" action="/admin/demo/reseed" class="admin-action-form">
2919 <button class="admin-action" type="submit" title="Idempotently (re)create demo user + 3 sample repos">
2920 <span class="admin-action-icon">{Icons.refresh}</span>
2921 Reseed demo
2922 </button>
2923 </form>
6dec21bClaude2924 </div>
4da1a3cccantynz-alt2925<div class="admin-automation-tile">
6dec21bClaude2926 <div class="admin-automation-heading">
2927 <span class="admin-automation-heading-icon" aria-hidden="true">{Icons.bot}</span>
2928 <span class="admin-automation-heading-label">Automation Activity</span>
2929 <span class="admin-automation-heading-sub">last 24h</span>
2930 </div>
2931 <div class="admin-automation-grid">
2932 <div class="admin-automation-stat">
2933 <div class="admin-automation-stat-label">AI Reviews</div>
2934 <div class="admin-automation-stat-value">{aiReviewsCount}</div>
2935 <div class="admin-automation-stat-hint">PR comments posted</div>
2936 </div>
2937 <div class="admin-automation-stat">
2938 <div class="admin-automation-stat-label">Auto-merges</div>
2939 <div class="admin-automation-stat-value">{autoMergesCount}</div>
2940 <div class="admin-automation-stat-hint">PRs auto-merged</div>
2941 </div>
2942 <div class="admin-automation-stat">
2943 <div class="admin-automation-stat-label">Issues Triaged</div>
2944 <div class="admin-automation-stat-value">{issueTriagedCount}</div>
2945 <div class="admin-automation-stat-hint">AI triage comments</div>
2946 </div>
2947 <div class="admin-automation-stat">
2948 <div class="admin-automation-stat-label">CI Autofixes</div>
2949 <div class="admin-automation-stat-value">{ciAutofixCount}</div>
2950 <div class="admin-automation-stat-hint">Autofix runs</div>
2951 </div>
2952 </div>
2953 </div>
2954
07f4b70Claude2955 <div class="admin-h3">
2956 <h3>Recent signups</h3>
2957 <span class="admin-h3-meta">
2958 {recent.length} most-recent
2959 </span>
2960 </div>
2961 <div class="admin-list" style="margin-bottom:20px">
2962 {recent.length === 0 ? (
2963 <div class="admin-list-empty">No users yet.</div>
2964 ) : (
2965 recent.map((u) => (
2966 <div class="admin-list-row">
2967 <div class="admin-list-main">
2968 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
2969 <div class="admin-row-text">
2970 <a href={`/${u.username}`} class="admin-row-title">
2971 {u.username}
2972 </a>
2973 <div class="admin-row-sub">
2974 <span>Joined</span>
2975 <span>
2976 {u.createdAt
2977 ? new Date(u.createdAt as unknown as string).toLocaleString()
2978 : ""}
2979 </span>
2980 </div>
2981 </div>
2982 </div>
2983 </div>
2984 ))
2985 )}
2986 </div>
8f50ed0Claude2987
07f4b70Claude2988 <div class="admin-h3">
2989 <h3>Site admins</h3>
2990 <span class="admin-h3-meta">
2991 {adminCount} active
2992 </span>
2993 </div>
2994 <div class="admin-list">
2995 {admins.length === 0 ? (
2996 <div class="admin-list-empty">
2997 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude2998 </div>
07f4b70Claude2999 ) : (
3000 admins.map((a) => (
3001 <div class="admin-list-row">
3002 <div class="admin-list-main">
3003 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
3004 <div class="admin-row-text">
3005 <a href={`/${a.username}`} class="admin-row-title">
3006 {a.username}
3007 </a>
3008 <div class="admin-row-sub">
3009 <span class="admin-pill is-admin">
3010 <span class="dot" aria-hidden="true" /> Site admin
3011 </span>
3012 <span>
3013 Granted{" "}
3014 {a.grantedAt
3015 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
3016 : "—"}
3017 </span>
3018 </div>
3019 </div>
3020 </div>
3021 </div>
3022 ))
3023 )}
3024 </div>
8f50ed0Claude3025 </div>
07f4b70Claude3026 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude3027 </Layout>
3028 );
3029});
3030
3031// ----- Users -----
3032
3033admin.get("/admin/users", async (c) => {
3034 const g = await gate(c);
3035 if (g instanceof Response) return g;
3036 const { user } = g;
3037 const q = c.req.query("q") || "";
3038 const rows = await db
3039 .select({
3040 id: users.id,
3041 username: users.username,
3042 email: users.email,
3043 createdAt: users.createdAt,
3044 })
3045 .from(users)
3046 .where(
3047 q
3048 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
3049 : sql`1=1`
3050 )
3051 .orderBy(desc(users.createdAt))
3052 .limit(200);
3053
3054 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude3055 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude3056
3057 return c.html(
3058 <Layout title="Admin — Users" user={user}>
8929744Claude3059 <div class="adm-users-wrap">
3060 <section class="adm-users-hero">
3061 <div class="adm-users-hero-inner">
3062 <div class="adm-users-hero-text">
3063 <div class="adm-users-eyebrow">
3064 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
3065 Site admin · Users
3066 </div>
3067 <h1 class="adm-users-title">
3068 <span class="adm-users-title-grad">Users</span>.
3069 </h1>
3070 <p class="adm-users-sub">
3071 Search, audit, and grant or revoke the site-admin flag.
3072 Showing up to 200 accounts ordered by signup recency.
3073 </p>
3074 </div>
3075 <a href="/admin" class="adm-users-back">
07f4b70Claude3076 {Icons.arrowLeft} Back
3077 </a>
3078 </div>
3079 </section>
3080
8929744Claude3081 <div class="adm-users-filterbar">
3082 <form method="get" action="/admin/users" class="adm-users-search">
3083 <span class="adm-users-search-ico" aria-hidden="true">
3084 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
3085 <circle cx="11" cy="11" r="8" />
3086 <line x1="21" y1="21" x2="16.65" y2="16.65" />
3087 </svg>
3088 </span>
3089 <input
3090 type="text"
3091 name="q"
3092 value={q}
3093 placeholder="Search username or email"
3094 aria-label="Search username or email"
3095 class="adm-users-input"
3096 />
3097 <button type="submit" class="adm-users-btn">Search</button>
3098 {q && (
3099 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
3100 )}
3101 </form>
3102 <div class="adm-users-pills">
3103 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3104 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
3105 </div>
3106 </div>
07f4b70Claude3107
8929744Claude3108 {rows.length === 0 ? (
3109 <div class="adm-users-empty">
3110 <div class="adm-users-empty-orb" aria-hidden="true" />
3111 <div class="adm-users-empty-inner">
3112 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
3113 <div class="adm-users-empty-title">No users found</div>
3114 <div class="adm-users-empty-sub">
3115 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
3116 </div>
3117 </div>
3118 </div>
3119 ) : (
3120 <div class="adm-users-grid">
3121 {rows.map((u) => {
07f4b70Claude3122 const isAdmin = adminIds.has(u.id);
3123 return (
8929744Claude3124 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
3125 <div class="adm-users-card-head">
3126 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude3127 {initials(u.username)}
8f50ed0Claude3128 </span>
8929744Claude3129 <div class="adm-users-card-id">
3130 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
3131 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude3132 </div>
8929744Claude3133 {isAdmin && (
3134 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
3135 )}
3136 </div>
3137 <div class="adm-users-card-meta">
3138 <span class="adm-users-meta-item">
3139 <span class="adm-users-meta-key">Email</span>
3140 <span class="adm-users-meta-val">{u.email}</span>
3141 </span>
3142 {u.createdAt && (
3143 <span class="adm-users-meta-item">
3144 <span class="adm-users-meta-key">Joined</span>
3145 <span class="adm-users-meta-val">
3146 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
3147 </span>
3148 </span>
3149 )}
3150 </div>
3151 <div class="adm-users-card-actions">
3152 <form
3153 method="post"
3154 action={`/admin/users/${u.id}/admin`}
3155 onsubmit={
3156 isAdmin
3157 ? "return confirm('Revoke site admin?')"
3158 : "return confirm('Grant site admin?')"
3159 }
3160 >
3161 <button
3162 type="submit"
3163 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
3164 >
3165 {isAdmin ? "Revoke admin" : "Grant admin"}
3166 </button>
3167 </form>
3168 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
3169 View profile
3170 </a>
07f4b70Claude3171 </div>
8f50ed0Claude3172 </div>
07f4b70Claude3173 );
8929744Claude3174 })}
3175 </div>
3176 )}
8f50ed0Claude3177 </div>
07f4b70Claude3178 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3179 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude3180 </Layout>
3181 );
3182});
3183
3184admin.post("/admin/users/:id/admin", async (c) => {
3185 const g = await gate(c);
3186 if (g instanceof Response) return g;
3187 const { user } = g;
3188 const id = c.req.param("id");
3189 const admins = await listSiteAdmins();
3190 const isAlready = admins.some((a) => a.userId === id);
3191 if (isAlready) {
3192 await revokeSiteAdmin(id);
3193 await audit({
3194 userId: user.id,
3195 action: "site_admin.revoke",
3196 targetType: "user",
3197 targetId: id,
3198 });
3199 } else {
3200 await grantSiteAdmin(id, user.id);
3201 await audit({
3202 userId: user.id,
3203 action: "site_admin.grant",
3204 targetType: "user",
3205 targetId: id,
3206 });
3207 }
3208 return c.redirect("/admin/users");
3209});
3210
3211// ----- Repos -----
3212
3213admin.get("/admin/repos", async (c) => {
3214 const g = await gate(c);
3215 if (g instanceof Response) return g;
3216 const { user } = g;
3217 const rows = await db
3218 .select({
3219 id: repositories.id,
3220 name: repositories.name,
3221 ownerUsername: users.username,
0316dbbClaude3222 isPrivate: repositories.isPrivate,
8f50ed0Claude3223 createdAt: repositories.createdAt,
3224 starCount: repositories.starCount,
3225 })
3226 .from(repositories)
3227 .innerJoin(users, eq(repositories.ownerId, users.id))
3228 .orderBy(desc(repositories.createdAt))
3229 .limit(200);
3230
8929744Claude3231 const privateCount = rows.filter((r) => r.isPrivate).length;
3232 const publicCount = rows.length - privateCount;
3233
8f50ed0Claude3234 return c.html(
3235 <Layout title="Admin — Repos" user={user}>
8929744Claude3236 <div class="adm-repos-wrap">
3237 <section class="adm-repos-hero">
3238 <div class="adm-repos-hero-inner">
3239 <div class="adm-repos-hero-text">
3240 <div class="adm-repos-eyebrow">
3241 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3242 Site admin · Repositories
3243 </div>
3244 <h1 class="adm-repos-title">
3245 <span class="adm-repos-title-grad">Repositories</span>.
3246 </h1>
3247 <p class="adm-repos-sub">
3248 Every repository on the platform — public and private.
3249 Delete is irreversible and audit-logged.
3250 </p>
3251 </div>
3252 <a href="/admin" class="adm-repos-back">
07f4b70Claude3253 {Icons.arrowLeft} Back
3254 </a>
3255 </div>
3256 </section>
3257
8929744Claude3258 <div class="adm-repos-pills">
3259 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3260 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3261 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3262 </div>
3263
3264 {rows.length === 0 ? (
3265 <div class="adm-repos-empty">
3266 <div class="adm-repos-empty-orb" aria-hidden="true" />
3267 <div class="adm-repos-empty-inner">
3268 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3269 <div class="adm-repos-empty-title">No repositories yet</div>
3270 <div class="adm-repos-empty-sub">
3271 When users create their first repos, they'll appear here.
3272 </div>
3273 </div>
3274 </div>
3275 ) : (
3276 <div class="adm-repos-grid">
3277 {rows.map((r) => (
3278 <div class="adm-repos-card">
3279 <div class="adm-repos-card-head">
3280 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3281 <div class="adm-repos-card-title">
07f4b70Claude3282 <a
3283 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3284 class="adm-repos-card-name"
07f4b70Claude3285 >
3286 {r.ownerUsername}/{r.name}
3287 </a>
8929744Claude3288 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3289 </div>
8929744Claude3290 <span
3291 class={
3292 "adm-repos-pill " +
3293 (r.isPrivate ? "is-private" : "is-public")
3294 }
3295 style="margin-left:auto"
3296 >
3297 <span class="dot" aria-hidden="true" />
3298 {r.isPrivate ? "private" : "public"}
3299 </span>
3300 </div>
3301 <div class="adm-repos-card-meta">
3302 <span class="adm-repos-meta-item">
3303 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3304 <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" />
3305 </svg>
3306 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3307 </span>
3308 {r.createdAt && (
3309 <span class="adm-repos-meta-item">
3310 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3311 <rect x="3" y="4" width="18" height="18" rx="2" />
3312 <line x1="16" y1="2" x2="16" y2="6" />
3313 <line x1="8" y1="2" x2="8" y2="6" />
3314 <line x1="3" y1="10" x2="21" y2="10" />
3315 </svg>
3316 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3317 </span>
3318 )}
3319 </div>
3320 <div class="adm-repos-card-actions">
3321 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3322 Open repo
3323 </a>
3324 <form
3325 method="post"
3326 action={`/admin/repos/${r.id}/delete`}
3327 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3328 >
3329 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3330 Delete
3331 </button>
3332 </form>
8f50ed0Claude3333 </div>
3334 </div>
8929744Claude3335 ))}
3336 </div>
3337 )}
8f50ed0Claude3338 </div>
07f4b70Claude3339 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3340 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3341 </Layout>
3342 );
3343});
3344
3345admin.post("/admin/repos/:id/delete", async (c) => {
3346 const g = await gate(c);
3347 if (g instanceof Response) return g;
3348 const { user } = g;
3349 const id = c.req.param("id");
3350 try {
3351 await db.delete(repositories).where(eq(repositories.id, id));
3352 } catch (err) {
3353 console.error("[admin] repo delete:", err);
3354 }
3355 await audit({
3356 userId: user.id,
3357 action: "admin.repo.delete",
3358 targetType: "repository",
3359 targetId: id,
3360 });
3361 return c.redirect("/admin/repos");
3362});
3363
3364// ----- Flags -----
3365
3366admin.get("/admin/flags", async (c) => {
3367 const g = await gate(c);
3368 if (g instanceof Response) return g;
3369 const { user } = g;
3370
3371 const existing = await listFlags();
3372 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3373 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3374
3375 return c.html(
3376 <Layout title="Admin — Flags" user={user}>
8929744Claude3377 <div class="adm-flags-wrap">
3378 <section class="adm-flags-hero">
3379 <div class="adm-flags-hero-inner">
3380 <div class="adm-flags-hero-text">
3381 <div class="adm-flags-eyebrow">
3382 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3383 Site admin · Feature flags
3384 </div>
3385 <h1 class="adm-flags-title">
3386 <span class="adm-flags-title-grad">Site flags</span>.
3387 </h1>
3388 <p class="adm-flags-sub">
3389 Runtime feature flags surfaced to the rest of the app via{" "}
3390 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3391 </p>
3392 </div>
3393 <a href="/admin" class="adm-flags-back">
07f4b70Claude3394 {Icons.arrowLeft} Back
3395 </a>
3396 </div>
3397 </section>
3398
8929744Claude3399 <form method="post" action="/admin/flags" class="adm-flags-card">
3400 <div class="adm-flags-card-body">
07f4b70Claude3401 {keys.map((k) => {
3402 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3403 const isOverridden =
3404 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3405 return (
8929744Claude3406 <div class="adm-flags-field">
3407 <div class="adm-flags-field-head">
3408 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3409 {isOverridden && (
3410 <span class="adm-flags-mono">overridden</span>
3411 )}
3412 </div>
07f4b70Claude3413 <input
8929744Claude3414 id={`flag-${k}`}
07f4b70Claude3415 type="text"
3416 name={k}
3417 value={current}
3418 aria-label={k}
8929744Claude3419 class="adm-flags-input"
07f4b70Claude3420 />
8929744Claude3421 <div class="adm-flags-hint">
07f4b70Claude3422 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3423 </div>
3424 </div>
3425 );
3426 })}
3427 </div>
8929744Claude3428 <div class="adm-flags-card-foot">
3429 <span class="adm-flags-foot-hint">
07f4b70Claude3430 Saved values overwrite the defaults at runtime.
3431 </span>
8929744Claude3432 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3433 Save changes
07f4b70Claude3434 </button>
3435 </div>
3436 </form>
8f50ed0Claude3437 </div>
07f4b70Claude3438 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3439 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3440 </Layout>
3441 );
3442});
3443
3444admin.post("/admin/flags", async (c) => {
3445 const g = await gate(c);
3446 if (g instanceof Response) return g;
3447 const { user } = g;
3448 const body = await c.req.parseBody();
3449 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3450 for (const k of keys) {
3451 const v = String(body[k] ?? "");
3452 await setFlag(k, v, user.id);
3453 }
3454 await audit({ userId: user.id, action: "admin.flags.save" });
3455 return c.redirect("/admin/flags");
3456});
3457
08420cdClaude3458// ----- Email digests (Block I7) -----
3459
3460admin.get("/admin/digests", async (c) => {
3461 const g = await gate(c);
3462 if (g instanceof Response) return g;
3463 const { user } = g;
3464
3465 const [optedRow] = await db
3466 .select({ n: sql<number>`count(*)::int` })
3467 .from(users)
3468 .where(eq(users.notifyEmailDigestWeekly, true));
3469 const opted = Number(optedRow?.n || 0);
3470
3471 const recentlySent = await db
3472 .select({
3473 id: users.id,
3474 username: users.username,
3475 lastDigestSentAt: users.lastDigestSentAt,
3476 })
3477 .from(users)
3478 .where(sql`${users.lastDigestSentAt} is not null`)
3479 .orderBy(desc(users.lastDigestSentAt))
3480 .limit(20);
3481
3482 const result = c.req.query("result");
3483 const error = c.req.query("error");
3484
3485 return c.html(
3486 <Layout title="Admin — Digests" user={user}>
8929744Claude3487 <div class="adm-digests-wrap">
3488 <section class="adm-digests-hero">
3489 <div class="adm-digests-hero-inner">
3490 <div class="adm-digests-hero-text">
3491 <div class="adm-digests-eyebrow">
3492 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3493 Site admin · Email
3494 </div>
3495 <h1 class="adm-digests-title">
3496 <span class="adm-digests-title-grad">Email digests</span>.
3497 </h1>
3498 <p class="adm-digests-sub">
3499 Manually trigger the weekly digest for every opted-in user
3500 or preview the email for a single account.
3501 </p>
3502 </div>
3503 <a href="/admin" class="adm-digests-back">
07f4b70Claude3504 {Icons.arrowLeft} Back
3505 </a>
3506 </div>
3507 </section>
08420cdClaude3508
07f4b70Claude3509 {result && (
8929744Claude3510 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3511 )}
3512 {error && (
8929744Claude3513 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3514 )}
08420cdClaude3515
8929744Claude3516 <div class="adm-digests-pills">
3517 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3518 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3519 </div>
3520
3521 <section class="adm-digests-section">
3522 <header class="adm-digests-section-head">
3523 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3524 <div>
3525 <h3 class="adm-digests-section-title">Send digests</h3>
3526 <p class="adm-digests-section-sub">
3527 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3528 </p>
08420cdClaude3529 </div>
8929744Claude3530 </header>
3531 <div class="adm-digests-section-body">
3532 <form method="post" action="/admin/digests/run">
07f4b70Claude3533 <button
3534 type="submit"
8929744Claude3535 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3536 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3537 >
8929744Claude3538 {Icons.mail}
07f4b70Claude3539 Send digests now
3540 </button>
3541 </form>
8929744Claude3542 <div class="adm-digests-section-divider">
3543 <div class="adm-digests-divider-hint">
07f4b70Claude3544 Preview / one-off — send the digest to a single user.
3545 </div>
3546 <form
3547 method="post"
3548 action="/admin/digests/preview"
8929744Claude3549 class="adm-digests-form-row"
07f4b70Claude3550 >
3551 <input
3552 type="text"
3553 name="username"
3554 placeholder="username"
3555 required
3556 aria-label="Username"
8929744Claude3557 class="adm-digests-input"
07f4b70Claude3558 />
8929744Claude3559 <button type="submit" class="adm-digests-btn">
07f4b70Claude3560 Send to one user
3561 </button>
3562 </form>
3563 </div>
3564 </div>
8929744Claude3565 </section>
07f4b70Claude3566
8929744Claude3567 <div class="adm-digests-h3">
07f4b70Claude3568 <h3>Recently sent</h3>
8929744Claude3569 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3570 </div>
8929744Claude3571 {recentlySent.length === 0 ? (
3572 <div class="adm-digests-empty">
3573 <div class="adm-digests-empty-orb" aria-hidden="true" />
3574 <div class="adm-digests-empty-inner">
3575 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3576 <div class="adm-digests-empty-title">No digests sent yet</div>
3577 <div class="adm-digests-empty-sub">
3578 When the weekly digest fires, sent recipients will appear here.
3579 </div>
3580 </div>
3581 </div>
3582 ) : (
3583 <div class="adm-digests-grid">
3584 {recentlySent.map((u) => (
3585 <div class="adm-digests-card">
3586 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3587 <div class="adm-digests-card-text">
3588 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3589 <div class="adm-digests-card-sent">
3590 sent {u.lastDigestSentAt
3591 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3592 : "—"}
07f4b70Claude3593 </div>
3594 </div>
3595 </div>
8929744Claude3596 ))}
3597 </div>
3598 )}
08420cdClaude3599 </div>
07f4b70Claude3600 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3601 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3602 </Layout>
3603 );
3604});
3605
3606admin.post("/admin/digests/run", async (c) => {
3607 const g = await gate(c);
3608 if (g instanceof Response) return g;
3609 const { user } = g;
3610 const results = await sendDigestsToAll();
3611 const sent = results.filter((r) => r.ok).length;
3612 const skipped = results.length - sent;
3613 await audit({
3614 userId: user.id,
3615 action: "admin.digests.run",
3616 metadata: { sent, skipped, total: results.length },
3617 });
3618 return c.redirect(
3619 `/admin/digests?result=${encodeURIComponent(
3620 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3621 )}`
3622 );
3623});
3624
3625admin.post("/admin/digests/preview", async (c) => {
3626 const g = await gate(c);
3627 if (g instanceof Response) return g;
3628 const { user } = g;
3629 const body = await c.req.parseBody();
3630 const username = String(body.username || "").trim();
3631 if (!username) {
3632 return c.redirect("/admin/digests?error=Username+required");
3633 }
3634 const [target] = await db
3635 .select({ id: users.id, username: users.username })
3636 .from(users)
3637 .where(eq(users.username, username))
3638 .limit(1);
3639 if (!target) {
3640 return c.redirect("/admin/digests?error=User+not+found");
3641 }
3642 const result = await sendDigestForUser(target.id);
3643 await audit({
3644 userId: user.id,
3645 action: "admin.digests.preview",
3646 targetType: "user",
3647 targetId: target.id,
3648 metadata: {
3649 ok: result.ok,
3650 skipped: "skipped" in result ? result.skipped : null,
3651 },
3652 });
3653 if (result.ok) {
3654 return c.redirect(
3655 `/admin/digests?result=${encodeURIComponent(
3656 `Digest sent to ${target.username}.`
3657 )}`
3658 );
3659 }
3660 return c.redirect(
3661 `/admin/digests?error=${encodeURIComponent(
3662 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3663 )}`
3664 );
3665});
3666
b5dd694Claude3667const AUTOPILOT_TASK_CATALOG = [
3668 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3669 { name: "merge-queue", desc: "Advance serialised merge queues" },
3670 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3671 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3672 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3673 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3674 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3675 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3676 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3677 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
f65f600Claude3678 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
b5dd694Claude3679] as const;
3680
8e9f1d9Claude3681admin.get("/admin/autopilot", async (c) => {
3682 const g = await gate(c);
3683 if (g instanceof Response) return g;
3684 const { user } = g;
3685 const tick = getLastTick();
3686 const total = getTickCount();
3687 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3688 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3689 const intervalMs =
3690 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3691 ? Number(intervalRaw)
3692 : 5 * 60 * 1000;
3693 const msg = c.req.query("result") || c.req.query("error");
3694 const isErr = !!c.req.query("error");
3695 return c.html(
3696 <Layout title="Autopilot — admin" user={user}>
8929744Claude3697 <div class="adm-autopilot-wrap">
3698 <section class="adm-autopilot-hero">
3699 <div class="adm-autopilot-hero-inner">
3700 <div class="adm-autopilot-hero-text">
3701 <div class="adm-autopilot-eyebrow">
3702 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3703 Site admin · Maintenance loop
3704 </div>
3705 <h1 class="adm-autopilot-title">
3706 <span class="adm-autopilot-title-grad">Autopilot</span>.
3707 </h1>
3708 <p class="adm-autopilot-sub">
3709 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3710 progress, weekly digests, advisory rescans, wait-timer release,
3711 scheduled workflows (cron), AI-gated auto-merge sweep, and
3712 ai:build issue → PR dispatch.
8929744Claude3713 </p>
3714 </div>
3715 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3716 {Icons.arrowLeft} Back
3717 </a>
3718 </div>
3719 </section>
3720
8e9f1d9Claude3721 {msg && (
8929744Claude3722 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3723 {decodeURIComponent(msg)}
3724 </div>
3725 )}
07f4b70Claude3726
8929744Claude3727 <div class="adm-autopilot-statgrid">
3728 <div class="adm-autopilot-stat">
3729 <div class="adm-autopilot-stat-head">
3730 <span class="adm-autopilot-stat-label">Status</span>
3731 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3732 <span class="dot" aria-hidden="true" />
3733 {disabled ? "disabled" : "running"}
3734 </span>
3735 </div>
8929744Claude3736 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3737 {disabled ? "disabled" : "running"}
3738 </div>
8929744Claude3739 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3740 </div>
8929744Claude3741 <div class="adm-autopilot-stat">
3742 <div class="adm-autopilot-stat-head">
3743 <span class="adm-autopilot-stat-label">Interval</span>
3744 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3745 </div>
8929744Claude3746 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3747 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3748 </div>
8929744Claude3749 <div class="adm-autopilot-stat">
3750 <div class="adm-autopilot-stat-head">
3751 <span class="adm-autopilot-stat-label">Ticks this process</span>
3752 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3753 </div>
8929744Claude3754 <div class="adm-autopilot-stat-value">{total}</div>
3755 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3756 </div>
8929744Claude3757 <div class="adm-autopilot-stat">
3758 <div class="adm-autopilot-stat-head">
3759 <span class="adm-autopilot-stat-label">Last tick</span>
3760 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3761 </div>
8929744Claude3762 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3763 {tick ? tick.finishedAt : "never"}
3764 </div>
3765 </div>
3766 </div>
07f4b70Claude3767
8929744Claude3768 <div class="adm-autopilot-actions">
3769 <form method="post" action="/admin/autopilot/run">
3770 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3771 {Icons.bot}
3772 Run tick now
3773 </button>
3774 </form>
3775 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3776 Executes all sub-tasks synchronously and records the result.
3777 </span>
8929744Claude3778 </div>
07f4b70Claude3779
8929744Claude3780 <div class="adm-autopilot-h3">
07f4b70Claude3781 <h3>Last tick tasks</h3>
3782 {tick && (
8929744Claude3783 <span class="adm-autopilot-h3-meta">
07f4b70Claude3784 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3785 </span>
3786 )}
3787 </div>
8e9f1d9Claude3788 {tick ? (
8929744Claude3789 <div class="adm-autopilot-tasks">
3790 {tick.tasks.map((t) => (
3791 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3792 <div class="adm-autopilot-task-head">
3793 <span
3794 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3795 aria-label={t.ok ? "ok" : "failed"}
3796 />
3797 <span class="adm-autopilot-task-name">{t.name}</span>
3798 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3799 {t.ok ? "ok" : "failed"}
8929744Claude3800 </span>
3801 </div>
3802 <div class="adm-autopilot-task-meta">
3803 <span>duration</span>
3804 <span>{t.durationMs}ms</span>
3805 </div>
3806 {t.error && (
3807 <div class="adm-autopilot-task-err">{t.error}</div>
3808 )}
3809 </div>
3810 ))}
3811 </div>
8e9f1d9Claude3812 ) : (
8929744Claude3813 <div class="adm-autopilot-empty">
3814 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3815 <div class="adm-autopilot-empty-inner">
3816 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3817 <div class="adm-autopilot-empty-title">No ticks yet</div>
3818 <div class="adm-autopilot-empty-sub">
3819 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3820 </div>
3821 </div>
07f4b70Claude3822 </div>
8e9f1d9Claude3823 )}
b5dd694Claude3824 <div class="adm-autopilot-h3" style="margin-top:28px">
3825 <h3>Configured tasks</h3>
44ed968Claude3826 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3827 </div>
3828 <div class="adm-autopilot-tasks">
3829 {AUTOPILOT_TASK_CATALOG.map((t) => {
3830 const last = tick?.tasks.find((r) => r.name === t.name);
3831 return (
3832 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3833 <div class="adm-autopilot-task-head">
3834 <span
3835 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3836 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3837 />
3838 <span class="adm-autopilot-task-name">{t.name}</span>
3839 {last && (
3840 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3841 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3842 </span>
3843 )}
3844 </div>
3845 <div class="adm-autopilot-task-meta">
3846 <span>{t.desc}</span>
3847 </div>
3848 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3849 </div>
3850 );
3851 })}
3852 </div>
3853
8929744Claude3854 <p class="adm-autopilot-foot">
8e9f1d9Claude3855 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3856 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3857 </p>
3858 </div>
07f4b70Claude3859 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3860 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3861 </Layout>
3862 );
3863});
3864
988380aClaude3865admin.post("/admin/demo/reseed", async (c) => {
3866 const g = await gate(c);
3867 if (g instanceof Response) return g;
3868 const { user } = g;
3869 try {
3870 const result = await ensureDemoContent({ force: true });
3871 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}` : ""}`;
3872 await audit({
3873 userId: user.id,
3874 action: "admin.demo.reseed",
3875 targetType: "user",
3876 targetId: result.demoUser?.id ?? "demo",
3877 metadata: {
3878 createdUser: result.created.user,
3879 createdRepos: result.created.repos,
3880 createdIssues: result.created.issues,
3881 createdPrs: result.created.prs,
3882 errors: result.errors.slice(0, 5),
3883 },
3884 });
3885 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
3886 } catch (err) {
3887 const message = err instanceof Error ? err.message : String(err);
3888 return c.redirect(
3889 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
3890 );
3891 }
3892});
3893
3894// Public jump-to-demo — redirects to the first demo repo if present,
3895// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
3896admin.get("/demo", (c) => {
3897 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
3898});
3899
8e9f1d9Claude3900admin.post("/admin/autopilot/run", async (c) => {
3901 const g = await gate(c);
3902 if (g instanceof Response) return g;
3903 const { user } = g;
3904 let summary = "";
3905 try {
3906 const result = await runAutopilotTick();
3907 const ok = result.tasks.filter((t) => t.ok).length;
3908 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
3909 await audit({
3910 userId: user.id,
3911 action: "admin.autopilot.run",
3912 targetType: "system",
3913 targetId: "autopilot",
3914 metadata: { ok, total: result.tasks.length },
3915 });
3916 return c.redirect(
3917 `/admin/autopilot?result=${encodeURIComponent(summary)}`
3918 );
3919 } catch (err) {
3920 const message = err instanceof Error ? err.message : String(err);
3921 return c.redirect(
3922 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
3923 );
3924 }
3925});
3926
b218e63Claude3927// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
3928
3929admin.get("/admin/ai-costs", async (c) => {
3930 const g = await gate(c);
3931 if (g instanceof Response) return g;
3932 const { user } = g;
3933
3934 // Start of current month (UTC)
3935 const now = new Date();
3936 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
3937
3938 // Total spend this month
3939 const [totalRow] = await db
3940 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
3941 .from(aiCostEvents)
3942 .where(gte(aiCostEvents.occurredAt, monthStart));
3943 const totalCents = Number(totalRow?.total ?? 0);
3944
3945 // Breakdown by category
3946 const byCategory = await db
3947 .select({
3948 category: aiCostEvents.category,
3949 cents: sql<number>`sum(cents_estimate)::int`,
3950 calls: sql<number>`count(*)::int`,
3951 })
3952 .from(aiCostEvents)
3953 .where(gte(aiCostEvents.occurredAt, monthStart))
3954 .groupBy(aiCostEvents.category)
3955 .orderBy(sql`sum(cents_estimate) desc`);
3956
3957 // Top 10 spenders (join users)
3958 const topSpenders = await db
3959 .select({
3960 username: users.username,
3961 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
3962 calls: sql<number>`count(*)::int`,
3963 })
3964 .from(aiCostEvents)
3965 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
3966 .where(gte(aiCostEvents.occurredAt, monthStart))
3967 .groupBy(users.username)
3968 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
3969 .limit(10);
3970
3971 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
3972 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
3973
3974 function fmtCents(c: number): string {
3975 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
3976 return `${c}¢`;
3977 }
3978
3979 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
3980
3981 return c.html(
3982 <Layout title="Admin — AI Costs" user={user}>
3983 <div class="adm-analytics-wrap">
3984 <section class="adm-analytics-hero">
3985 <div class="adm-analytics-hero-inner">
3986 <div class="adm-analytics-hero-text">
3987 <div class="adm-analytics-eyebrow">
3988 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
3989 Site admin · Analytics
3990 </div>
3991 <h1 class="adm-analytics-title">
3992 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
3993 </h1>
3994 <p class="adm-analytics-sub">
3995 Per-call AI spend for {monthName} — by feature category and top spenders.
3996 </p>
3997 </div>
3998 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
3999 </div>
4000 </section>
4001
4002 <div class="adm-analytics-statgrid">
4003 <div class="adm-analytics-stat">
4004 <div class="adm-analytics-stat-label">Total spend this month</div>
4005 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
4006 <div class="adm-analytics-stat-hint">{monthName}</div>
4007 </div>
4008 <div class="adm-analytics-stat">
4009 <div class="adm-analytics-stat-label">Categories active</div>
4010 <div class="adm-analytics-stat-value">{byCategory.length}</div>
4011 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
4012 </div>
4013 <div class="adm-analytics-stat">
4014 <div class="adm-analytics-stat-label">Total AI calls</div>
4015 <div class="adm-analytics-stat-value">
4016 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
4017 </div>
4018 <div class="adm-analytics-stat-hint">recorded events</div>
4019 </div>
4020 </div>
4021
4022 <div class="adm-analytics-h3">
4023 <h3>By category</h3>
4024 <span class="adm-analytics-h3-meta">{monthName}</span>
4025 </div>
4026 {byCategory.length === 0 ? (
4027 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
4028 ) : (
4029 <table class="adm-analytics-table">
4030 <thead>
4031 <tr>
4032 <th>Category</th>
4033 <th>Spend</th>
4034 <th>Calls</th>
4035 <th style="width:200px">Distribution</th>
4036 </tr>
4037 </thead>
4038 <tbody>
4039 {byCategory.map((row) => {
4040 const pct = Math.round((Number(row.cents) / maxCents) * 100);
4041 return (
4042 <tr>
4043 <td><code>{row.category ?? "other"}</code></td>
4044 <td>{fmtCents(Number(row.cents))}</td>
4045 <td>{Number(row.calls).toLocaleString()}</td>
4046 <td>
4047 <div class="adm-analytics-bar-cell">
4048 <div class="adm-analytics-bar-track">
4049 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4050 </div>
4051 <span class="adm-analytics-bar-label">{pct}%</span>
4052 </div>
4053 </td>
4054 </tr>
4055 );
4056 })}
4057 </tbody>
4058 </table>
4059 )}
4060
4061 <div class="adm-analytics-h3">
4062 <h3>Top 10 spenders</h3>
4063 <span class="adm-analytics-h3-meta">{monthName}</span>
4064 </div>
4065 {topSpenders.length === 0 ? (
4066 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4067 ) : (
4068 <table class="adm-analytics-table">
4069 <thead>
4070 <tr>
4071 <th>User</th>
4072 <th>Spend</th>
4073 <th>Calls</th>
4074 <th style="width:200px">Share</th>
4075 </tr>
4076 </thead>
4077 <tbody>
4078 {topSpenders.map((row, i) => {
4079 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4080 return (
4081 <tr>
4082 <td>
4083 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4084 #{i + 1} {row.username}
4085 </a>
4086 </td>
4087 <td>{fmtCents(Number(row.cents))}</td>
4088 <td>{Number(row.calls).toLocaleString()}</td>
4089 <td>
4090 <div class="adm-analytics-bar-cell">
4091 <div class="adm-analytics-bar-track">
4092 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4093 </div>
4094 <span class="adm-analytics-bar-label">{pct}%</span>
4095 </div>
4096 </td>
4097 </tr>
4098 );
4099 })}
4100 </tbody>
4101 </table>
4102 )}
4103 </div>
4104 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4105 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4106 </Layout>
4107 );
4108});
4109
4110// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4111
4112const ALL_AUTOPILOT_TASKS = [
4113 "mirror-sync",
4114 "merge-queue",
4115 "weekly-digest",
4116 "advisory-rescan",
4117 "wait-timer-release",
4118 "scheduled-workflows",
4119 "auto-merge-sweep",
4120 "ai-build-from-issues",
4121 "sleep-mode-digest",
4122 "stale-pr-sweep",
4123] as const;
4124
4125admin.get("/admin/autopilot/health", async (c) => {
4126 const g = await gate(c);
4127 if (g instanceof Response) return g;
4128 const { user } = g;
4129
4130 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4131
4132 // Pull the last 200 audit log rows that match autopilot task patterns
4133 const logRows = await db
4134 .select({
4135 action: auditLog.action,
4136 createdAt: auditLog.createdAt,
4137 metadata: auditLog.metadata,
4138 })
4139 .from(auditLog)
4140 .where(gte(auditLog.createdAt, since24h))
4141 .orderBy(desc(auditLog.createdAt))
4142 .limit(500);
4143
4144 // Build per-task health from the last autopilot tick (in-memory)
4145 const tick = getLastTick();
4146
4147 type TaskHealth = {
4148 name: string;
4149 lastRun: string | null;
4150 lastDurationMs: number | null;
4151 lastOk: boolean | null;
4152 lastError: string | null;
4153 successCount24h: number;
4154 errorCount24h: number;
4155 };
4156
4157 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4158 // Count audit events in last 24h that look like this task
4159 const successCount24h = logRows.filter(
4160 (r) => r.action === `autopilot.task.ok.${taskName}`
4161 ).length;
4162 const errorCount24h = logRows.filter(
4163 (r) => r.action === `autopilot.task.error.${taskName}`
4164 ).length;
4165
4166 // Get last tick result for this task (in-memory)
4167 const last = tick?.tasks.find((t) => t.name === taskName);
4168
4169 return {
4170 name: taskName,
4171 lastRun: tick?.finishedAt ?? null,
4172 lastDurationMs: last?.durationMs ?? null,
4173 lastOk: last?.ok ?? null,
4174 lastError: last?.error ?? null,
4175 successCount24h,
4176 errorCount24h,
4177 };
4178 });
4179
4180 const total = getTickCount();
4181 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4182
4183 return c.html(
4184 <Layout title="Autopilot Health — admin" user={user}>
4185 <div class="adm-analytics-wrap">
4186 <section class="adm-analytics-hero">
4187 <div class="adm-analytics-hero-inner">
4188 <div class="adm-analytics-hero-text">
4189 <div class="adm-analytics-eyebrow">
4190 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4191 Site admin · Autopilot
4192 </div>
4193 <h1 class="adm-analytics-title">
4194 <span class="adm-analytics-title-grad">Autopilot health</span>.
4195 </h1>
4196 <p class="adm-analytics-sub">
4197 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4198 </p>
4199 </div>
4200 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4201 </div>
4202 </section>
4203
4204 <div class="adm-analytics-statgrid">
4205 <div class="adm-analytics-stat">
4206 <div class="adm-analytics-stat-label">Status</div>
4207 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4208 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4209 </div>
4210 <div class="adm-analytics-stat">
4211 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4212 <div class="adm-analytics-stat-value">{total}</div>
4213 <div class="adm-analytics-stat-hint">since boot</div>
4214 </div>
4215 <div class="adm-analytics-stat">
4216 <div class="adm-analytics-stat-label">Last tick</div>
4217 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4218 {tick?.finishedAt ?? "—"}
4219 </div>
4220 </div>
4221 <div class="adm-analytics-stat">
4222 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4223 <div class="adm-analytics-stat-value">
4224 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4225 </div>
4226 </div>
4227 </div>
4228
4229 <div class="adm-analytics-h3">
4230 <h3>Per-task health</h3>
4231 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4232 </div>
4233 <table class="adm-analytics-table">
4234 <thead>
4235 <tr>
4236 <th>Task</th>
4237 <th>Last status</th>
4238 <th>Duration (last)</th>
4239 <th>OK (24h)</th>
4240 <th>Errors (24h)</th>
4241 </tr>
4242 </thead>
4243 <tbody>
4244 {health.map((t) => (
4245 <tr>
4246 <td><code>{t.name}</code></td>
4247 <td>
4248 {t.lastOk === null ? (
4249 <span style="color:var(--text-muted)">—</span>
4250 ) : t.lastOk ? (
4251 <span class="adm-analytics-pill is-ok">ok</span>
4252 ) : (
4253 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4254 )}
4255 </td>
4256 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4257 <td>
4258 {t.successCount24h > 0 ? (
4259 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4260 ) : (
4261 <span style="color:var(--text-muted)">—</span>
4262 )}
4263 </td>
4264 <td>
4265 {t.errorCount24h > 0 ? (
4266 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4267 ) : (
4268 <span style="color:var(--text-muted)">0</span>
4269 )}
4270 </td>
4271 </tr>
4272 ))}
4273 </tbody>
4274 </table>
4275
4276 {tick?.tasks.some((t) => !t.ok && t.error) && (
4277 <>
4278 <div class="adm-analytics-h3" style="margin-top:28px">
4279 <h3>Last-tick errors</h3>
4280 </div>
4281 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4282 <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">
4283 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4284 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4285 </div>
4286 ))}
4287 </>
4288 )}
4289
4290 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4291 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).
4292 </p>
4293 </div>
4294 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4295 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4296 </Layout>
4297 );
4298});
4299
4300// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4301
4302admin.get("/admin/growth", async (c) => {
4303 const g = await gate(c);
4304 if (g instanceof Response) return g;
4305 const { user } = g;
4306
4307 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4308
4309 // Daily signups last 30 days
4310 const dailySignups = await db
4311 .select({
4312 day: sql<string>`date_trunc('day', created_at)::date::text`,
4313 count: sql<number>`count(*)::int`,
4314 })
4315 .from(users)
4316 .where(gte(users.createdAt, since30d))
4317 .groupBy(sql`date_trunc('day', created_at)`)
4318 .orderBy(sql`date_trunc('day', created_at)`);
4319
4320 // Activation rate: users who created at least 1 repo (last 30d signups)
4321 const activatedRows = await db
4322 .select({
4323 userId: users.id,
4324 })
4325 .from(users)
4326 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4327 .where(gte(users.createdAt, since30d))
4328 .groupBy(users.id);
4329
4330 // Total signups in window
4331 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4332 const activated = activatedRows.length;
4333 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4334
4335 // All-time user count
4336 const [allTimeRow] = await db
4337 .select({ n: sql<number>`count(*)::int` })
4338 .from(users);
4339 const allTime = Number(allTimeRow?.n ?? 0);
4340
4341 // Build a 30-slot array (fill missing days with 0)
4342 const dayMap = new Map<string, number>();
4343 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4344
4345 const slots: { label: string; count: number }[] = [];
4346 for (let i = 29; i >= 0; i--) {
4347 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4348 const key = d.toISOString().slice(0, 10);
4349 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4350 slots.push({ label, count: dayMap.get(key) ?? 0 });
4351 }
4352
4353 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4354
4355 return c.html(
4356 <Layout title="Admin — User Growth" user={user}>
4357 <div class="adm-analytics-wrap">
4358 <section class="adm-analytics-hero">
4359 <div class="adm-analytics-hero-inner">
4360 <div class="adm-analytics-hero-text">
4361 <div class="adm-analytics-eyebrow">
4362 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4363 Site admin · Analytics
4364 </div>
4365 <h1 class="adm-analytics-title">
4366 <span class="adm-analytics-title-grad">User growth</span>.
4367 </h1>
4368 <p class="adm-analytics-sub">
4369 Daily signups and activation rate over the last 30 days.
4370 </p>
4371 </div>
4372 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4373 </div>
4374 </section>
4375
4376 <div class="adm-analytics-statgrid">
4377 <div class="adm-analytics-stat">
4378 <div class="adm-analytics-stat-label">Total users</div>
4379 <div class="adm-analytics-stat-value">{allTime}</div>
4380 <div class="adm-analytics-stat-hint">all time</div>
4381 </div>
4382 <div class="adm-analytics-stat">
4383 <div class="adm-analytics-stat-label">New signups (30d)</div>
4384 <div class="adm-analytics-stat-value">{totalSignups}</div>
4385 <div class="adm-analytics-stat-hint">last 30 days</div>
4386 </div>
4387 <div class="adm-analytics-stat">
4388 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4389 <div class="adm-analytics-stat-value">{activationRate}%</div>
4390 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4391 </div>
4392 <div class="adm-analytics-stat">
4393 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4394 <div class="adm-analytics-stat-value">{activated}</div>
4395 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4396 </div>
4397 </div>
4398
4399 <div class="adm-analytics-h3">
4400 <h3>Daily signups — last 30 days</h3>
4401 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4402 </div>
4403
4404 {totalSignups === 0 ? (
4405 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4406 ) : (
4407 <table class="adm-analytics-table">
4408 <thead>
4409 <tr>
4410 <th>Date</th>
4411 <th>Signups</th>
4412 <th style="width:280px">Bar</th>
4413 </tr>
4414 </thead>
4415 <tbody>
4416 {slots.filter((s) => s.count > 0 || true).map((s) => {
4417 const pct = Math.round((s.count / maxCount) * 100);
4418 return (
4419 <tr>
4420 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4421 <td>{s.count}</td>
4422 <td>
4423 {s.count > 0 ? (
4424 <div class="adm-analytics-bar-cell">
4425 <div class="adm-analytics-bar-track">
4426 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4427 </div>
4428 <span class="adm-analytics-bar-label">{s.count}</span>
4429 </div>
4430 ) : (
4431 <span style="color:var(--text-faint);font-size:11px">—</span>
4432 )}
4433 </td>
4434 </tr>
4435 );
4436 })}
4437 </tbody>
4438 </table>
4439 )}
4440
4441 <div class="adm-analytics-h3" style="margin-top:28px">
4442 <h3>Activation breakdown</h3>
4443 <span class="adm-analytics-h3-meta">30d cohort</span>
4444 </div>
4445 <table class="adm-analytics-table" style="max-width:500px">
4446 <thead>
4447 <tr>
4448 <th>Segment</th>
4449 <th>Count</th>
4450 <th style="width:200px">Rate</th>
4451 </tr>
4452 </thead>
4453 <tbody>
4454 <tr>
4455 <td>New signups (30d)</td>
4456 <td>{totalSignups}</td>
4457 <td>100%</td>
4458 </tr>
4459 <tr>
4460 <td>Activated (created ≥1 repo)</td>
4461 <td>{activated}</td>
4462 <td>
4463 <div class="adm-analytics-bar-cell">
4464 <div class="adm-analytics-bar-track">
4465 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4466 </div>
4467 <span class="adm-analytics-bar-label">{activationRate}%</span>
4468 </div>
4469 </td>
4470 </tr>
4471 <tr>
4472 <td>Not activated</td>
4473 <td>{totalSignups - activated}</td>
4474 <td>{100 - activationRate}%</td>
4475 </tr>
4476 </tbody>
4477 </table>
4478 </div>
4479 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4480 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4481 </Layout>
4482 );
4483});
4484
8f50ed0Claude4485export default admin;