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.tsxBlame4609 lines · 1 contributor
8f50ed0Claude1/**
2 * Block F3 — Site admin panel.
3 *
4 * GET /admin — dashboard (counts + recent users)
5 * GET /admin/users — user list + search
6 * POST /admin/users/:id/admin — toggle site-admin flag
7 * GET /admin/repos — repo list (including private)
8 * POST /admin/repos/:id/delete — nuclear delete (audit-logged)
9 * GET /admin/flags — site flags CRUD
10 * POST /admin/flags — set flag
11 *
12 * All routes gated by `isSiteAdmin`. First registered user is the bootstrap
13 * admin. Site banner + registration lock are surfaced to the rest of the app
14 * via `getFlag`.
07f4b70Claude15 *
16 * Visual polish (parallel session 3.I): adopts the 2026 design language —
17 * gradient-hairline hero, animated orb, stat cards with display font, and a
18 * polished action grid with inline-SVG glyphs. Logic, auth gates, redirects,
19 * and audit emissions are unchanged from the pre-polish version. All scoped
20 * CSS classes are prefixed `.admin-`.
8f50ed0Claude21 */
22
23import { Hono } from "hono";
b218e63Claude24import { and, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
8f50ed0Claude25import { db } from "../db";
6dec21bClaude26import { aiCostEvents, auditLog, issueComments, prComments, 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-orb {
86 position: absolute;
87 inset: 0;
6fd5915Claude88 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
07f4b70Claude89 filter: blur(80px);
90 opacity: 0.7;
91 animation: adminHeroOrb 14s ease-in-out infinite;
92 }
93 @keyframes adminHeroOrb {
94 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
95 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .admin-hero-orb { animation: none; }
99 }
100 .admin-hero-inner {
101 position: relative;
102 z-index: 1;
103 max-width: 720px;
104 }
105 .admin-hero-eyebrow {
106 font-size: 12px;
107 color: var(--text-muted);
108 margin-bottom: var(--space-2);
109 letter-spacing: 0.02em;
110 text-transform: none;
111 display: inline-flex;
112 align-items: center;
113 gap: 8px;
114 }
115 .admin-hero-eyebrow .admin-shield {
116 display: inline-flex;
117 align-items: center;
118 justify-content: center;
119 width: 18px; height: 18px;
120 border-radius: 6px;
6fd5915Claude121 background: rgba(91,110,232,0.14);
122 color: #5b6ee8;
123 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
07f4b70Claude124 }
125 .admin-hero-eyebrow .admin-who {
126 color: var(--accent);
127 font-weight: 600;
128 }
129 .admin-hero-title {
130 font-size: clamp(28px, 4vw, 40px);
131 font-family: var(--font-display);
132 font-weight: 800;
133 letter-spacing: -0.028em;
134 line-height: 1.05;
135 margin: 0 0 var(--space-2);
136 color: var(--text-strong);
137 }
138 .admin-hero-title .gradient-text,
139 .admin-hero-title-grad {
6fd5915Claude140 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
07f4b70Claude141 -webkit-background-clip: text;
142 background-clip: text;
143 -webkit-text-fill-color: transparent;
144 color: transparent;
145 }
146 .admin-hero-sub {
147 font-size: 15px;
148 color: var(--text-muted);
149 margin: 0;
150 line-height: 1.5;
151 max-width: 620px;
152 }
153
154 /* ─── Section hero (sub-pages) ─── */
155 .admin-sec-hero {
156 position: relative;
157 margin-bottom: var(--space-5);
158 padding: var(--space-4) var(--space-5);
159 background: var(--bg-elevated);
160 border: 1px solid var(--border);
161 border-radius: 14px;
162 overflow: hidden;
163 display: flex;
164 align-items: flex-end;
165 justify-content: space-between;
166 gap: var(--space-4);
167 flex-wrap: wrap;
168 }
169 .admin-sec-hero::before {
170 content: '';
171 position: absolute;
172 top: 0; left: 0; right: 0;
173 height: 2px;
6fd5915Claude174 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
07f4b70Claude175 opacity: 0.6;
176 pointer-events: none;
177 }
178 .admin-sec-hero-text { flex: 1; min-width: 240px; }
179 .admin-sec-eyebrow {
180 font-size: 11px;
181 font-weight: 600;
182 letter-spacing: 0.08em;
183 text-transform: uppercase;
184 color: var(--accent);
185 margin-bottom: 6px;
186 }
187 .admin-sec-title {
188 font-family: var(--font-display);
189 font-size: clamp(22px, 2.8vw, 28px);
190 font-weight: 800;
191 letter-spacing: -0.022em;
192 line-height: 1.1;
193 margin: 0 0 4px;
194 color: var(--text-strong);
195 }
196 .admin-sec-sub {
197 font-size: 13.5px;
198 color: var(--text-muted);
199 margin: 0;
200 line-height: 1.5;
201 max-width: 620px;
202 }
203 .admin-sec-hero-actions {
204 display: flex;
205 gap: var(--space-2);
206 flex-wrap: wrap;
207 }
208
209 /* ─── Banners ─── */
210 .admin-banner {
211 margin-bottom: var(--space-4);
212 padding: 10px 14px;
213 border-radius: 10px;
214 font-size: 13.5px;
215 border: 1px solid var(--border);
216 background: rgba(255,255,255,0.025);
217 color: var(--text);
218 }
219 .admin-banner.is-error {
220 border-color: rgba(248,113,113,0.40);
221 background: rgba(248,113,113,0.08);
222 color: #fecaca;
223 }
224 .admin-banner.is-ok {
225 border-color: rgba(52,211,153,0.40);
226 background: rgba(52,211,153,0.08);
227 color: #bbf7d0;
228 }
229
230 /* ─── Stat grid ─── */
231 .admin-stat-grid {
232 display: grid;
233 grid-template-columns: repeat(3, 1fr);
234 gap: var(--space-3);
235 margin-bottom: var(--space-5);
236 }
237 @media (max-width: 720px) {
238 .admin-stat-grid { grid-template-columns: 1fr; }
f1dc7c7Claude239 .admin-hero { padding: var(--space-4); }
240 .admin-actions { grid-template-columns: 1fr; }
241 .admin-action { min-height: 44px; padding: 14px; }
242 .admin-list-row { flex-direction: column; align-items: stretch; padding: 14px; }
243 .admin-search { flex-direction: column; align-items: stretch; }
244 .admin-search .admin-input { width: 100%; }
245 .admin-card-body { padding: var(--space-4); }
246 .admin-card-foot { padding: var(--space-3) var(--space-4); justify-content: flex-start; }
247 .admin-ap-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
07f4b70Claude248 }
249 .admin-stat {
250 position: relative;
251 padding: var(--space-4) var(--space-4);
252 background: var(--bg-elevated);
253 border: 1px solid var(--border);
254 border-radius: 14px;
255 overflow: hidden;
256 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
257 }
258 .admin-stat::after {
259 content: '';
260 position: absolute;
261 inset: 0;
262 border-radius: inherit;
6fd5915Claude263 background: linear-gradient(135deg, rgba(91,110,232,0.05), rgba(95,143,160,0.04));
07f4b70Claude264 opacity: 0;
265 pointer-events: none;
266 transition: opacity 200ms ease;
267 }
268 .admin-stat:hover {
269 transform: translateY(-2px);
270 border-color: var(--border-strong);
271 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
272 }
273 .admin-stat:hover::after { opacity: 1; }
274 .admin-stat-head {
275 display: flex;
276 align-items: center;
277 justify-content: space-between;
278 margin-bottom: var(--space-2);
279 position: relative;
280 z-index: 1;
281 }
282 .admin-stat-label {
283 font-size: 11px;
284 font-weight: 600;
285 letter-spacing: 0.08em;
286 text-transform: uppercase;
287 color: var(--text-muted);
288 }
289 .admin-stat-icon {
290 display: inline-flex;
291 align-items: center;
292 justify-content: center;
293 width: 26px; height: 26px;
294 border-radius: 8px;
6fd5915Claude295 background: rgba(91,110,232,0.12);
296 color: #5b6ee8;
297 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
07f4b70Claude298 }
299 .admin-stat-value {
300 position: relative;
301 z-index: 1;
302 font-family: var(--font-display);
303 font-size: 36px;
304 font-weight: 800;
305 letter-spacing: -0.028em;
306 line-height: 1;
307 color: var(--text-strong);
308 }
309 .admin-stat-hint {
310 margin-top: 6px;
311 font-size: 12px;
312 color: var(--text-faint);
313 position: relative;
314 z-index: 1;
315 }
316
317 /* ─── Action grid ─── */
318 .admin-actions {
319 display: grid;
320 grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
321 gap: var(--space-2);
322 margin-bottom: var(--space-6);
323 }
324 .admin-action {
325 display: flex;
326 align-items: center;
327 gap: 10px;
328 padding: 12px 14px;
329 background: var(--bg-elevated);
330 border: 1px solid var(--border);
331 border-radius: 12px;
332 color: var(--text);
333 text-decoration: none;
334 font-size: 13.5px;
335 font-weight: 500;
336 line-height: 1.25;
337 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
338 cursor: pointer;
339 text-align: left;
340 font-family: inherit;
341 }
342 .admin-action:hover {
343 border-color: var(--border-strong);
344 background: rgba(255,255,255,0.025);
345 transform: translateY(-1px);
346 }
347 .admin-action:focus-visible {
348 outline: none;
349 border-color: var(--border-focus);
6fd5915Claude350 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude351 }
352 .admin-action .admin-action-icon {
353 display: inline-flex;
354 align-items: center;
355 justify-content: center;
356 width: 30px; height: 30px;
357 border-radius: 8px;
358 background: rgba(255,255,255,0.03);
359 color: var(--text-muted);
360 flex-shrink: 0;
361 box-shadow: inset 0 0 0 1px var(--border);
362 }
363 .admin-action.is-primary {
6fd5915Claude364 border-color: rgba(91,110,232,0.35);
365 background: linear-gradient(135deg, rgba(91,110,232,0.14), rgba(95,143,160,0.10));
07f4b70Claude366 color: var(--text-strong);
367 }
368 .admin-action.is-primary:hover {
6fd5915Claude369 border-color: rgba(91,110,232,0.55);
370 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
07f4b70Claude371 }
372 .admin-action.is-primary .admin-action-icon {
6fd5915Claude373 background: rgba(91,110,232,0.18);
07f4b70Claude374 color: #c5b3ff;
6fd5915Claude375 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
07f4b70Claude376 }
377 .admin-action-form { display: contents; }
378
379 /* ─── Section header (h3 replacement) ─── */
380 .admin-h3 {
381 display: flex;
382 align-items: baseline;
383 justify-content: space-between;
384 gap: var(--space-3);
385 margin: var(--space-5) 0 var(--space-3);
386 }
387 .admin-h3 h3 {
388 font-family: var(--font-display);
389 font-size: 16px;
390 font-weight: 700;
391 letter-spacing: -0.014em;
392 margin: 0;
393 color: var(--text-strong);
394 }
395 .admin-h3-meta {
396 font-size: 12px;
397 color: var(--text-muted);
398 }
399
400 /* ─── Lists / cards ─── */
401 .admin-list {
402 background: var(--bg-elevated);
403 border: 1px solid var(--border);
404 border-radius: 14px;
405 overflow: hidden;
406 }
407 .admin-list-row {
408 display: flex;
409 align-items: center;
410 justify-content: space-between;
411 gap: var(--space-3);
412 padding: 12px 16px;
413 border-bottom: 1px solid var(--border-subtle);
414 transition: background 120ms ease;
415 }
416 .admin-list-row:last-child { border-bottom: none; }
417 .admin-list-row:hover { background: rgba(255,255,255,0.018); }
418 .admin-list-empty {
419 padding: var(--space-5);
420 text-align: center;
421 color: var(--text-muted);
422 font-size: 13.5px;
423 }
424 .admin-list-main { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1; }
425 .admin-avatar {
426 width: 30px; height: 30px;
427 border-radius: 9999px;
6fd5915Claude428 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
07f4b70Claude429 color: var(--text-strong);
430 display: inline-flex;
431 align-items: center;
432 justify-content: center;
433 font-size: 12px;
434 font-weight: 700;
6fd5915Claude435 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
07f4b70Claude436 flex-shrink: 0;
437 text-transform: uppercase;
438 }
439 .admin-avatar.is-admin {
6fd5915Claude440 background: linear-gradient(135deg, rgba(91,110,232,0.50), rgba(95,143,160,0.35));
07f4b70Claude441 color: #fff;
6fd5915Claude442 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.55), 0 0 12px rgba(91,110,232,0.25);
07f4b70Claude443 }
444 .admin-row-text { min-width: 0; }
445 .admin-row-title {
446 font-size: 14px;
447 font-weight: 600;
448 color: var(--text-strong);
449 text-decoration: none;
450 }
451 .admin-row-title:hover { color: var(--accent-hover); }
452 .admin-row-sub {
453 margin-top: 2px;
454 font-size: 12px;
455 color: var(--text-muted);
456 display: flex;
457 gap: 8px;
458 flex-wrap: wrap;
459 align-items: center;
460 }
461 .admin-pill {
462 display: inline-flex;
463 align-items: center;
464 gap: 4px;
465 padding: 2px 8px;
466 border-radius: 9999px;
467 font-size: 10.5px;
468 font-weight: 600;
469 letter-spacing: 0.04em;
470 text-transform: uppercase;
471 }
472 .admin-pill.is-admin {
6fd5915Claude473 background: rgba(91,110,232,0.16);
07f4b70Claude474 color: #c5b3ff;
6fd5915Claude475 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
07f4b70Claude476 }
477 .admin-pill.is-private {
478 background: rgba(251,191,36,0.10);
479 color: #fde68a;
480 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
481 }
482 .admin-pill.is-public {
483 background: rgba(255,255,255,0.04);
484 color: var(--text-muted);
485 box-shadow: inset 0 0 0 1px var(--border);
486 }
487 .admin-pill.is-on {
488 background: rgba(52,211,153,0.14);
489 color: #6ee7b7;
490 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
491 }
492 .admin-pill.is-off {
493 background: rgba(255,255,255,0.04);
494 color: var(--text-muted);
495 box-shadow: inset 0 0 0 1px var(--border);
496 }
497 .admin-pill .dot {
498 width: 6px; height: 6px;
499 border-radius: 9999px;
500 background: currentColor;
501 }
502
503 /* ─── Inline forms / search ─── */
504 .admin-search {
505 display: flex;
506 gap: 8px;
507 flex-wrap: wrap;
508 align-items: center;
509 margin-bottom: var(--space-4);
510 }
511 .admin-input {
512 width: 320px;
513 max-width: 100%;
514 padding: 9px 12px;
515 font-size: 14px;
516 color: var(--text);
517 background: var(--bg);
518 border: 1px solid var(--border-strong);
519 border-radius: 8px;
520 outline: none;
521 font-family: var(--font-sans);
522 transition: border-color 120ms ease, box-shadow 120ms ease;
523 }
524 .admin-input:focus {
525 border-color: var(--border-focus);
6fd5915Claude526 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude527 }
528
529 /* ─── Flags form ─── */
530 .admin-card {
531 background: var(--bg-elevated);
532 border: 1px solid var(--border);
533 border-radius: 14px;
534 overflow: hidden;
535 }
536 .admin-card-body { padding: var(--space-5); }
537 .admin-card-foot {
538 padding: var(--space-3) var(--space-5);
539 border-top: 1px solid var(--border);
540 background: rgba(255,255,255,0.012);
541 display: flex;
542 justify-content: flex-end;
543 gap: var(--space-2);
544 align-items: center;
545 flex-wrap: wrap;
546 }
547 .admin-card-foot .admin-foot-hint {
548 margin-right: auto;
549 font-size: 12.5px;
550 color: var(--text-muted);
551 }
552 .admin-field { margin-bottom: var(--space-4); }
553 .admin-field:last-child { margin-bottom: 0; }
554 .admin-field label {
555 display: block;
556 font-family: var(--font-mono);
557 font-size: 12.5px;
558 font-weight: 600;
559 color: var(--text-strong);
560 margin-bottom: 6px;
561 letter-spacing: -0.005em;
562 }
563 .admin-field .admin-input-mono {
564 width: 100%;
565 padding: 9px 12px;
566 font-size: 13.5px;
567 color: var(--text);
568 background: var(--bg);
569 border: 1px solid var(--border-strong);
570 border-radius: 8px;
571 outline: none;
572 font-family: var(--font-mono);
573 transition: border-color 120ms ease, box-shadow 120ms ease;
574 }
575 .admin-field .admin-input-mono:focus {
576 border-color: var(--border-focus);
6fd5915Claude577 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
07f4b70Claude578 }
579 .admin-field-hint {
580 font-size: 11.5px;
581 color: var(--text-muted);
582 margin-top: 6px;
583 line-height: 1.45;
584 }
585 .admin-field-hint code {
586 font-family: var(--font-mono);
587 font-size: 11.5px;
588 background: var(--bg-tertiary);
589 padding: 1px 5px;
590 border-radius: 4px;
591 }
592
593 /* ─── Digest forms ─── */
594 .admin-digest-row {
595 display: flex;
596 gap: 8px;
597 align-items: center;
598 flex-wrap: wrap;
599 }
600
601 /* ─── Autopilot specific ─── */
602 .admin-ap-table {
603 width: 100%;
604 border-collapse: collapse;
605 background: var(--bg-elevated);
606 border: 1px solid var(--border);
607 border-radius: 14px;
608 overflow: hidden;
609 }
610 .admin-ap-table thead th {
611 text-align: left;
612 font-size: 11px;
613 font-weight: 600;
614 letter-spacing: 0.08em;
615 text-transform: uppercase;
616 color: var(--text-muted);
617 padding: 10px 14px;
618 background: rgba(255,255,255,0.015);
619 border-bottom: 1px solid var(--border);
620 }
621 .admin-ap-table tbody td {
622 padding: 10px 14px;
623 border-bottom: 1px solid var(--border-subtle);
624 font-size: 13px;
625 color: var(--text);
626 vertical-align: top;
627 }
628 .admin-ap-table tbody tr:last-child td { border-bottom: none; }
629 .admin-ap-table code {
630 font-family: var(--font-mono);
631 font-size: 12px;
632 color: var(--text-strong);
633 }
634 .admin-ap-status-ok { color: var(--green); font-weight: 600; }
635 .admin-ap-status-fail { color: var(--red); font-weight: 600; }
636 .admin-ap-empty {
637 padding: var(--space-5);
638 text-align: center;
639 color: var(--text-muted);
640 font-size: 13.5px;
641 background: var(--bg-elevated);
642 border: 1px dashed var(--border);
643 border-radius: 14px;
644 }
645 .admin-ap-foot {
646 margin-top: var(--space-5);
647 padding: var(--space-3) var(--space-4);
648 border: 1px solid var(--border-subtle);
649 background: rgba(255,255,255,0.015);
650 border-radius: 10px;
651 color: var(--text-muted);
652 font-size: 12.5px;
653 }
654 .admin-ap-foot code {
655 font-family: var(--font-mono);
656 font-size: 12px;
657 background: var(--bg-tertiary);
658 padding: 1px 5px;
659 border-radius: 4px;
660 color: var(--text);
661 }
662
663 /* ─── Misc ─── */
664 .admin-403 {
665 max-width: 540px;
666 margin: var(--space-12) auto;
667 padding: var(--space-6);
668 text-align: center;
669 background: var(--bg-elevated);
670 border: 1px solid var(--border);
671 border-radius: 16px;
672 }
673 .admin-403 h2 {
674 font-family: var(--font-display);
675 font-size: 22px;
676 margin: 0 0 8px;
677 color: var(--text-strong);
678 }
679 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
6dec21bClaude680
681 /* ─── Automation activity tile ─── */
682 .admin-automation-tile {
683 position: relative;
684 padding: var(--space-4);
685 background: var(--bg-elevated);
686 border: 1px solid var(--border);
687 border-radius: 14px;
688 overflow: hidden;
689 margin-bottom: var(--space-5);
690 }
691 .admin-automation-tile::before {
692 content: '';
693 position: absolute;
694 top: 0; left: 0; right: 0;
695 height: 2px;
696 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
697 opacity: 0.5;
698 pointer-events: none;
699 }
700 .admin-automation-heading {
701 display: flex;
702 align-items: center;
703 gap: 8px;
704 margin-bottom: var(--space-3);
705 }
706 .admin-automation-heading-icon {
707 display: inline-flex;
708 align-items: center;
709 justify-content: center;
710 width: 26px; height: 26px;
711 border-radius: 8px;
712 background: rgba(140,109,255,0.12);
713 color: #b69dff;
714 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
715 }
716 .admin-automation-heading-label {
717 font-family: var(--font-display);
718 font-size: 14px;
719 font-weight: 700;
720 letter-spacing: -0.012em;
721 color: var(--text-strong);
722 }
723 .admin-automation-heading-sub {
724 font-size: 12px;
725 color: var(--text-muted);
726 margin-left: auto;
727 }
728 .admin-automation-grid {
729 display: grid;
730 grid-template-columns: repeat(4, 1fr);
731 gap: var(--space-3);
732 }
733 @media (max-width: 720px) {
734 .admin-automation-grid { grid-template-columns: 1fr 1fr; }
735 }
736 .admin-automation-stat {
737 padding: var(--space-3) var(--space-3);
738 background: rgba(255,255,255,0.02);
739 border: 1px solid var(--border-subtle);
740 border-radius: 10px;
741 display: flex;
742 flex-direction: column;
743 gap: 4px;
744 }
745 .admin-automation-stat-label {
746 font-size: 10.5px;
747 font-weight: 600;
748 letter-spacing: 0.06em;
749 text-transform: uppercase;
750 color: var(--text-muted);
751 }
752 .admin-automation-stat-value {
753 font-family: var(--font-display);
754 font-size: 28px;
755 font-weight: 800;
756 letter-spacing: -0.022em;
757 line-height: 1;
758 color: var(--text-strong);
759 }
760 .admin-automation-stat-hint {
761 font-size: 11px;
762 color: var(--text-faint);
763 }
07f4b70Claude764`;
765
8929744Claude766/* ─────────────────────────────────────────────────────────────────────────
767 * Per-sub-page scoped CSS — each handler gets its own namespace so they
768 * cannot bleed into each other or back into the shared `.admin-*` panel.
769 *
770 * .adm-users-* /admin/users
771 * .adm-repos-* /admin/repos
772 * .adm-flags-* /admin/flags
773 * .adm-digests-* /admin/digests
774 * .adm-autopilot-* /admin/autopilot
775 *
776 * All five mirror the 2026 design language from /admin and /admin/ops:
777 * - gradient hairline (::before)
778 * - animated radial-gradient orb
779 * - clamp() display headline + gradient-text span
780 * - eyebrow + subtitle
781 * - cards with avatar/icon + mono IDs + action buttons
782 * - filter pills / search bar
783 * - empty state with orb
784 * ───────────────────────────────────────────────────────────────────── */
785const admUsersStyles = `
eed4684Claude786 .adm-users-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude787
788 /* Hero */
789 .adm-users-hero {
790 position: relative;
791 margin-bottom: var(--space-5);
792 padding: var(--space-5) var(--space-6);
793 background: var(--bg-elevated);
794 border: 1px solid var(--border);
795 border-radius: 16px;
796 overflow: hidden;
797 }
798 .adm-users-hero::before {
799 content: '';
800 position: absolute;
801 top: 0; left: 0; right: 0;
802 height: 2px;
6fd5915Claude803 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude804 opacity: 0.7;
805 pointer-events: none;
806 }
807 .adm-users-hero-orb {
808 position: absolute;
809 inset: -20% -10% auto auto;
810 width: 380px; height: 380px;
6fd5915Claude811 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
8929744Claude812 filter: blur(80px);
813 opacity: 0.7;
814 pointer-events: none;
815 z-index: 0;
816 animation: admUsersOrb 14s ease-in-out infinite;
817 }
818 @keyframes admUsersOrb {
819 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
820 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
821 }
822 @media (prefers-reduced-motion: reduce) {
823 .adm-users-hero-orb { animation: none; }
824 }
825 .adm-users-hero-inner {
826 position: relative;
827 z-index: 1;
828 display: flex;
829 align-items: flex-end;
830 justify-content: space-between;
831 gap: var(--space-4);
832 flex-wrap: wrap;
833 }
834 .adm-users-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
835 .adm-users-eyebrow {
836 font-size: 12px;
837 color: var(--text-muted);
838 margin-bottom: var(--space-2);
839 letter-spacing: 0.02em;
840 display: inline-flex;
841 align-items: center;
842 gap: 8px;
843 }
844 .adm-users-eyebrow-pill {
845 display: inline-flex;
846 align-items: center;
847 justify-content: center;
848 width: 22px; height: 22px;
849 border-radius: 6px;
6fd5915Claude850 background: rgba(91,110,232,0.14);
851 color: #5b6ee8;
852 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude853 }
854 .adm-users-title {
855 font-size: clamp(28px, 4vw, 40px);
856 font-family: var(--font-display);
857 font-weight: 800;
858 letter-spacing: -0.028em;
859 line-height: 1.05;
860 margin: 0 0 var(--space-2);
861 color: var(--text-strong);
862 }
863 .adm-users-title-grad {
6fd5915Claude864 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8929744Claude865 -webkit-background-clip: text;
866 background-clip: text;
867 -webkit-text-fill-color: transparent;
868 color: transparent;
869 }
870 .adm-users-sub {
871 font-size: 15px;
872 color: var(--text-muted);
873 margin: 0;
874 line-height: 1.5;
875 max-width: 620px;
876 }
877 .adm-users-back {
878 display: inline-flex;
879 align-items: center;
880 gap: 6px;
881 padding: 7px 12px;
882 font-size: 12.5px;
883 color: var(--text-muted);
884 background: rgba(255,255,255,0.02);
885 border: 1px solid var(--border);
886 border-radius: 8px;
887 text-decoration: none;
888 font-weight: 500;
889 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
890 }
891 .adm-users-back:hover {
892 border-color: var(--border-strong);
893 color: var(--text-strong);
894 background: rgba(255,255,255,0.04);
895 }
896
897 /* Filter bar */
898 .adm-users-filterbar {
899 display: flex;
900 gap: var(--space-3);
901 align-items: center;
902 justify-content: space-between;
903 margin-bottom: var(--space-4);
904 flex-wrap: wrap;
905 }
906 .adm-users-search {
907 position: relative;
908 display: flex;
909 align-items: center;
910 gap: 8px;
911 flex: 1;
912 min-width: 280px;
913 }
914 .adm-users-search-ico {
915 position: absolute;
916 left: 12px;
917 top: 50%;
918 transform: translateY(-50%);
919 color: var(--text-muted);
920 pointer-events: none;
921 display: inline-flex;
922 }
923 .adm-users-input {
924 flex: 1;
925 width: 100%;
926 padding: 10px 12px 10px 36px;
927 font-size: 14px;
928 color: var(--text);
929 background: var(--bg);
930 border: 1px solid var(--border-strong);
931 border-radius: 10px;
932 outline: none;
933 font-family: var(--font-sans);
934 transition: border-color 120ms ease, box-shadow 120ms ease;
935 }
936 .adm-users-input:focus {
937 border-color: var(--border-focus);
6fd5915Claude938 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude939 }
940 .adm-users-pills {
941 display: inline-flex;
942 gap: 6px;
943 flex-wrap: wrap;
944 }
945 .adm-users-pill {
946 display: inline-flex;
947 align-items: center;
948 gap: 6px;
949 padding: 4px 10px;
950 border-radius: 9999px;
951 font-size: 11.5px;
952 font-weight: 600;
953 background: rgba(255,255,255,0.04);
954 color: var(--text-muted);
955 box-shadow: inset 0 0 0 1px var(--border);
956 }
957 .adm-users-pill .dot {
958 width: 6px; height: 6px;
959 border-radius: 9999px;
960 background: currentColor;
961 }
962 .adm-users-pill.is-admin {
6fd5915Claude963 background: rgba(91,110,232,0.16);
8929744Claude964 color: #c5b3ff;
6fd5915Claude965 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude966 }
967
968 /* Buttons */
969 .adm-users-btn {
970 display: inline-flex;
971 align-items: center;
972 gap: 6px;
973 padding: 8px 14px;
974 border-radius: 8px;
975 font-size: 13px;
976 font-weight: 600;
977 text-decoration: none;
978 border: 1px solid var(--border-strong);
979 background: rgba(255,255,255,0.02);
980 color: var(--text);
981 cursor: pointer;
982 font: inherit;
983 font-weight: 600;
984 line-height: 1;
985 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
986 }
6fd5915Claude987 .adm-users-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude988 .adm-users-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
989 .adm-users-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
990 .adm-users-btn-primary {
6fd5915Claude991 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude992 color: #fff;
993 border-color: transparent;
6fd5915Claude994 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude995 }
6fd5915Claude996 .adm-users-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude997 .adm-users-btn-danger {
998 background: transparent;
999 color: #fca5a5;
1000 border-color: rgba(248,113,113,0.40);
1001 }
1002 .adm-users-btn-danger:hover {
1003 background: rgba(248,113,113,0.08);
1004 border-color: rgba(248,113,113,0.70);
1005 color: #fecaca;
1006 }
1007
1008 /* Card grid */
1009 .adm-users-grid {
1010 display: grid;
1011 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1012 gap: var(--space-3);
1013 }
1014 .adm-users-card {
1015 position: relative;
1016 padding: var(--space-4);
1017 background: var(--bg-elevated);
1018 border: 1px solid var(--border);
1019 border-radius: 14px;
1020 display: flex;
1021 flex-direction: column;
1022 gap: var(--space-3);
1023 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1024 }
1025 .adm-users-card:hover {
1026 transform: translateY(-2px);
1027 border-color: var(--border-strong);
1028 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1029 }
1030 .adm-users-card.is-admin {
6fd5915Claude1031 border-color: rgba(91,110,232,0.35);
8929744Claude1032 background:
6fd5915Claude1033 linear-gradient(180deg, rgba(91,110,232,0.04), transparent 60%),
8929744Claude1034 var(--bg-elevated);
1035 }
1036 .adm-users-card-head {
1037 display: flex;
1038 align-items: center;
1039 gap: 12px;
1040 }
1041 .adm-users-avatar {
1042 width: 38px; height: 38px;
1043 border-radius: 9999px;
6fd5915Claude1044 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
8929744Claude1045 color: var(--text-strong);
1046 display: inline-flex;
1047 align-items: center;
1048 justify-content: center;
1049 font-size: 14px;
1050 font-weight: 700;
6fd5915Claude1051 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
8929744Claude1052 flex-shrink: 0;
1053 text-transform: uppercase;
1054 }
1055 .adm-users-avatar.is-admin {
6fd5915Claude1056 background: linear-gradient(135deg, rgba(91,110,232,0.50), rgba(95,143,160,0.35));
8929744Claude1057 color: #fff;
6fd5915Claude1058 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.55), 0 0 12px rgba(91,110,232,0.25);
8929744Claude1059 }
1060 .adm-users-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1061 .adm-users-card-name {
1062 font-size: 14.5px;
1063 font-weight: 700;
1064 color: var(--text-strong);
1065 text-decoration: none;
1066 letter-spacing: -0.005em;
1067 }
1068 .adm-users-card-name:hover { color: var(--accent-hover, var(--accent)); }
1069 .adm-users-card-mono {
1070 font-family: var(--font-mono);
1071 font-size: 11px;
1072 color: var(--text-muted);
1073 background: rgba(255,255,255,0.03);
1074 padding: 1px 6px;
1075 border-radius: 4px;
1076 border: 1px solid var(--border-subtle);
1077 width: fit-content;
1078 }
1079 .adm-users-card-meta {
1080 display: flex;
1081 flex-direction: column;
1082 gap: 6px;
1083 font-size: 12.5px;
1084 }
1085 .adm-users-meta-item { display: flex; gap: 8px; align-items: baseline; min-width: 0; }
1086 .adm-users-meta-key {
1087 font-family: var(--font-mono);
1088 font-size: 10.5px;
1089 text-transform: uppercase;
1090 letter-spacing: 0.06em;
1091 color: var(--text-muted);
1092 flex-shrink: 0;
1093 width: 50px;
1094 }
1095 .adm-users-meta-val { color: var(--text); word-break: break-all; min-width: 0; }
1096 .adm-users-card-actions {
1097 display: flex;
1098 gap: 8px;
1099 flex-wrap: wrap;
1100 margin-top: auto;
1101 }
1102 .adm-users-card-actions form { margin: 0; }
1103
1104 /* Empty state */
1105 .adm-users-empty {
1106 position: relative;
1107 padding: var(--space-12) var(--space-6);
1108 border: 1px dashed var(--border);
1109 border-radius: 16px;
1110 background: var(--bg-elevated);
1111 text-align: center;
1112 overflow: hidden;
1113 }
1114 .adm-users-empty-orb {
1115 position: absolute;
1116 inset: 50% auto auto 50%;
1117 transform: translate(-50%, -50%);
1118 width: 320px; height: 320px;
6fd5915Claude1119 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude1120 filter: blur(60px);
1121 pointer-events: none;
1122 z-index: 0;
1123 }
1124 .adm-users-empty-inner { position: relative; z-index: 1; }
1125 .adm-users-empty-icon {
1126 display: inline-flex;
1127 align-items: center;
1128 justify-content: center;
1129 width: 56px; height: 56px;
1130 border-radius: 16px;
6fd5915Claude1131 background: rgba(91,110,232,0.10);
1132 color: #5b6ee8;
1133 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1134 margin-bottom: var(--space-3);
1135 }
1136 .adm-users-empty-icon svg { width: 24px; height: 24px; }
1137 .adm-users-empty-title {
1138 font-family: var(--font-display);
1139 font-size: 20px;
1140 font-weight: 700;
1141 letter-spacing: -0.015em;
1142 color: var(--text-strong);
1143 margin-bottom: 6px;
1144 }
1145 .adm-users-empty-sub {
1146 color: var(--text-muted);
1147 font-size: 13.5px;
1148 line-height: 1.5;
1149 }
1150 .adm-users-empty-sub code {
1151 font-family: var(--font-mono);
1152 font-size: 12px;
1153 background: var(--bg-tertiary);
1154 padding: 1px 6px;
1155 border-radius: 4px;
1156 color: var(--text);
1157 }
f1dc7c7Claude1158
1159 @media (max-width: 720px) {
1160 .adm-users-wrap { padding: var(--space-4) var(--space-3); }
1161 .adm-users-hero { padding: var(--space-4); }
1162 .adm-users-grid { grid-template-columns: 1fr; }
1163 }
8929744Claude1164`;
1165
1166const admReposStyles = `
eed4684Claude1167 .adm-repos-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1168
1169 .adm-repos-hero {
1170 position: relative;
1171 margin-bottom: var(--space-5);
1172 padding: var(--space-5) var(--space-6);
1173 background: var(--bg-elevated);
1174 border: 1px solid var(--border);
1175 border-radius: 16px;
1176 overflow: hidden;
1177 }
1178 .adm-repos-hero::before {
1179 content: '';
1180 position: absolute;
1181 top: 0; left: 0; right: 0;
1182 height: 2px;
6fd5915Claude1183 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1184 opacity: 0.7;
1185 pointer-events: none;
1186 }
1187 .adm-repos-hero-orb {
1188 position: absolute;
1189 inset: -20% -10% auto auto;
1190 width: 380px; height: 380px;
6fd5915Claude1191 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
8929744Claude1192 filter: blur(80px);
1193 opacity: 0.7;
1194 pointer-events: none;
1195 z-index: 0;
1196 animation: admReposOrb 14s ease-in-out infinite;
1197 }
1198 @keyframes admReposOrb {
1199 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1200 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1201 }
1202 @media (prefers-reduced-motion: reduce) {
1203 .adm-repos-hero-orb { animation: none; }
1204 }
1205 .adm-repos-hero-inner {
1206 position: relative;
1207 z-index: 1;
1208 display: flex;
1209 align-items: flex-end;
1210 justify-content: space-between;
1211 gap: var(--space-4);
1212 flex-wrap: wrap;
1213 }
1214 .adm-repos-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1215 .adm-repos-eyebrow {
1216 font-size: 12px;
1217 color: var(--text-muted);
1218 margin-bottom: var(--space-2);
1219 letter-spacing: 0.02em;
1220 display: inline-flex;
1221 align-items: center;
1222 gap: 8px;
1223 }
1224 .adm-repos-eyebrow-pill {
1225 display: inline-flex;
1226 align-items: center;
1227 justify-content: center;
1228 width: 22px; height: 22px;
1229 border-radius: 6px;
6fd5915Claude1230 background: rgba(91,110,232,0.14);
1231 color: #5b6ee8;
1232 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1233 }
1234 .adm-repos-title {
1235 font-size: clamp(28px, 4vw, 40px);
1236 font-family: var(--font-display);
1237 font-weight: 800;
1238 letter-spacing: -0.028em;
1239 line-height: 1.05;
1240 margin: 0 0 var(--space-2);
1241 color: var(--text-strong);
1242 }
1243 .adm-repos-title-grad {
6fd5915Claude1244 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8929744Claude1245 -webkit-background-clip: text;
1246 background-clip: text;
1247 -webkit-text-fill-color: transparent;
1248 color: transparent;
1249 }
1250 .adm-repos-sub {
1251 font-size: 15px;
1252 color: var(--text-muted);
1253 margin: 0;
1254 line-height: 1.5;
1255 max-width: 620px;
1256 }
1257 .adm-repos-back {
1258 display: inline-flex;
1259 align-items: center;
1260 gap: 6px;
1261 padding: 7px 12px;
1262 font-size: 12.5px;
1263 color: var(--text-muted);
1264 background: rgba(255,255,255,0.02);
1265 border: 1px solid var(--border);
1266 border-radius: 8px;
1267 text-decoration: none;
1268 font-weight: 500;
1269 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1270 }
1271 .adm-repos-back:hover {
1272 border-color: var(--border-strong);
1273 color: var(--text-strong);
1274 background: rgba(255,255,255,0.04);
1275 }
1276
1277 .adm-repos-pills {
1278 display: inline-flex;
1279 gap: 6px;
1280 flex-wrap: wrap;
1281 margin-bottom: var(--space-4);
1282 }
1283 .adm-repos-pill {
1284 display: inline-flex;
1285 align-items: center;
1286 gap: 6px;
1287 padding: 4px 10px;
1288 border-radius: 9999px;
1289 font-size: 11.5px;
1290 font-weight: 600;
1291 background: rgba(255,255,255,0.04);
1292 color: var(--text-muted);
1293 box-shadow: inset 0 0 0 1px var(--border);
1294 }
1295 .adm-repos-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1296 .adm-repos-pill.is-private {
1297 background: rgba(251,191,36,0.10);
1298 color: #fde68a;
1299 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
1300 }
1301 .adm-repos-pill.is-public {
1302 background: rgba(52,211,153,0.10);
1303 color: #86efac;
1304 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
1305 }
1306
1307 .adm-repos-btn {
1308 display: inline-flex;
1309 align-items: center;
1310 gap: 6px;
1311 padding: 8px 14px;
1312 border-radius: 8px;
1313 font-size: 13px;
1314 font-weight: 600;
1315 text-decoration: none;
1316 border: 1px solid var(--border-strong);
1317 background: rgba(255,255,255,0.02);
1318 color: var(--text);
1319 cursor: pointer;
1320 font: inherit;
1321 line-height: 1;
1322 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
1323 }
6fd5915Claude1324 .adm-repos-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude1325 .adm-repos-btn-ghost { background: transparent; color: var(--text-muted); border-color: var(--border); }
1326 .adm-repos-btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.03); border-color: var(--border-strong); }
1327 .adm-repos-btn-danger {
1328 background: transparent;
1329 color: #fca5a5;
1330 border-color: rgba(248,113,113,0.40);
1331 }
1332 .adm-repos-btn-danger:hover {
1333 background: rgba(248,113,113,0.08);
1334 border-color: rgba(248,113,113,0.70);
1335 color: #fecaca;
1336 }
1337
1338 .adm-repos-grid {
1339 display: grid;
1340 grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1341 gap: var(--space-3);
1342 }
1343 .adm-repos-card {
1344 position: relative;
1345 padding: var(--space-4);
1346 background: var(--bg-elevated);
1347 border: 1px solid var(--border);
1348 border-radius: 14px;
1349 display: flex;
1350 flex-direction: column;
1351 gap: var(--space-3);
1352 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
1353 }
1354 .adm-repos-card:hover {
1355 transform: translateY(-2px);
1356 border-color: var(--border-strong);
1357 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
1358 }
1359 .adm-repos-card-head {
1360 display: flex;
1361 align-items: center;
1362 gap: 12px;
1363 }
1364 .adm-repos-icon {
1365 width: 38px; height: 38px;
1366 border-radius: 10px;
6fd5915Claude1367 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
1368 color: #5b6ee8;
8929744Claude1369 display: inline-flex;
1370 align-items: center;
1371 justify-content: center;
6fd5915Claude1372 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1373 flex-shrink: 0;
1374 }
1375 .adm-repos-icon svg { width: 18px; height: 18px; }
1376 .adm-repos-card-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
1377 .adm-repos-card-name {
1378 font-size: 14.5px;
1379 font-weight: 700;
1380 color: var(--text-strong);
1381 text-decoration: none;
1382 letter-spacing: -0.005em;
1383 word-break: break-all;
1384 }
1385 .adm-repos-card-name:hover { color: var(--accent-hover, var(--accent)); }
1386 .adm-repos-card-mono {
1387 font-family: var(--font-mono);
1388 font-size: 11px;
1389 color: var(--text-muted);
1390 background: rgba(255,255,255,0.03);
1391 padding: 1px 6px;
1392 border-radius: 4px;
1393 border: 1px solid var(--border-subtle);
1394 width: fit-content;
1395 }
1396 .adm-repos-card-meta {
1397 display: flex;
1398 gap: var(--space-3);
1399 flex-wrap: wrap;
1400 font-size: 12.5px;
1401 color: var(--text-muted);
1402 }
1403 .adm-repos-meta-item {
1404 display: inline-flex;
1405 align-items: center;
1406 gap: 5px;
1407 }
1408 .adm-repos-meta-item svg { width: 13px; height: 13px; color: var(--text-muted); }
1409 .adm-repos-card-actions {
1410 display: flex;
1411 gap: 8px;
1412 flex-wrap: wrap;
1413 margin-top: auto;
1414 }
1415 .adm-repos-card-actions form { margin: 0; }
1416
1417 .adm-repos-empty {
1418 position: relative;
1419 padding: var(--space-12) var(--space-6);
1420 border: 1px dashed var(--border);
1421 border-radius: 16px;
1422 background: var(--bg-elevated);
1423 text-align: center;
1424 overflow: hidden;
1425 }
1426 .adm-repos-empty-orb {
1427 position: absolute;
1428 inset: 50% auto auto 50%;
1429 transform: translate(-50%, -50%);
1430 width: 320px; height: 320px;
6fd5915Claude1431 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude1432 filter: blur(60px);
1433 pointer-events: none;
1434 z-index: 0;
1435 }
1436 .adm-repos-empty-inner { position: relative; z-index: 1; }
1437 .adm-repos-empty-icon {
1438 display: inline-flex;
1439 align-items: center;
1440 justify-content: center;
1441 width: 56px; height: 56px;
1442 border-radius: 16px;
6fd5915Claude1443 background: rgba(91,110,232,0.10);
1444 color: #5b6ee8;
1445 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1446 margin-bottom: var(--space-3);
1447 }
1448 .adm-repos-empty-icon svg { width: 24px; height: 24px; }
1449 .adm-repos-empty-title {
1450 font-family: var(--font-display);
1451 font-size: 20px;
1452 font-weight: 700;
1453 letter-spacing: -0.015em;
1454 color: var(--text-strong);
1455 margin-bottom: 6px;
1456 }
1457 .adm-repos-empty-sub {
1458 color: var(--text-muted);
1459 font-size: 13.5px;
1460 line-height: 1.5;
1461 }
f1dc7c7Claude1462
1463 @media (max-width: 720px) {
1464 .adm-repos-wrap { padding: var(--space-4) var(--space-3); }
1465 .adm-repos-hero { padding: var(--space-4); }
1466 .adm-repos-grid { grid-template-columns: 1fr; }
1467 }
8929744Claude1468`;
1469
1470const admFlagsStyles = `
eed4684Claude1471 .adm-flags-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1472
1473 .adm-flags-hero {
1474 position: relative;
1475 margin-bottom: var(--space-5);
1476 padding: var(--space-5) var(--space-6);
1477 background: var(--bg-elevated);
1478 border: 1px solid var(--border);
1479 border-radius: 16px;
1480 overflow: hidden;
1481 }
1482 .adm-flags-hero::before {
1483 content: '';
1484 position: absolute;
1485 top: 0; left: 0; right: 0;
1486 height: 2px;
6fd5915Claude1487 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1488 opacity: 0.7;
1489 pointer-events: none;
1490 }
1491 .adm-flags-hero-orb {
1492 position: absolute;
1493 inset: -20% -10% auto auto;
1494 width: 380px; height: 380px;
6fd5915Claude1495 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
8929744Claude1496 filter: blur(80px);
1497 opacity: 0.7;
1498 pointer-events: none;
1499 z-index: 0;
1500 animation: admFlagsOrb 14s ease-in-out infinite;
1501 }
1502 @keyframes admFlagsOrb {
1503 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1504 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1505 }
1506 @media (prefers-reduced-motion: reduce) {
1507 .adm-flags-hero-orb { animation: none; }
1508 }
1509 .adm-flags-hero-inner {
1510 position: relative;
1511 z-index: 1;
1512 display: flex;
1513 align-items: flex-end;
1514 justify-content: space-between;
1515 gap: var(--space-4);
1516 flex-wrap: wrap;
1517 }
1518 .adm-flags-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1519 .adm-flags-eyebrow {
1520 font-size: 12px;
1521 color: var(--text-muted);
1522 margin-bottom: var(--space-2);
1523 letter-spacing: 0.02em;
1524 display: inline-flex;
1525 align-items: center;
1526 gap: 8px;
1527 }
1528 .adm-flags-eyebrow-pill {
1529 display: inline-flex;
1530 align-items: center;
1531 justify-content: center;
1532 width: 22px; height: 22px;
1533 border-radius: 6px;
6fd5915Claude1534 background: rgba(91,110,232,0.14);
1535 color: #5b6ee8;
1536 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1537 }
1538 .adm-flags-title {
1539 font-size: clamp(28px, 4vw, 40px);
1540 font-family: var(--font-display);
1541 font-weight: 800;
1542 letter-spacing: -0.028em;
1543 line-height: 1.05;
1544 margin: 0 0 var(--space-2);
1545 color: var(--text-strong);
1546 }
1547 .adm-flags-title-grad {
6fd5915Claude1548 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8929744Claude1549 -webkit-background-clip: text;
1550 background-clip: text;
1551 -webkit-text-fill-color: transparent;
1552 color: transparent;
1553 }
1554 .adm-flags-sub {
1555 font-size: 15px;
1556 color: var(--text-muted);
1557 margin: 0;
1558 line-height: 1.5;
1559 max-width: 620px;
1560 }
1561 .adm-flags-sub code {
1562 font-family: var(--font-mono);
1563 font-size: 13px;
1564 background: var(--bg-tertiary);
1565 padding: 1px 5px;
1566 border-radius: 4px;
1567 color: var(--text);
1568 }
1569 .adm-flags-back {
1570 display: inline-flex;
1571 align-items: center;
1572 gap: 6px;
1573 padding: 7px 12px;
1574 font-size: 12.5px;
1575 color: var(--text-muted);
1576 background: rgba(255,255,255,0.02);
1577 border: 1px solid var(--border);
1578 border-radius: 8px;
1579 text-decoration: none;
1580 font-weight: 500;
1581 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1582 }
1583 .adm-flags-back:hover {
1584 border-color: var(--border-strong);
1585 color: var(--text-strong);
1586 background: rgba(255,255,255,0.04);
1587 }
1588
1589 .adm-flags-card {
1590 background: var(--bg-elevated);
1591 border: 1px solid var(--border);
1592 border-radius: 14px;
1593 overflow: hidden;
1594 }
1595 .adm-flags-card-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1596 .adm-flags-card-foot {
1597 padding: var(--space-3) var(--space-5);
1598 border-top: 1px solid var(--border);
1599 background: rgba(255,255,255,0.012);
1600 display: flex;
1601 justify-content: flex-end;
1602 gap: var(--space-2);
1603 align-items: center;
1604 flex-wrap: wrap;
1605 }
1606 .adm-flags-foot-hint {
1607 margin-right: auto;
1608 font-size: 12.5px;
1609 color: var(--text-muted);
1610 }
1611
1612 .adm-flags-field {
1613 display: flex;
1614 flex-direction: column;
1615 gap: 6px;
1616 padding: var(--space-3);
1617 border: 1px solid var(--border-subtle);
1618 border-radius: 12px;
1619 background: rgba(255,255,255,0.015);
1620 transition: border-color 120ms ease, background 120ms ease;
1621 }
1622 .adm-flags-field:hover { border-color: var(--border); background: rgba(255,255,255,0.025); }
1623 .adm-flags-field-head {
1624 display: flex;
1625 align-items: center;
1626 gap: 8px;
1627 }
1628 .adm-flags-key {
1629 font-family: var(--font-mono);
1630 font-size: 12.5px;
1631 font-weight: 600;
1632 color: var(--text-strong);
1633 letter-spacing: -0.005em;
1634 }
1635 .adm-flags-mono {
1636 font-family: var(--font-mono);
1637 font-size: 10.5px;
1638 color: var(--text-muted);
1639 background: rgba(255,255,255,0.04);
1640 padding: 1px 6px;
1641 border-radius: 4px;
1642 border: 1px solid var(--border-subtle);
1643 }
1644 .adm-flags-input {
1645 width: 100%;
1646 padding: 9px 12px;
1647 font-size: 13.5px;
1648 color: var(--text);
1649 background: var(--bg);
1650 border: 1px solid var(--border-strong);
1651 border-radius: 8px;
1652 outline: none;
1653 font-family: var(--font-mono);
1654 transition: border-color 120ms ease, box-shadow 120ms ease;
1655 box-sizing: border-box;
1656 }
1657 .adm-flags-input:focus {
1658 border-color: var(--border-focus);
6fd5915Claude1659 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude1660 }
1661 .adm-flags-hint {
1662 font-size: 11.5px;
1663 color: var(--text-muted);
1664 margin-top: 2px;
1665 line-height: 1.45;
1666 }
1667 .adm-flags-hint code {
1668 font-family: var(--font-mono);
1669 font-size: 11.5px;
1670 background: var(--bg-tertiary);
1671 padding: 1px 5px;
1672 border-radius: 4px;
1673 color: var(--text);
1674 }
1675
1676 .adm-flags-btn {
1677 display: inline-flex;
1678 align-items: center;
1679 gap: 6px;
1680 padding: 9px 16px;
1681 border-radius: 10px;
1682 font-size: 13px;
1683 font-weight: 600;
1684 text-decoration: none;
1685 border: 1px solid transparent;
1686 cursor: pointer;
1687 font: inherit;
1688 line-height: 1;
1689 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1690 }
1691 .adm-flags-btn-primary {
6fd5915Claude1692 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude1693 color: #fff;
6fd5915Claude1694 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude1695 }
6fd5915Claude1696 .adm-flags-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
f1dc7c7Claude1697
1698 @media (max-width: 720px) {
1699 .adm-flags-wrap { padding: var(--space-4) var(--space-3); }
1700 .adm-flags-hero { padding: var(--space-4); }
1701 }
8929744Claude1702`;
1703
1704const admDigestsStyles = `
eed4684Claude1705 .adm-digests-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude1706
1707 .adm-digests-hero {
1708 position: relative;
1709 margin-bottom: var(--space-5);
1710 padding: var(--space-5) var(--space-6);
1711 background: var(--bg-elevated);
1712 border: 1px solid var(--border);
1713 border-radius: 16px;
1714 overflow: hidden;
1715 }
1716 .adm-digests-hero::before {
1717 content: '';
1718 position: absolute;
1719 top: 0; left: 0; right: 0;
1720 height: 2px;
6fd5915Claude1721 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude1722 opacity: 0.7;
1723 pointer-events: none;
1724 }
1725 .adm-digests-hero-orb {
1726 position: absolute;
1727 inset: -20% -10% auto auto;
1728 width: 380px; height: 380px;
6fd5915Claude1729 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
8929744Claude1730 filter: blur(80px);
1731 opacity: 0.7;
1732 pointer-events: none;
1733 z-index: 0;
1734 animation: admDigestsOrb 14s ease-in-out infinite;
1735 }
1736 @keyframes admDigestsOrb {
1737 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
1738 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
1739 }
1740 @media (prefers-reduced-motion: reduce) {
1741 .adm-digests-hero-orb { animation: none; }
1742 }
1743 .adm-digests-hero-inner {
1744 position: relative;
1745 z-index: 1;
1746 display: flex;
1747 align-items: flex-end;
1748 justify-content: space-between;
1749 gap: var(--space-4);
1750 flex-wrap: wrap;
1751 }
1752 .adm-digests-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
1753 .adm-digests-eyebrow {
1754 font-size: 12px;
1755 color: var(--text-muted);
1756 margin-bottom: var(--space-2);
1757 letter-spacing: 0.02em;
1758 display: inline-flex;
1759 align-items: center;
1760 gap: 8px;
1761 }
1762 .adm-digests-eyebrow-pill {
1763 display: inline-flex;
1764 align-items: center;
1765 justify-content: center;
1766 width: 22px; height: 22px;
1767 border-radius: 6px;
6fd5915Claude1768 background: rgba(91,110,232,0.14);
1769 color: #5b6ee8;
1770 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude1771 }
1772 .adm-digests-title {
1773 font-size: clamp(28px, 4vw, 40px);
1774 font-family: var(--font-display);
1775 font-weight: 800;
1776 letter-spacing: -0.028em;
1777 line-height: 1.05;
1778 margin: 0 0 var(--space-2);
1779 color: var(--text-strong);
1780 }
1781 .adm-digests-title-grad {
6fd5915Claude1782 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8929744Claude1783 -webkit-background-clip: text;
1784 background-clip: text;
1785 -webkit-text-fill-color: transparent;
1786 color: transparent;
1787 }
1788 .adm-digests-sub {
1789 font-size: 15px;
1790 color: var(--text-muted);
1791 margin: 0;
1792 line-height: 1.5;
1793 max-width: 620px;
1794 }
1795 .adm-digests-back {
1796 display: inline-flex;
1797 align-items: center;
1798 gap: 6px;
1799 padding: 7px 12px;
1800 font-size: 12.5px;
1801 color: var(--text-muted);
1802 background: rgba(255,255,255,0.02);
1803 border: 1px solid var(--border);
1804 border-radius: 8px;
1805 text-decoration: none;
1806 font-weight: 500;
1807 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
1808 }
1809 .adm-digests-back:hover {
1810 border-color: var(--border-strong);
1811 color: var(--text-strong);
1812 background: rgba(255,255,255,0.04);
1813 }
1814
1815 .adm-digests-banner {
1816 margin-bottom: var(--space-4);
1817 padding: 10px 14px;
1818 border-radius: 10px;
1819 font-size: 13.5px;
1820 border: 1px solid var(--border);
1821 background: rgba(255,255,255,0.025);
1822 color: var(--text);
1823 }
1824 .adm-digests-banner.is-ok {
1825 border-color: rgba(52,211,153,0.40);
1826 background: rgba(52,211,153,0.08);
1827 color: #bbf7d0;
1828 }
1829 .adm-digests-banner.is-error {
1830 border-color: rgba(248,113,113,0.40);
1831 background: rgba(248,113,113,0.08);
1832 color: #fecaca;
1833 }
1834
1835 .adm-digests-pills {
1836 display: inline-flex;
1837 gap: 6px;
1838 flex-wrap: wrap;
1839 margin-bottom: var(--space-4);
1840 }
1841 .adm-digests-pill {
1842 display: inline-flex;
1843 align-items: center;
1844 gap: 6px;
1845 padding: 4px 10px;
1846 border-radius: 9999px;
1847 font-size: 11.5px;
1848 font-weight: 600;
1849 background: rgba(255,255,255,0.04);
1850 color: var(--text-muted);
1851 box-shadow: inset 0 0 0 1px var(--border);
1852 }
1853 .adm-digests-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
1854 .adm-digests-pill.is-on {
1855 background: rgba(52,211,153,0.14);
1856 color: #6ee7b7;
1857 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
1858 }
1859
1860 .adm-digests-section {
1861 margin-bottom: var(--space-5);
1862 background: var(--bg-elevated);
1863 border: 1px solid var(--border);
1864 border-radius: 14px;
1865 overflow: hidden;
1866 }
1867 .adm-digests-section-head {
1868 padding: var(--space-4) var(--space-5);
1869 border-bottom: 1px solid var(--border);
1870 display: flex;
1871 align-items: center;
1872 gap: 10px;
1873 flex-wrap: wrap;
1874 }
1875 .adm-digests-section-icon {
1876 display: inline-flex;
1877 align-items: center;
1878 justify-content: center;
1879 width: 26px; height: 26px;
1880 border-radius: 8px;
6fd5915Claude1881 background: rgba(91,110,232,0.12);
1882 color: #5b6ee8;
1883 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude1884 flex-shrink: 0;
1885 }
1886 .adm-digests-section-title {
1887 margin: 0;
1888 font-family: var(--font-display);
1889 font-size: 17px;
1890 font-weight: 700;
1891 letter-spacing: -0.018em;
1892 color: var(--text-strong);
1893 }
1894 .adm-digests-section-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-muted); }
1895 .adm-digests-section-body { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1896
1897 .adm-digests-input {
1898 width: 100%;
1899 padding: 9px 12px;
1900 font-size: 13.5px;
1901 color: var(--text);
1902 background: var(--bg);
1903 border: 1px solid var(--border-strong);
1904 border-radius: 8px;
1905 outline: none;
1906 font-family: var(--font-mono);
1907 transition: border-color 120ms ease, box-shadow 120ms ease;
1908 box-sizing: border-box;
1909 max-width: 280px;
1910 }
1911 .adm-digests-input:focus {
1912 border-color: var(--border-focus);
6fd5915Claude1913 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
8929744Claude1914 }
1915 .adm-digests-form-row {
1916 display: flex;
1917 gap: 8px;
1918 align-items: center;
1919 flex-wrap: wrap;
1920 }
1921
1922 .adm-digests-btn {
1923 display: inline-flex;
1924 align-items: center;
1925 gap: 6px;
1926 padding: 9px 16px;
1927 border-radius: 10px;
1928 font-size: 13px;
1929 font-weight: 600;
1930 text-decoration: none;
1931 border: 1px solid var(--border-strong);
1932 background: rgba(255,255,255,0.02);
1933 color: var(--text);
1934 cursor: pointer;
1935 font: inherit;
1936 line-height: 1;
1937 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
1938 }
6fd5915Claude1939 .adm-digests-btn:hover { border-color: rgba(91,110,232,0.45); background: rgba(91,110,232,0.06); color: var(--text-strong); }
8929744Claude1940 .adm-digests-btn-primary {
6fd5915Claude1941 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude1942 color: #fff;
1943 border-color: transparent;
6fd5915Claude1944 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude1945 }
6fd5915Claude1946 .adm-digests-btn-primary:hover { color: #fff; transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude1947
1948 .adm-digests-section-divider {
1949 border-top: 1px solid var(--border-subtle);
1950 padding-top: var(--space-3);
1951 margin-top: var(--space-2);
1952 }
1953 .adm-digests-divider-hint {
1954 font-size: 12.5px;
1955 color: var(--text-muted);
1956 margin-bottom: 8px;
1957 }
1958
1959 .adm-digests-h3 {
1960 display: flex;
1961 align-items: baseline;
1962 justify-content: space-between;
1963 gap: var(--space-3);
1964 margin: var(--space-5) 0 var(--space-3);
1965 }
1966 .adm-digests-h3 h3 {
1967 font-family: var(--font-display);
1968 font-size: 16px;
1969 font-weight: 700;
1970 letter-spacing: -0.014em;
1971 margin: 0;
1972 color: var(--text-strong);
1973 }
1974 .adm-digests-h3-meta { font-size: 12px; color: var(--text-muted); }
1975
1976 .adm-digests-grid {
1977 display: grid;
1978 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
1979 gap: var(--space-3);
1980 }
1981 .adm-digests-card {
1982 padding: var(--space-3) var(--space-4);
1983 background: var(--bg-elevated);
1984 border: 1px solid var(--border);
1985 border-radius: 12px;
1986 display: flex;
1987 align-items: center;
1988 gap: 12px;
1989 transition: transform 160ms ease, border-color 160ms ease;
1990 }
1991 .adm-digests-card:hover { transform: translateY(-1px); border-color: var(--border-strong); }
1992 .adm-digests-avatar {
1993 width: 36px; height: 36px;
1994 border-radius: 9999px;
6fd5915Claude1995 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.22));
8929744Claude1996 color: var(--text-strong);
1997 display: inline-flex;
1998 align-items: center;
1999 justify-content: center;
2000 font-size: 13px;
2001 font-weight: 700;
6fd5915Claude2002 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
8929744Claude2003 flex-shrink: 0;
2004 text-transform: uppercase;
2005 }
2006 .adm-digests-card-text { min-width: 0; flex: 1; }
2007 .adm-digests-card-name {
2008 font-size: 13.5px;
2009 font-weight: 600;
2010 color: var(--text-strong);
2011 text-decoration: none;
2012 }
2013 .adm-digests-card-name:hover { color: var(--accent-hover, var(--accent)); }
2014 .adm-digests-card-sent {
2015 margin-top: 2px;
2016 font-size: 11.5px;
2017 color: var(--text-muted);
2018 font-family: var(--font-mono);
2019 }
2020
2021 .adm-digests-empty {
2022 position: relative;
2023 padding: var(--space-12) var(--space-6);
2024 border: 1px dashed var(--border);
2025 border-radius: 16px;
2026 background: var(--bg-elevated);
2027 text-align: center;
2028 overflow: hidden;
2029 }
2030 .adm-digests-empty-orb {
2031 position: absolute;
2032 inset: 50% auto auto 50%;
2033 transform: translate(-50%, -50%);
2034 width: 320px; height: 320px;
6fd5915Claude2035 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude2036 filter: blur(60px);
2037 pointer-events: none;
2038 z-index: 0;
2039 }
2040 .adm-digests-empty-inner { position: relative; z-index: 1; }
2041 .adm-digests-empty-icon {
2042 display: inline-flex;
2043 align-items: center;
2044 justify-content: center;
2045 width: 56px; height: 56px;
2046 border-radius: 16px;
6fd5915Claude2047 background: rgba(91,110,232,0.10);
2048 color: #5b6ee8;
2049 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude2050 margin-bottom: var(--space-3);
2051 }
2052 .adm-digests-empty-icon svg { width: 24px; height: 24px; }
2053 .adm-digests-empty-title {
2054 font-family: var(--font-display);
2055 font-size: 20px;
2056 font-weight: 700;
2057 letter-spacing: -0.015em;
2058 color: var(--text-strong);
2059 margin-bottom: 6px;
2060 }
2061 .adm-digests-empty-sub {
2062 color: var(--text-muted);
2063 font-size: 13.5px;
2064 line-height: 1.5;
2065 }
f1dc7c7Claude2066
2067 @media (max-width: 720px) {
2068 .adm-digests-wrap { padding: var(--space-4) var(--space-3); }
2069 .adm-digests-hero { padding: var(--space-4); }
2070 .adm-digests-grid { grid-template-columns: 1fr; }
2071 }
8929744Claude2072`;
2073
2074const admAutopilotStyles = `
eed4684Claude2075 .adm-autopilot-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
8929744Claude2076
2077 .adm-autopilot-hero {
2078 position: relative;
2079 margin-bottom: var(--space-5);
2080 padding: var(--space-5) var(--space-6);
2081 background: var(--bg-elevated);
2082 border: 1px solid var(--border);
2083 border-radius: 16px;
2084 overflow: hidden;
2085 }
2086 .adm-autopilot-hero::before {
2087 content: '';
2088 position: absolute;
2089 top: 0; left: 0; right: 0;
2090 height: 2px;
6fd5915Claude2091 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
8929744Claude2092 opacity: 0.7;
2093 pointer-events: none;
2094 }
2095 .adm-autopilot-hero-orb {
2096 position: absolute;
2097 inset: -20% -10% auto auto;
2098 width: 380px; height: 380px;
6fd5915Claude2099 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
8929744Claude2100 filter: blur(80px);
2101 opacity: 0.7;
2102 pointer-events: none;
2103 z-index: 0;
2104 animation: admAutopilotOrb 14s ease-in-out infinite;
2105 }
2106 @keyframes admAutopilotOrb {
2107 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2108 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2109 }
2110 @media (prefers-reduced-motion: reduce) {
2111 .adm-autopilot-hero-orb { animation: none; }
2112 }
2113 .adm-autopilot-hero-inner {
2114 position: relative;
2115 z-index: 1;
2116 display: flex;
2117 align-items: flex-end;
2118 justify-content: space-between;
2119 gap: var(--space-4);
2120 flex-wrap: wrap;
2121 }
2122 .adm-autopilot-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2123 .adm-autopilot-eyebrow {
2124 font-size: 12px;
2125 color: var(--text-muted);
2126 margin-bottom: var(--space-2);
2127 letter-spacing: 0.02em;
2128 display: inline-flex;
2129 align-items: center;
2130 gap: 8px;
2131 }
2132 .adm-autopilot-eyebrow-pill {
2133 display: inline-flex;
2134 align-items: center;
2135 justify-content: center;
2136 width: 22px; height: 22px;
2137 border-radius: 6px;
6fd5915Claude2138 background: rgba(91,110,232,0.14);
2139 color: #5b6ee8;
2140 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
8929744Claude2141 }
2142 .adm-autopilot-title {
2143 font-size: clamp(28px, 4vw, 40px);
2144 font-family: var(--font-display);
2145 font-weight: 800;
2146 letter-spacing: -0.028em;
2147 line-height: 1.05;
2148 margin: 0 0 var(--space-2);
2149 color: var(--text-strong);
2150 }
2151 .adm-autopilot-title-grad {
6fd5915Claude2152 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
8929744Claude2153 -webkit-background-clip: text;
2154 background-clip: text;
2155 -webkit-text-fill-color: transparent;
2156 color: transparent;
2157 }
2158 .adm-autopilot-sub {
2159 font-size: 15px;
2160 color: var(--text-muted);
2161 margin: 0;
2162 line-height: 1.5;
2163 max-width: 620px;
2164 }
2165 .adm-autopilot-back {
2166 display: inline-flex;
2167 align-items: center;
2168 gap: 6px;
2169 padding: 7px 12px;
2170 font-size: 12.5px;
2171 color: var(--text-muted);
2172 background: rgba(255,255,255,0.02);
2173 border: 1px solid var(--border);
2174 border-radius: 8px;
2175 text-decoration: none;
2176 font-weight: 500;
2177 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2178 }
2179 .adm-autopilot-back:hover {
2180 border-color: var(--border-strong);
2181 color: var(--text-strong);
2182 background: rgba(255,255,255,0.04);
2183 }
2184
2185 .adm-autopilot-banner {
2186 margin-bottom: var(--space-4);
2187 padding: 10px 14px;
2188 border-radius: 10px;
2189 font-size: 13.5px;
2190 border: 1px solid var(--border);
2191 background: rgba(255,255,255,0.025);
2192 color: var(--text);
2193 }
2194 .adm-autopilot-banner.is-ok {
2195 border-color: rgba(52,211,153,0.40);
2196 background: rgba(52,211,153,0.08);
2197 color: #bbf7d0;
2198 }
2199 .adm-autopilot-banner.is-error {
2200 border-color: rgba(248,113,113,0.40);
2201 background: rgba(248,113,113,0.08);
2202 color: #fecaca;
2203 }
2204
2205 .adm-autopilot-statgrid {
2206 display: grid;
2207 grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
2208 gap: var(--space-3);
2209 margin-bottom: var(--space-5);
2210 }
2211 .adm-autopilot-stat {
2212 position: relative;
2213 padding: var(--space-4);
2214 background: var(--bg-elevated);
2215 border: 1px solid var(--border);
2216 border-radius: 14px;
2217 overflow: hidden;
2218 transition: transform 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
2219 }
2220 .adm-autopilot-stat:hover {
2221 transform: translateY(-2px);
2222 border-color: var(--border-strong);
2223 box-shadow: 0 10px 28px -16px rgba(0,0,0,0.55);
2224 }
2225 .adm-autopilot-stat-head {
2226 display: flex;
2227 align-items: center;
2228 justify-content: space-between;
2229 margin-bottom: var(--space-2);
2230 }
2231 .adm-autopilot-stat-label {
2232 font-size: 11px;
2233 font-weight: 600;
2234 letter-spacing: 0.08em;
2235 text-transform: uppercase;
2236 color: var(--text-muted);
2237 }
2238 .adm-autopilot-stat-icon {
2239 display: inline-flex;
2240 align-items: center;
2241 justify-content: center;
2242 width: 26px; height: 26px;
2243 border-radius: 8px;
6fd5915Claude2244 background: rgba(91,110,232,0.12);
2245 color: #5b6ee8;
2246 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude2247 }
2248 .adm-autopilot-stat-value {
2249 font-family: var(--font-display);
2250 font-size: 32px;
2251 font-weight: 800;
2252 letter-spacing: -0.028em;
2253 line-height: 1;
2254 color: var(--text-strong);
2255 }
2256 .adm-autopilot-stat-value.is-mono {
2257 font-family: var(--font-mono);
2258 font-size: 13px;
2259 line-height: 1.3;
2260 word-break: break-all;
2261 }
2262 .adm-autopilot-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
2263 .adm-autopilot-pill {
2264 display: inline-flex;
2265 align-items: center;
2266 gap: 6px;
2267 padding: 2px 8px;
2268 border-radius: 9999px;
2269 font-size: 10.5px;
2270 font-weight: 600;
2271 letter-spacing: 0.04em;
2272 text-transform: uppercase;
2273 }
2274 .adm-autopilot-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
2275 .adm-autopilot-pill.is-on {
2276 background: rgba(52,211,153,0.14);
2277 color: #6ee7b7;
2278 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2279 }
2280 .adm-autopilot-pill.is-off {
2281 background: rgba(255,255,255,0.04);
2282 color: var(--text-muted);
2283 box-shadow: inset 0 0 0 1px var(--border);
2284 }
2285
2286 .adm-autopilot-actions {
2287 display: flex;
2288 align-items: center;
2289 gap: 12px;
2290 flex-wrap: wrap;
2291 margin-bottom: var(--space-5);
2292 }
2293 .adm-autopilot-actions form { margin: 0; }
2294 .adm-autopilot-action-hint {
2295 color: var(--text-muted);
2296 font-size: 13px;
2297 }
2298
2299 .adm-autopilot-btn {
2300 display: inline-flex;
2301 align-items: center;
2302 gap: 6px;
2303 padding: 9px 16px;
2304 border-radius: 10px;
2305 font-size: 13px;
2306 font-weight: 600;
2307 text-decoration: none;
2308 border: 1px solid transparent;
2309 cursor: pointer;
2310 font: inherit;
2311 line-height: 1;
2312 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
2313 }
2314 .adm-autopilot-btn-primary {
6fd5915Claude2315 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
8929744Claude2316 color: #fff;
6fd5915Claude2317 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
8929744Claude2318 }
6fd5915Claude2319 .adm-autopilot-btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px -8px rgba(91,110,232,0.55); }
8929744Claude2320
2321 .adm-autopilot-h3 {
2322 display: flex;
2323 align-items: baseline;
2324 justify-content: space-between;
2325 gap: var(--space-3);
2326 margin: 0 0 var(--space-3);
2327 }
2328 .adm-autopilot-h3 h3 {
2329 font-family: var(--font-display);
2330 font-size: 16px;
2331 font-weight: 700;
2332 letter-spacing: -0.014em;
2333 margin: 0;
2334 color: var(--text-strong);
2335 }
2336 .adm-autopilot-h3-meta { font-size: 12px; color: var(--text-muted); }
2337
2338 .adm-autopilot-tasks {
2339 display: grid;
2340 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2341 gap: var(--space-3);
2342 }
2343 .adm-autopilot-task {
2344 padding: var(--space-3) var(--space-4);
2345 background: var(--bg-elevated);
2346 border: 1px solid var(--border);
2347 border-radius: 12px;
2348 display: flex;
2349 flex-direction: column;
2350 gap: 8px;
2351 transition: transform 160ms ease, border-color 160ms ease;
2352 }
2353 .adm-autopilot-task:hover { transform: translateY(-1px); border-color: var(--border-strong); }
2354 .adm-autopilot-task.is-ok { border-color: rgba(52,211,153,0.28); }
2355 .adm-autopilot-task.is-fail { border-color: rgba(248,113,113,0.32); }
2356 .adm-autopilot-task-head {
2357 display: flex;
2358 align-items: center;
2359 gap: 10px;
2360 }
2361 .adm-autopilot-task-light {
2362 flex-shrink: 0;
2363 width: 10px; height: 10px;
2364 border-radius: 9999px;
2365 background: #6b7280;
2366 box-shadow: 0 0 0 3px rgba(107,114,128,0.16);
2367 }
2368 .adm-autopilot-task-light.is-ok {
2369 background: #34d399;
2370 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 8px rgba(52,211,153,0.45);
2371 }
2372 .adm-autopilot-task-light.is-fail {
2373 background: #f87171;
2374 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 10px rgba(248,113,113,0.50);
2375 animation: admApPulse 1.8s ease-in-out infinite;
2376 }
2377 @keyframes admApPulse {
2378 0%, 100% { opacity: 1; transform: scale(1); }
2379 50% { opacity: 0.7; transform: scale(0.92); }
2380 }
2381 @media (prefers-reduced-motion: reduce) {
2382 .adm-autopilot-task-light.is-fail { animation: none; }
2383 }
2384 .adm-autopilot-task-name {
2385 font-family: var(--font-mono);
2386 font-size: 12.5px;
2387 font-weight: 600;
2388 color: var(--text-strong);
2389 word-break: break-word;
2390 flex: 1;
2391 min-width: 0;
2392 }
2393 .adm-autopilot-task-status {
2394 display: inline-flex;
2395 align-items: center;
2396 gap: 4px;
2397 padding: 2px 8px;
2398 border-radius: 9999px;
2399 font-size: 10.5px;
2400 font-weight: 600;
2401 letter-spacing: 0.04em;
2402 text-transform: uppercase;
2403 flex-shrink: 0;
2404 }
2405 .adm-autopilot-task-status.is-ok {
2406 background: rgba(52,211,153,0.14);
2407 color: #6ee7b7;
2408 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
2409 }
2410 .adm-autopilot-task-status.is-fail {
2411 background: rgba(248,113,113,0.12);
2412 color: #fecaca;
2413 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
2414 }
2415 .adm-autopilot-task-meta {
2416 display: flex;
2417 align-items: center;
2418 justify-content: space-between;
2419 gap: 8px;
2420 font-size: 11.5px;
2421 color: var(--text-muted);
2422 font-family: var(--font-mono);
2423 }
2424 .adm-autopilot-task-err {
2425 font-size: 11.5px;
2426 color: #fecaca;
2427 line-height: 1.5;
2428 background: rgba(248,113,113,0.06);
2429 border: 1px solid rgba(248,113,113,0.20);
2430 padding: 6px 8px;
2431 border-radius: 6px;
2432 word-break: break-word;
2433 }
2434
2435 .adm-autopilot-empty {
2436 position: relative;
2437 padding: var(--space-12) var(--space-6);
2438 border: 1px dashed var(--border);
2439 border-radius: 16px;
2440 background: var(--bg-elevated);
2441 text-align: center;
2442 overflow: hidden;
2443 }
2444 .adm-autopilot-empty-orb {
2445 position: absolute;
2446 inset: 50% auto auto 50%;
2447 transform: translate(-50%, -50%);
2448 width: 320px; height: 320px;
6fd5915Claude2449 background: radial-gradient(circle, rgba(91,110,232,0.16), rgba(95,143,160,0.08) 45%, transparent 70%);
8929744Claude2450 filter: blur(60px);
2451 pointer-events: none;
2452 z-index: 0;
2453 }
2454 .adm-autopilot-empty-inner { position: relative; z-index: 1; }
2455 .adm-autopilot-empty-icon {
2456 display: inline-flex;
2457 align-items: center;
2458 justify-content: center;
2459 width: 56px; height: 56px;
2460 border-radius: 16px;
6fd5915Claude2461 background: rgba(91,110,232,0.10);
2462 color: #5b6ee8;
2463 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
8929744Claude2464 margin-bottom: var(--space-3);
2465 }
2466 .adm-autopilot-empty-icon svg { width: 24px; height: 24px; }
2467 .adm-autopilot-empty-title {
2468 font-family: var(--font-display);
2469 font-size: 20px;
2470 font-weight: 700;
2471 letter-spacing: -0.015em;
2472 color: var(--text-strong);
2473 margin-bottom: 6px;
2474 }
2475 .adm-autopilot-empty-sub {
2476 color: var(--text-muted);
2477 font-size: 13.5px;
2478 line-height: 1.5;
2479 }
2480
2481 .adm-autopilot-foot {
2482 margin-top: var(--space-5);
2483 padding: var(--space-3) var(--space-4);
2484 border: 1px solid var(--border-subtle);
2485 background: rgba(255,255,255,0.015);
2486 border-radius: 10px;
2487 color: var(--text-muted);
2488 font-size: 12.5px;
2489 }
2490 .adm-autopilot-foot code {
2491 font-family: var(--font-mono);
2492 font-size: 12px;
2493 background: var(--bg-tertiary);
2494 padding: 1px 5px;
2495 border-radius: 4px;
2496 color: var(--text);
2497 }
f1dc7c7Claude2498
2499 @media (max-width: 720px) {
2500 .adm-autopilot-wrap { padding: var(--space-4) var(--space-3); }
2501 .adm-autopilot-hero { padding: var(--space-4); }
2502 .adm-autopilot-statgrid { grid-template-columns: 1fr 1fr; }
2503 .adm-autopilot-tasks { grid-template-columns: 1fr; }
2504 }
8929744Claude2505`;
2506
b218e63Claude2507const admAnalyticsStyles = `
2508 .adm-analytics-wrap { max-width: 1400px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
2509
2510 .adm-analytics-hero {
2511 position: relative;
2512 margin-bottom: var(--space-5);
2513 padding: var(--space-5) var(--space-6);
2514 background: var(--bg-elevated);
2515 border: 1px solid var(--border);
2516 border-radius: 16px;
2517 overflow: hidden;
2518 }
2519 .adm-analytics-hero::before {
2520 content: '';
2521 position: absolute;
2522 top: 0; left: 0; right: 0;
2523 height: 2px;
6fd5915Claude2524 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b218e63Claude2525 opacity: 0.7;
2526 pointer-events: none;
2527 }
2528 .adm-analytics-hero-orb {
2529 position: absolute;
2530 inset: -20% -10% auto auto;
2531 width: 380px; height: 380px;
6fd5915Claude2532 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
b218e63Claude2533 filter: blur(80px);
2534 opacity: 0.7;
2535 pointer-events: none;
2536 z-index: 0;
2537 animation: admAnalyticsOrb 14s ease-in-out infinite;
2538 }
2539 @keyframes admAnalyticsOrb {
2540 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
2541 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
2542 }
2543 @media (prefers-reduced-motion: reduce) { .adm-analytics-hero-orb { animation: none; } }
2544 .adm-analytics-hero-inner {
2545 position: relative; z-index: 1;
2546 display: flex; align-items: flex-end; justify-content: space-between;
2547 gap: var(--space-4); flex-wrap: wrap;
2548 }
2549 .adm-analytics-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
2550 .adm-analytics-eyebrow {
2551 font-size: 12px; color: var(--text-muted); margin-bottom: var(--space-2);
2552 letter-spacing: 0.02em; display: inline-flex; align-items: center; gap: 8px;
2553 }
2554 .adm-analytics-eyebrow-pill {
2555 display: inline-flex; align-items: center; justify-content: center;
2556 width: 22px; height: 22px; border-radius: 6px;
6fd5915Claude2557 background: rgba(91,110,232,0.14); color: #5b6ee8;
2558 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
b218e63Claude2559 }
2560 .adm-analytics-title {
2561 font-size: clamp(28px, 4vw, 36px); font-family: var(--font-display);
2562 font-weight: 800; letter-spacing: -0.028em; line-height: 1.05;
2563 margin: 0 0 var(--space-2); color: var(--text-strong);
2564 }
2565 .adm-analytics-title-grad {
6fd5915Claude2566 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b218e63Claude2567 -webkit-background-clip: text; background-clip: text;
2568 -webkit-text-fill-color: transparent; color: transparent;
2569 }
2570 .adm-analytics-sub { font-size: 14px; color: var(--text-muted); margin: 0; line-height: 1.5; }
2571 .adm-analytics-back {
2572 display: inline-flex; align-items: center; gap: 6px;
2573 padding: 7px 12px; font-size: 12.5px; color: var(--text-muted);
2574 background: rgba(255,255,255,0.02); border: 1px solid var(--border);
2575 border-radius: 8px; text-decoration: none; font-weight: 500;
2576 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
2577 }
2578 .adm-analytics-back:hover { border-color: var(--border-strong); color: var(--text-strong); background: rgba(255,255,255,0.04); }
2579
2580 .adm-analytics-statgrid {
2581 display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
2582 gap: var(--space-3); margin-bottom: var(--space-5);
2583 }
2584 .adm-analytics-stat {
2585 padding: var(--space-4); background: var(--bg-elevated);
2586 border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
2587 }
2588 .adm-analytics-stat-label {
2589 font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
2590 text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px;
2591 }
2592 .adm-analytics-stat-value {
2593 font-family: var(--font-display); font-size: 28px; font-weight: 800;
2594 letter-spacing: -0.024em; line-height: 1; color: var(--text-strong);
2595 }
2596 .adm-analytics-stat-hint { margin-top: 6px; font-size: 12px; color: var(--text-faint); }
2597
2598 .adm-analytics-h3 {
2599 display: flex; align-items: baseline; justify-content: space-between;
2600 gap: var(--space-3); margin: var(--space-5) 0 var(--space-3);
2601 }
2602 .adm-analytics-h3 h3 {
2603 font-family: var(--font-display); font-size: 16px; font-weight: 700;
2604 letter-spacing: -0.014em; margin: 0; color: var(--text-strong);
2605 }
2606 .adm-analytics-h3-meta { font-size: 12px; color: var(--text-muted); }
2607
2608 .adm-analytics-table {
2609 width: 100%; border-collapse: collapse;
2610 background: var(--bg-elevated); border: 1px solid var(--border);
2611 border-radius: 14px; overflow: hidden;
2612 }
2613 .adm-analytics-table thead th {
2614 text-align: left; font-size: 11px; font-weight: 600;
2615 letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted);
2616 padding: 10px 16px; background: rgba(255,255,255,0.015);
2617 border-bottom: 1px solid var(--border);
2618 }
2619 .adm-analytics-table thead th:not(:first-child) { text-align: right; }
2620 .adm-analytics-table tbody td {
2621 padding: 10px 16px; border-bottom: 1px solid var(--border-subtle);
2622 font-size: 13px; color: var(--text); vertical-align: middle;
2623 }
2624 .adm-analytics-table tbody td:not(:first-child) { text-align: right; font-family: var(--font-mono); font-size: 12px; }
2625 .adm-analytics-table tbody tr:last-child td { border-bottom: none; }
2626 .adm-analytics-table tbody tr:hover td { background: rgba(255,255,255,0.018); }
2627 .adm-analytics-table code { font-family: var(--font-mono); font-size: 12px; color: var(--text-strong); }
2628 .adm-analytics-empty {
2629 padding: var(--space-6); text-align: center; color: var(--text-muted);
2630 font-size: 13.5px; background: var(--bg-elevated);
2631 border: 1px dashed var(--border); border-radius: 14px;
2632 }
2633
2634 /* bar chart cells */
2635 .adm-analytics-bar-cell { display: flex; align-items: center; gap: 8px; }
2636 .adm-analytics-bar-track {
2637 flex: 1; height: 6px; background: var(--bg-tertiary);
2638 border-radius: 9999px; overflow: hidden; min-width: 60px;
2639 }
2640 .adm-analytics-bar-fill {
2641 height: 100%; border-radius: 9999px;
6fd5915Claude2642 background: linear-gradient(90deg, #5b6ee8, #5f8fa0);
b218e63Claude2643 }
2644 .adm-analytics-bar-label { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
2645
2646 .adm-analytics-pill {
2647 display: inline-flex; align-items: center; gap: 4px;
2648 padding: 2px 8px; border-radius: 9999px; font-size: 10.5px;
2649 font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;
6fd5915Claude2650 background: rgba(91,110,232,0.12); color: #c5b3ff;
2651 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
b218e63Claude2652 }
2653 .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); }
2654 .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); }
2655
2656 @media (max-width: 720px) {
2657 .adm-analytics-wrap { padding: var(--space-4) var(--space-3); }
2658 .adm-analytics-hero { padding: var(--space-4); }
2659 .adm-analytics-statgrid { grid-template-columns: 1fr 1fr; }
2660 .adm-analytics-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
2661 }
2662`;
2663
07f4b70Claude2664/** Inline-SVG icons (no external deps). Stroke-based, currentColor. */
2665const Icons = {
2666 shield: (
2667 <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">
2668 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2669 </svg>
2670 ),
2671 users: (
2672 <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">
2673 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
2674 <circle cx="9" cy="7" r="4" />
2675 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
2676 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
2677 </svg>
2678 ),
2679 repo: (
2680 <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">
2681 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
2682 <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" />
2683 </svg>
2684 ),
2685 starShield: (
2686 <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">
2687 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
2688 <path d="m9 12 2 2 4-4" />
2689 </svg>
2690 ),
2691 ops: (
2692 <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">
2693 <circle cx="12" cy="12" r="3" />
2694 <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" />
2695 </svg>
2696 ),
2697 pulse: (
2698 <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">
2699 <path d="M22 12h-4l-3 9L9 3l-3 9H2" />
2700 </svg>
2701 ),
2702 flag: (
2703 <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">
2704 <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" />
2705 <line x1="4" y1="22" x2="4" y2="15" />
2706 </svg>
2707 ),
2708 mail: (
2709 <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">
2710 <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" />
2711 <polyline points="22,6 12,13 2,6" />
2712 </svg>
2713 ),
2714 google: (
2715 <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">
2716 <circle cx="12" cy="12" r="10" />
2717 <path d="M12 8v8" /><path d="M8 12h8" />
2718 </svg>
2719 ),
2720 github: (
2721 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2722 <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" />
2723 </svg>
2724 ),
2725 sso: (
2726 <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">
2727 <rect x="3" y="11" width="18" height="11" rx="2" />
2728 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
2729 </svg>
2730 ),
2731 bot: (
2732 <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">
2733 <rect x="3" y="7" width="18" height="13" rx="2" />
2734 <circle cx="9" cy="13" r="1" />
2735 <circle cx="15" cy="13" r="1" />
2736 <path d="M12 3v4" />
2737 </svg>
2738 ),
2739 refresh: (
2740 <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">
2741 <polyline points="23 4 23 10 17 10" />
2742 <polyline points="1 20 1 14 7 14" />
2743 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10" />
2744 <path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14" />
2745 </svg>
2746 ),
2747 arrowLeft: (
2748 <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">
2749 <line x1="19" y1="12" x2="5" y2="12" />
2750 <polyline points="12 19 5 12 12 5" />
2751 </svg>
2752 ),
b218e63Claude2753 dollarSign: (
2754 <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">
2755 <line x1="12" y1="1" x2="12" y2="23" />
2756 <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
2757 </svg>
2758 ),
2759 trendingUp: (
2760 <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">
2761 <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
2762 <polyline points="17 6 23 6 23 12" />
2763 </svg>
2764 ),
509c376Claude2765 key: (
2766 <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">
2767 <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" />
2768 </svg>
2769 ),
07f4b70Claude2770};
2771
2772/** First-letter avatar helper. */
2773function initials(s: string): string {
2774 return (s || "?").trim().charAt(0).toUpperCase() || "?";
2775}
2776
8f50ed0Claude2777async function gate(c: any): Promise<{ user: any } | Response> {
2778 const user = c.get("user");
2779 if (!user) return c.redirect("/login?next=/admin");
2780 if (!(await isSiteAdmin(user.id))) {
2781 return c.html(
2782 <Layout title="Forbidden" user={user}>
07f4b70Claude2783 <div class="admin-403">
8f50ed0Claude2784 <h2>403 — Not a site admin</h2>
2785 <p>You don't have permission to view this page.</p>
2786 </div>
07f4b70Claude2787 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude2788 </Layout>,
2789 403
2790 );
2791 }
2792 return { user };
2793}
2794
2795admin.get("/admin", async (c) => {
2796 const g = await gate(c);
2797 if (g instanceof Response) return g;
2798 const { user } = g;
2799
6dec21bClaude2800 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
2801
2802 const [[uc], [rc], recent, admins] = await Promise.all([
2803 db.select({ n: sql<number>`count(*)::int` }).from(users),
2804 db.select({ n: sql<number>`count(*)::int` }).from(repositories),
2805 db
2806 .select({
2807 id: users.id,
2808 username: users.username,
2809 createdAt: users.createdAt,
2810 })
2811 .from(users)
2812 .orderBy(desc(users.createdAt))
2813 .limit(10),
2814 listSiteAdmins(),
2815 ]);
2816
2817 // Automation activity stats (last 24h) — wrapped in try/catch so a missing
2818 // table never breaks the dashboard.
2819 let aiReviewsCount = 0;
2820 let autoMergesCount = 0;
2821 let issueTriagedCount = 0;
2822 let ciAutofixCount = 0;
2823 try {
2824 const [aiReviewsRow] = await db
2825 .select({ n: sql<number>`count(*)::int` })
2826 .from(prComments)
2827 .where(
2828 sql`${prComments.isAiReview} = true AND ${prComments.createdAt} > ${since24h}`
2829 );
2830 const [autoMergesRow] = await db
2831 .select({ n: sql<number>`count(*)::int` })
2832 .from(auditLog)
2833 .where(
2834 sql`${auditLog.action} = 'auto_merge.merged' AND ${auditLog.createdAt} > ${since24h}`
2835 );
2836 const [issueTriagedRow] = await db
2837 .select({ n: sql<number>`count(*)::int` })
2838 .from(issueComments)
2839 .where(
2840 sql`${issueComments.body} LIKE '%gluecron:issue-triage%' AND ${issueComments.createdAt} > ${since24h}`
2841 );
2842 const [ciAutofixRow] = await db
2843 .select({ n: sql<number>`count(*)::int` })
2844 .from(auditLog)
2845 .where(
2846 sql`${auditLog.action} LIKE 'ci_autofix%' AND ${auditLog.createdAt} > ${since24h}`
2847 );
2848 aiReviewsCount = Number(aiReviewsRow?.n || 0);
2849 autoMergesCount = Number(autoMergesRow?.n || 0);
2850 issueTriagedCount = Number(issueTriagedRow?.n || 0);
2851 ciAutofixCount = Number(ciAutofixRow?.n || 0);
2852 } catch (_) {
2853 // Stats unavailable — defaults (0) already set above
2854 }
8f50ed0Claude2855
988380aClaude2856 const msg = c.req.query("result") || c.req.query("error");
2857 const isErr = !!c.req.query("error");
2858
07f4b70Claude2859 const userCount = Number(uc?.n || 0);
2860 const repoCount = Number(rc?.n || 0);
2861 const adminCount = admins.length;
2862
8f50ed0Claude2863 return c.html(
2864 <Layout title="Admin — Gluecron" user={user}>
07f4b70Claude2865 <div class="admin-wrap">
2866 <section class="admin-hero">
2867 <div class="admin-hero-bg" aria-hidden="true">
2868 <div class="admin-hero-orb" />
2869 </div>
2870 <div class="admin-hero-inner">
2871 <div class="admin-hero-eyebrow">
2872 <span class="admin-shield" aria-hidden="true">{Icons.shield}</span>
2873 Site administration ·{" "}
2874 <span class="admin-who">{user.username}</span>
2875 </div>
2876 <h2 class="admin-hero-title">
2877 <span class="admin-hero-title-grad">Site admin</span>.
2878 </h2>
2879 <p class="admin-hero-sub">
2880 {userCount} user{userCount === 1 ? "" : "s"} ·{" "}
2881 {repoCount} repo{repoCount === 1 ? "" : "s"} ·{" "}
2882 {adminCount} site admin{adminCount === 1 ? "" : "s"}.{" "}
2883 Operations, flags, digests, and autopilot — all in one place.
2884 </p>
2885 </div>
2886 </section>
8f50ed0Claude2887
07f4b70Claude2888 {msg && (
2889 <div class={"admin-banner " + (isErr ? "is-error" : "is-ok")}>
2890 {decodeURIComponent(msg)}
2891 </div>
2892 )}
988380aClaude2893
07f4b70Claude2894 <div class="admin-stat-grid">
2895 <div class="admin-stat">
2896 <div class="admin-stat-head">
2897 <span class="admin-stat-label">Users</span>
2898 <span class="admin-stat-icon">{Icons.users}</span>
2899 </div>
2900 <div class="admin-stat-value">{userCount}</div>
2901 <div class="admin-stat-hint">Registered accounts</div>
8f50ed0Claude2902 </div>
07f4b70Claude2903 <div class="admin-stat">
2904 <div class="admin-stat-head">
2905 <span class="admin-stat-label">Repos</span>
2906 <span class="admin-stat-icon">{Icons.repo}</span>
2907 </div>
2908 <div class="admin-stat-value">{repoCount}</div>
2909 <div class="admin-stat-hint">Public + private</div>
8f50ed0Claude2910 </div>
07f4b70Claude2911 <div class="admin-stat">
2912 <div class="admin-stat-head">
2913 <span class="admin-stat-label">Admins</span>
2914 <span class="admin-stat-icon">{Icons.starShield}</span>
2915 </div>
2916 <div class="admin-stat-value">{adminCount}</div>
2917 <div class="admin-stat-hint">Site admins</div>
8f50ed0Claude2918 </div>
2919 </div>
2920
07f4b70Claude2921 <div class="admin-actions">
2922 <a href="/admin/ops" class="admin-action is-primary">
2923 <span class="admin-action-icon">{Icons.ops}</span>
2924 Operations
2925 </a>
509c376Claude2926 <a href="/admin/integrations" class="admin-action is-primary">
2927 <span class="admin-action-icon">{Icons.key}</span>
2928 Integrations
2929 </a>
cf793f9Claude2930 <a href="/admin/health" class="admin-action is-primary">
07f4b70Claude2931 <span class="admin-action-icon">{Icons.pulse}</span>
cf793f9Claude2932 Health (traffic lights)
2933 </a>
2934 <a href="/admin/deploys" class="admin-action is-primary">
2935 <span class="admin-action-icon">{Icons.ops}</span>
2936 Deploys
2937 </a>
2938 <a href="/admin/diagnose" class="admin-action">
2939 <span class="admin-action-icon">{Icons.pulse}</span>
2940 Diagnose
2941 </a>
2942 <a href="/admin/self-host" class="admin-action">
2943 <span class="admin-action-icon">{Icons.ops}</span>
2944 Self-host status
2945 </a>
2946 <a href="/admin/status" class="admin-action">
2947 <span class="admin-action-icon">{Icons.pulse}</span>
2948 Live activity stream
07f4b70Claude2949 </a>
2950 <a href="/admin/users" class="admin-action">
2951 <span class="admin-action-icon">{Icons.users}</span>
2952 Manage users
2953 </a>
2954 <a href="/admin/repos" class="admin-action">
2955 <span class="admin-action-icon">{Icons.repo}</span>
2956 Manage repos
2957 </a>
2958 <a href="/admin/flags" class="admin-action">
2959 <span class="admin-action-icon">{Icons.flag}</span>
2960 Site flags
2961 </a>
2962 <a href="/admin/digests" class="admin-action">
2963 <span class="admin-action-icon">{Icons.mail}</span>
2964 Email digests
2965 </a>
2966 <a href="/admin/google-oauth" class="admin-action">
2967 <span class="admin-action-icon">{Icons.google}</span>
2968 Sign in with Google
2969 </a>
2970 <a href="/admin/github-oauth" class="admin-action">
2971 <span class="admin-action-icon">{Icons.github}</span>
2972 Sign in with GitHub
2973 </a>
2974 <a href="/admin/sso" class="admin-action">
2975 <span class="admin-action-icon">{Icons.sso}</span>
2976 Enterprise SSO
2977 </a>
b218e63Claude2978 <a href="/admin/ai-costs" class="admin-action is-primary">
2979 <span class="admin-action-icon">{Icons.dollarSign}</span>
2980 AI cost breakdown
2981 </a>
2982 <a href="/admin/growth" class="admin-action is-primary">
2983 <span class="admin-action-icon">{Icons.trendingUp}</span>
2984 User growth
2985 </a>
c6018a5Claude2986 <a href="/admin/autopilot" class="admin-action" title="CI healer, patch generator, proactive monitor, AI build tasks">
07f4b70Claude2987 <span class="admin-action-icon">{Icons.bot}</span>
2988 Autopilot
2989 </a>
c6018a5Claude2990 <a href="/admin/diagnose" class="admin-action" title="Live status of the AI CI healer, patch generator, and proactive monitor">
2991 <span class="admin-action-icon">{Icons.bot}</span>
2992 AI background tasks
2993 </a>
662ce86Claude2994 <a href="/connect/claude" class="admin-action is-primary">
2995 <span class="admin-action-icon">{Icons.bot}</span>
2996 Connect Claude
2997 </a>
7293c67Claude2998 <a href="/admin/deletions" class="admin-action">
2999 <span class="admin-action-icon">{Icons.flag}</span>
3000 GDPR Deletions
3001 </a>
3002 <a href="/admin/stripe" class="admin-action">
3003 <span class="admin-action-icon">{Icons.key}</span>
3004 Stripe Sync
3005 </a>
07f4b70Claude3006 <form
3007 method="post"
3008 action="/admin/demo/reseed"
3009 class="admin-action-form"
3010 >
3011 <button
3012 class="admin-action"
3013 type="submit"
3014 title="Idempotently (re)create demo user + 3 sample repos"
3015 >
3016 <span class="admin-action-icon">{Icons.refresh}</span>
3017 Reseed demo
3018 </button>
3019 </form>
3020 </div>
8f50ed0Claude3021
6dec21bClaude3022 <div class="admin-h3">
3023 <h3>System Health</h3>
3024 <span class="admin-h3-meta">Ops &amp; security pages</span>
3025 </div>
3026 <div class="admin-actions" style="margin-bottom:var(--space-5)">
3027 <a href="/admin/env-health" class="admin-action is-primary" title="Check which features are enabled via env vars">
3028 <span class="admin-action-icon">{Icons.pulse}</span>
3029 Env health
3030 </a>
3031 <a href="/admin/advancement" class="admin-action" title="Platform advancement tracker">
3032 <span class="admin-action-icon">{Icons.trendingUp}</span>
3033 Advancement
3034 </a>
3035 <a href="/admin/security" class="admin-action" title="Security overview">
3036 <span class="admin-action-icon">{Icons.starShield}</span>
3037 Security
3038 </a>
3039 </div>
3040
3041 <div class="admin-automation-tile">
3042 <div class="admin-automation-heading">
3043 <span class="admin-automation-heading-icon" aria-hidden="true">{Icons.bot}</span>
3044 <span class="admin-automation-heading-label">Automation Activity</span>
3045 <span class="admin-automation-heading-sub">last 24h</span>
3046 </div>
3047 <div class="admin-automation-grid">
3048 <div class="admin-automation-stat">
3049 <div class="admin-automation-stat-label">AI Reviews</div>
3050 <div class="admin-automation-stat-value">{aiReviewsCount}</div>
3051 <div class="admin-automation-stat-hint">PR comments posted</div>
3052 </div>
3053 <div class="admin-automation-stat">
3054 <div class="admin-automation-stat-label">Auto-merges</div>
3055 <div class="admin-automation-stat-value">{autoMergesCount}</div>
3056 <div class="admin-automation-stat-hint">PRs auto-merged</div>
3057 </div>
3058 <div class="admin-automation-stat">
3059 <div class="admin-automation-stat-label">Issues Triaged</div>
3060 <div class="admin-automation-stat-value">{issueTriagedCount}</div>
3061 <div class="admin-automation-stat-hint">AI triage comments</div>
3062 </div>
3063 <div class="admin-automation-stat">
3064 <div class="admin-automation-stat-label">CI Autofixes</div>
3065 <div class="admin-automation-stat-value">{ciAutofixCount}</div>
3066 <div class="admin-automation-stat-hint">Autofix runs</div>
3067 </div>
3068 </div>
3069 </div>
3070
07f4b70Claude3071 <div class="admin-h3">
3072 <h3>Recent signups</h3>
3073 <span class="admin-h3-meta">
3074 {recent.length} most-recent
3075 </span>
3076 </div>
3077 <div class="admin-list" style="margin-bottom:20px">
3078 {recent.length === 0 ? (
3079 <div class="admin-list-empty">No users yet.</div>
3080 ) : (
3081 recent.map((u) => (
3082 <div class="admin-list-row">
3083 <div class="admin-list-main">
3084 <span class="admin-avatar" aria-hidden="true">{initials(u.username)}</span>
3085 <div class="admin-row-text">
3086 <a href={`/${u.username}`} class="admin-row-title">
3087 {u.username}
3088 </a>
3089 <div class="admin-row-sub">
3090 <span>Joined</span>
3091 <span>
3092 {u.createdAt
3093 ? new Date(u.createdAt as unknown as string).toLocaleString()
3094 : ""}
3095 </span>
3096 </div>
3097 </div>
3098 </div>
3099 </div>
3100 ))
3101 )}
3102 </div>
8f50ed0Claude3103
07f4b70Claude3104 <div class="admin-h3">
3105 <h3>Site admins</h3>
3106 <span class="admin-h3-meta">
3107 {adminCount} active
3108 </span>
3109 </div>
3110 <div class="admin-list">
3111 {admins.length === 0 ? (
3112 <div class="admin-list-empty">
3113 No admins (bootstrap mode — oldest user is admin).
8f50ed0Claude3114 </div>
07f4b70Claude3115 ) : (
3116 admins.map((a) => (
3117 <div class="admin-list-row">
3118 <div class="admin-list-main">
3119 <span class="admin-avatar is-admin" aria-hidden="true">{initials(a.username)}</span>
3120 <div class="admin-row-text">
3121 <a href={`/${a.username}`} class="admin-row-title">
3122 {a.username}
3123 </a>
3124 <div class="admin-row-sub">
3125 <span class="admin-pill is-admin">
3126 <span class="dot" aria-hidden="true" /> Site admin
3127 </span>
3128 <span>
3129 Granted{" "}
3130 {a.grantedAt
3131 ? new Date(a.grantedAt as unknown as string).toLocaleDateString()
3132 : "—"}
3133 </span>
3134 </div>
3135 </div>
3136 </div>
3137 </div>
3138 ))
3139 )}
3140 </div>
8f50ed0Claude3141 </div>
07f4b70Claude3142 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8f50ed0Claude3143 </Layout>
3144 );
3145});
3146
3147// ----- Users -----
3148
3149admin.get("/admin/users", async (c) => {
3150 const g = await gate(c);
3151 if (g instanceof Response) return g;
3152 const { user } = g;
3153 const q = c.req.query("q") || "";
3154 const rows = await db
3155 .select({
3156 id: users.id,
3157 username: users.username,
3158 email: users.email,
3159 createdAt: users.createdAt,
3160 })
3161 .from(users)
3162 .where(
3163 q
3164 ? or(ilike(users.username, `%${q}%`), ilike(users.email, `%${q}%`))!
3165 : sql`1=1`
3166 )
3167 .orderBy(desc(users.createdAt))
3168 .limit(200);
3169
3170 const adminIds = new Set((await listSiteAdmins()).map((a) => a.userId));
8929744Claude3171 const adminCount = rows.filter((u) => adminIds.has(u.id)).length;
8f50ed0Claude3172
3173 return c.html(
3174 <Layout title="Admin — Users" user={user}>
8929744Claude3175 <div class="adm-users-wrap">
3176 <section class="adm-users-hero">
3177 <div class="adm-users-hero-orb" aria-hidden="true" />
3178 <div class="adm-users-hero-inner">
3179 <div class="adm-users-hero-text">
3180 <div class="adm-users-eyebrow">
3181 <span class="adm-users-eyebrow-pill" aria-hidden="true">{Icons.users}</span>
3182 Site admin · Users
3183 </div>
3184 <h1 class="adm-users-title">
3185 <span class="adm-users-title-grad">Users</span>.
3186 </h1>
3187 <p class="adm-users-sub">
3188 Search, audit, and grant or revoke the site-admin flag.
3189 Showing up to 200 accounts ordered by signup recency.
3190 </p>
3191 </div>
3192 <a href="/admin" class="adm-users-back">
07f4b70Claude3193 {Icons.arrowLeft} Back
3194 </a>
3195 </div>
3196 </section>
3197
8929744Claude3198 <div class="adm-users-filterbar">
3199 <form method="get" action="/admin/users" class="adm-users-search">
3200 <span class="adm-users-search-ico" aria-hidden="true">
3201 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
3202 <circle cx="11" cy="11" r="8" />
3203 <line x1="21" y1="21" x2="16.65" y2="16.65" />
3204 </svg>
3205 </span>
3206 <input
3207 type="text"
3208 name="q"
3209 value={q}
3210 placeholder="Search username or email"
3211 aria-label="Search username or email"
3212 class="adm-users-input"
3213 />
3214 <button type="submit" class="adm-users-btn">Search</button>
3215 {q && (
3216 <a href="/admin/users" class="adm-users-btn adm-users-btn-ghost">Clear</a>
3217 )}
3218 </form>
3219 <div class="adm-users-pills">
3220 <span class="adm-users-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3221 <span class="adm-users-pill is-admin"><span class="dot" aria-hidden="true" />{adminCount} admin{adminCount === 1 ? "" : "s"}</span>
3222 </div>
3223 </div>
07f4b70Claude3224
8929744Claude3225 {rows.length === 0 ? (
3226 <div class="adm-users-empty">
3227 <div class="adm-users-empty-orb" aria-hidden="true" />
3228 <div class="adm-users-empty-inner">
3229 <div class="adm-users-empty-icon" aria-hidden="true">{Icons.users}</div>
3230 <div class="adm-users-empty-title">No users found</div>
3231 <div class="adm-users-empty-sub">
3232 {q ? <>No accounts match <code>{q}</code>. Try a different query.</> : "There are no registered accounts yet."}
3233 </div>
3234 </div>
3235 </div>
3236 ) : (
3237 <div class="adm-users-grid">
3238 {rows.map((u) => {
07f4b70Claude3239 const isAdmin = adminIds.has(u.id);
3240 return (
8929744Claude3241 <div class={"adm-users-card" + (isAdmin ? " is-admin" : "")}>
3242 <div class="adm-users-card-head">
3243 <span class={"adm-users-avatar" + (isAdmin ? " is-admin" : "")} aria-hidden="true">
07f4b70Claude3244 {initials(u.username)}
8f50ed0Claude3245 </span>
8929744Claude3246 <div class="adm-users-card-id">
3247 <a href={`/${u.username}`} class="adm-users-card-name">{u.username}</a>
3248 <code class="adm-users-card-mono" title={u.id}>{u.id.slice(0, 8)}</code>
07f4b70Claude3249 </div>
8929744Claude3250 {isAdmin && (
3251 <span class="adm-users-pill is-admin" style="margin-left:auto"><span class="dot" aria-hidden="true" />Admin</span>
3252 )}
3253 </div>
3254 <div class="adm-users-card-meta">
3255 <span class="adm-users-meta-item">
3256 <span class="adm-users-meta-key">Email</span>
3257 <span class="adm-users-meta-val">{u.email}</span>
3258 </span>
3259 {u.createdAt && (
3260 <span class="adm-users-meta-item">
3261 <span class="adm-users-meta-key">Joined</span>
3262 <span class="adm-users-meta-val">
3263 {new Date(u.createdAt as unknown as string).toLocaleDateString()}
3264 </span>
3265 </span>
3266 )}
3267 </div>
3268 <div class="adm-users-card-actions">
3269 <form
3270 method="post"
3271 action={`/admin/users/${u.id}/admin`}
3272 onsubmit={
3273 isAdmin
3274 ? "return confirm('Revoke site admin?')"
3275 : "return confirm('Grant site admin?')"
3276 }
3277 >
3278 <button
3279 type="submit"
3280 class={"adm-users-btn " + (isAdmin ? "adm-users-btn-danger" : "adm-users-btn-primary")}
3281 >
3282 {isAdmin ? "Revoke admin" : "Grant admin"}
3283 </button>
3284 </form>
3285 <a href={`/${u.username}`} class="adm-users-btn adm-users-btn-ghost">
3286 View profile
3287 </a>
07f4b70Claude3288 </div>
8f50ed0Claude3289 </div>
07f4b70Claude3290 );
8929744Claude3291 })}
3292 </div>
3293 )}
8f50ed0Claude3294 </div>
07f4b70Claude3295 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3296 <style dangerouslySetInnerHTML={{ __html: admUsersStyles }} />
8f50ed0Claude3297 </Layout>
3298 );
3299});
3300
3301admin.post("/admin/users/:id/admin", async (c) => {
3302 const g = await gate(c);
3303 if (g instanceof Response) return g;
3304 const { user } = g;
3305 const id = c.req.param("id");
3306 const admins = await listSiteAdmins();
3307 const isAlready = admins.some((a) => a.userId === id);
3308 if (isAlready) {
3309 await revokeSiteAdmin(id);
3310 await audit({
3311 userId: user.id,
3312 action: "site_admin.revoke",
3313 targetType: "user",
3314 targetId: id,
3315 });
3316 } else {
3317 await grantSiteAdmin(id, user.id);
3318 await audit({
3319 userId: user.id,
3320 action: "site_admin.grant",
3321 targetType: "user",
3322 targetId: id,
3323 });
3324 }
3325 return c.redirect("/admin/users");
3326});
3327
3328// ----- Repos -----
3329
3330admin.get("/admin/repos", async (c) => {
3331 const g = await gate(c);
3332 if (g instanceof Response) return g;
3333 const { user } = g;
3334 const rows = await db
3335 .select({
3336 id: repositories.id,
3337 name: repositories.name,
3338 ownerUsername: users.username,
0316dbbClaude3339 isPrivate: repositories.isPrivate,
8f50ed0Claude3340 createdAt: repositories.createdAt,
3341 starCount: repositories.starCount,
3342 })
3343 .from(repositories)
3344 .innerJoin(users, eq(repositories.ownerId, users.id))
3345 .orderBy(desc(repositories.createdAt))
3346 .limit(200);
3347
8929744Claude3348 const privateCount = rows.filter((r) => r.isPrivate).length;
3349 const publicCount = rows.length - privateCount;
3350
8f50ed0Claude3351 return c.html(
3352 <Layout title="Admin — Repos" user={user}>
8929744Claude3353 <div class="adm-repos-wrap">
3354 <section class="adm-repos-hero">
3355 <div class="adm-repos-hero-orb" aria-hidden="true" />
3356 <div class="adm-repos-hero-inner">
3357 <div class="adm-repos-hero-text">
3358 <div class="adm-repos-eyebrow">
3359 <span class="adm-repos-eyebrow-pill" aria-hidden="true">{Icons.repo}</span>
3360 Site admin · Repositories
3361 </div>
3362 <h1 class="adm-repos-title">
3363 <span class="adm-repos-title-grad">Repositories</span>.
3364 </h1>
3365 <p class="adm-repos-sub">
3366 Every repository on the platform — public and private.
3367 Delete is irreversible and audit-logged.
3368 </p>
3369 </div>
3370 <a href="/admin" class="adm-repos-back">
07f4b70Claude3371 {Icons.arrowLeft} Back
3372 </a>
3373 </div>
3374 </section>
3375
8929744Claude3376 <div class="adm-repos-pills">
3377 <span class="adm-repos-pill"><span class="dot" aria-hidden="true" />{rows.length} shown</span>
3378 <span class="adm-repos-pill is-public"><span class="dot" aria-hidden="true" />{publicCount} public</span>
3379 <span class="adm-repos-pill is-private"><span class="dot" aria-hidden="true" />{privateCount} private</span>
3380 </div>
3381
3382 {rows.length === 0 ? (
3383 <div class="adm-repos-empty">
3384 <div class="adm-repos-empty-orb" aria-hidden="true" />
3385 <div class="adm-repos-empty-inner">
3386 <div class="adm-repos-empty-icon" aria-hidden="true">{Icons.repo}</div>
3387 <div class="adm-repos-empty-title">No repositories yet</div>
3388 <div class="adm-repos-empty-sub">
3389 When users create their first repos, they'll appear here.
3390 </div>
3391 </div>
3392 </div>
3393 ) : (
3394 <div class="adm-repos-grid">
3395 {rows.map((r) => (
3396 <div class="adm-repos-card">
3397 <div class="adm-repos-card-head">
3398 <span class="adm-repos-icon" aria-hidden="true">{Icons.repo}</span>
3399 <div class="adm-repos-card-title">
07f4b70Claude3400 <a
3401 href={`/${r.ownerUsername}/${r.name}`}
8929744Claude3402 class="adm-repos-card-name"
07f4b70Claude3403 >
3404 {r.ownerUsername}/{r.name}
3405 </a>
8929744Claude3406 <code class="adm-repos-card-mono" title={r.id}>{r.id.slice(0, 8)}</code>
07f4b70Claude3407 </div>
8929744Claude3408 <span
3409 class={
3410 "adm-repos-pill " +
3411 (r.isPrivate ? "is-private" : "is-public")
3412 }
3413 style="margin-left:auto"
3414 >
3415 <span class="dot" aria-hidden="true" />
3416 {r.isPrivate ? "private" : "public"}
3417 </span>
3418 </div>
3419 <div class="adm-repos-card-meta">
3420 <span class="adm-repos-meta-item">
3421 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3422 <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" />
3423 </svg>
3424 {r.starCount} star{r.starCount === 1 ? "" : "s"}
3425 </span>
3426 {r.createdAt && (
3427 <span class="adm-repos-meta-item">
3428 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3429 <rect x="3" y="4" width="18" height="18" rx="2" />
3430 <line x1="16" y1="2" x2="16" y2="6" />
3431 <line x1="8" y1="2" x2="8" y2="6" />
3432 <line x1="3" y1="10" x2="21" y2="10" />
3433 </svg>
3434 {new Date(r.createdAt as unknown as string).toLocaleDateString()}
3435 </span>
3436 )}
3437 </div>
3438 <div class="adm-repos-card-actions">
3439 <a href={`/${r.ownerUsername}/${r.name}`} class="adm-repos-btn adm-repos-btn-ghost">
3440 Open repo
3441 </a>
3442 <form
3443 method="post"
3444 action={`/admin/repos/${r.id}/delete`}
3445 onsubmit="return confirm('Delete repository permanently? This cannot be undone.')"
3446 >
3447 <button type="submit" class="adm-repos-btn adm-repos-btn-danger">
3448 Delete
3449 </button>
3450 </form>
8f50ed0Claude3451 </div>
3452 </div>
8929744Claude3453 ))}
3454 </div>
3455 )}
8f50ed0Claude3456 </div>
07f4b70Claude3457 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3458 <style dangerouslySetInnerHTML={{ __html: admReposStyles }} />
8f50ed0Claude3459 </Layout>
3460 );
3461});
3462
3463admin.post("/admin/repos/:id/delete", async (c) => {
3464 const g = await gate(c);
3465 if (g instanceof Response) return g;
3466 const { user } = g;
3467 const id = c.req.param("id");
3468 try {
3469 await db.delete(repositories).where(eq(repositories.id, id));
3470 } catch (err) {
3471 console.error("[admin] repo delete:", err);
3472 }
3473 await audit({
3474 userId: user.id,
3475 action: "admin.repo.delete",
3476 targetType: "repository",
3477 targetId: id,
3478 });
3479 return c.redirect("/admin/repos");
3480});
3481
3482// ----- Flags -----
3483
3484admin.get("/admin/flags", async (c) => {
3485 const g = await gate(c);
3486 if (g instanceof Response) return g;
3487 const { user } = g;
3488
3489 const existing = await listFlags();
3490 const existingMap = new Map(existing.map((f) => [f.key, f.value]));
3491 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3492
3493 return c.html(
3494 <Layout title="Admin — Flags" user={user}>
8929744Claude3495 <div class="adm-flags-wrap">
3496 <section class="adm-flags-hero">
3497 <div class="adm-flags-hero-orb" aria-hidden="true" />
3498 <div class="adm-flags-hero-inner">
3499 <div class="adm-flags-hero-text">
3500 <div class="adm-flags-eyebrow">
3501 <span class="adm-flags-eyebrow-pill" aria-hidden="true">{Icons.flag}</span>
3502 Site admin · Feature flags
3503 </div>
3504 <h1 class="adm-flags-title">
3505 <span class="adm-flags-title-grad">Site flags</span>.
3506 </h1>
3507 <p class="adm-flags-sub">
3508 Runtime feature flags surfaced to the rest of the app via{" "}
3509 <code>getFlag()</code> — registration lock, site banner, read-only mode, and more.
3510 </p>
3511 </div>
3512 <a href="/admin" class="adm-flags-back">
07f4b70Claude3513 {Icons.arrowLeft} Back
3514 </a>
3515 </div>
3516 </section>
3517
8929744Claude3518 <form method="post" action="/admin/flags" class="adm-flags-card">
3519 <div class="adm-flags-card-body">
07f4b70Claude3520 {keys.map((k) => {
3521 const current = existingMap.get(k) ?? (KNOWN_FLAGS as any)[k];
8929744Claude3522 const isOverridden =
3523 existingMap.has(k) && existingMap.get(k) !== (KNOWN_FLAGS as any)[k];
07f4b70Claude3524 return (
8929744Claude3525 <div class="adm-flags-field">
3526 <div class="adm-flags-field-head">
3527 <label for={`flag-${k}`} class="adm-flags-key">{k}</label>
3528 {isOverridden && (
3529 <span class="adm-flags-mono">overridden</span>
3530 )}
3531 </div>
07f4b70Claude3532 <input
8929744Claude3533 id={`flag-${k}`}
07f4b70Claude3534 type="text"
3535 name={k}
3536 value={current}
3537 aria-label={k}
8929744Claude3538 class="adm-flags-input"
07f4b70Claude3539 />
8929744Claude3540 <div class="adm-flags-hint">
07f4b70Claude3541 default: <code>{(KNOWN_FLAGS as any)[k] || "(empty)"}</code>
3542 </div>
3543 </div>
3544 );
3545 })}
3546 </div>
8929744Claude3547 <div class="adm-flags-card-foot">
3548 <span class="adm-flags-foot-hint">
07f4b70Claude3549 Saved values overwrite the defaults at runtime.
3550 </span>
8929744Claude3551 <button type="submit" class="adm-flags-btn adm-flags-btn-primary">
3552 Save changes
07f4b70Claude3553 </button>
3554 </div>
3555 </form>
8f50ed0Claude3556 </div>
07f4b70Claude3557 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3558 <style dangerouslySetInnerHTML={{ __html: admFlagsStyles }} />
8f50ed0Claude3559 </Layout>
3560 );
3561});
3562
3563admin.post("/admin/flags", async (c) => {
3564 const g = await gate(c);
3565 if (g instanceof Response) return g;
3566 const { user } = g;
3567 const body = await c.req.parseBody();
3568 const keys = Object.keys(KNOWN_FLAGS) as Array<keyof typeof KNOWN_FLAGS>;
3569 for (const k of keys) {
3570 const v = String(body[k] ?? "");
3571 await setFlag(k, v, user.id);
3572 }
3573 await audit({ userId: user.id, action: "admin.flags.save" });
3574 return c.redirect("/admin/flags");
3575});
3576
08420cdClaude3577// ----- Email digests (Block I7) -----
3578
3579admin.get("/admin/digests", async (c) => {
3580 const g = await gate(c);
3581 if (g instanceof Response) return g;
3582 const { user } = g;
3583
3584 const [optedRow] = await db
3585 .select({ n: sql<number>`count(*)::int` })
3586 .from(users)
3587 .where(eq(users.notifyEmailDigestWeekly, true));
3588 const opted = Number(optedRow?.n || 0);
3589
3590 const recentlySent = await db
3591 .select({
3592 id: users.id,
3593 username: users.username,
3594 lastDigestSentAt: users.lastDigestSentAt,
3595 })
3596 .from(users)
3597 .where(sql`${users.lastDigestSentAt} is not null`)
3598 .orderBy(desc(users.lastDigestSentAt))
3599 .limit(20);
3600
3601 const result = c.req.query("result");
3602 const error = c.req.query("error");
3603
3604 return c.html(
3605 <Layout title="Admin — Digests" user={user}>
8929744Claude3606 <div class="adm-digests-wrap">
3607 <section class="adm-digests-hero">
3608 <div class="adm-digests-hero-orb" aria-hidden="true" />
3609 <div class="adm-digests-hero-inner">
3610 <div class="adm-digests-hero-text">
3611 <div class="adm-digests-eyebrow">
3612 <span class="adm-digests-eyebrow-pill" aria-hidden="true">{Icons.mail}</span>
3613 Site admin · Email
3614 </div>
3615 <h1 class="adm-digests-title">
3616 <span class="adm-digests-title-grad">Email digests</span>.
3617 </h1>
3618 <p class="adm-digests-sub">
3619 Manually trigger the weekly digest for every opted-in user
3620 or preview the email for a single account.
3621 </p>
3622 </div>
3623 <a href="/admin" class="adm-digests-back">
07f4b70Claude3624 {Icons.arrowLeft} Back
3625 </a>
3626 </div>
3627 </section>
08420cdClaude3628
07f4b70Claude3629 {result && (
8929744Claude3630 <div class="adm-digests-banner is-ok">{decodeURIComponent(result)}</div>
07f4b70Claude3631 )}
3632 {error && (
8929744Claude3633 <div class="adm-digests-banner is-error">{decodeURIComponent(error)}</div>
07f4b70Claude3634 )}
08420cdClaude3635
8929744Claude3636 <div class="adm-digests-pills">
3637 <span class="adm-digests-pill is-on"><span class="dot" aria-hidden="true" />{opted} opted-in</span>
3638 <span class="adm-digests-pill"><span class="dot" aria-hidden="true" />{recentlySent.length} recent</span>
3639 </div>
3640
3641 <section class="adm-digests-section">
3642 <header class="adm-digests-section-head">
3643 <span class="adm-digests-section-icon" aria-hidden="true">{Icons.mail}</span>
3644 <div>
3645 <h3 class="adm-digests-section-title">Send digests</h3>
3646 <p class="adm-digests-section-sub">
3647 {opted} user{opted === 1 ? "" : "s"} subscribed to the weekly digest.
3648 </p>
08420cdClaude3649 </div>
8929744Claude3650 </header>
3651 <div class="adm-digests-section-body">
3652 <form method="post" action="/admin/digests/run">
07f4b70Claude3653 <button
3654 type="submit"
8929744Claude3655 class="adm-digests-btn adm-digests-btn-primary"
07f4b70Claude3656 onclick="return confirm('Send weekly digest to all opted-in users now?')"
3657 >
8929744Claude3658 {Icons.mail}
07f4b70Claude3659 Send digests now
3660 </button>
3661 </form>
8929744Claude3662 <div class="adm-digests-section-divider">
3663 <div class="adm-digests-divider-hint">
07f4b70Claude3664 Preview / one-off — send the digest to a single user.
3665 </div>
3666 <form
3667 method="post"
3668 action="/admin/digests/preview"
8929744Claude3669 class="adm-digests-form-row"
07f4b70Claude3670 >
3671 <input
3672 type="text"
3673 name="username"
3674 placeholder="username"
3675 required
3676 aria-label="Username"
8929744Claude3677 class="adm-digests-input"
07f4b70Claude3678 />
8929744Claude3679 <button type="submit" class="adm-digests-btn">
07f4b70Claude3680 Send to one user
3681 </button>
3682 </form>
3683 </div>
3684 </div>
8929744Claude3685 </section>
07f4b70Claude3686
8929744Claude3687 <div class="adm-digests-h3">
07f4b70Claude3688 <h3>Recently sent</h3>
8929744Claude3689 <span class="adm-digests-h3-meta">last {recentlySent.length}</span>
07f4b70Claude3690 </div>
8929744Claude3691 {recentlySent.length === 0 ? (
3692 <div class="adm-digests-empty">
3693 <div class="adm-digests-empty-orb" aria-hidden="true" />
3694 <div class="adm-digests-empty-inner">
3695 <div class="adm-digests-empty-icon" aria-hidden="true">{Icons.mail}</div>
3696 <div class="adm-digests-empty-title">No digests sent yet</div>
3697 <div class="adm-digests-empty-sub">
3698 When the weekly digest fires, sent recipients will appear here.
3699 </div>
3700 </div>
3701 </div>
3702 ) : (
3703 <div class="adm-digests-grid">
3704 {recentlySent.map((u) => (
3705 <div class="adm-digests-card">
3706 <span class="adm-digests-avatar" aria-hidden="true">{initials(u.username)}</span>
3707 <div class="adm-digests-card-text">
3708 <a href={`/${u.username}`} class="adm-digests-card-name">{u.username}</a>
3709 <div class="adm-digests-card-sent">
3710 sent {u.lastDigestSentAt
3711 ? new Date(u.lastDigestSentAt as unknown as string).toLocaleString()
3712 : "—"}
07f4b70Claude3713 </div>
3714 </div>
3715 </div>
8929744Claude3716 ))}
3717 </div>
3718 )}
08420cdClaude3719 </div>
07f4b70Claude3720 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3721 <style dangerouslySetInnerHTML={{ __html: admDigestsStyles }} />
08420cdClaude3722 </Layout>
3723 );
3724});
3725
3726admin.post("/admin/digests/run", async (c) => {
3727 const g = await gate(c);
3728 if (g instanceof Response) return g;
3729 const { user } = g;
3730 const results = await sendDigestsToAll();
3731 const sent = results.filter((r) => r.ok).length;
3732 const skipped = results.length - sent;
3733 await audit({
3734 userId: user.id,
3735 action: "admin.digests.run",
3736 metadata: { sent, skipped, total: results.length },
3737 });
3738 return c.redirect(
3739 `/admin/digests?result=${encodeURIComponent(
3740 `Processed ${results.length} opted-in users: ${sent} sent, ${skipped} skipped.`
3741 )}`
3742 );
3743});
3744
3745admin.post("/admin/digests/preview", async (c) => {
3746 const g = await gate(c);
3747 if (g instanceof Response) return g;
3748 const { user } = g;
3749 const body = await c.req.parseBody();
3750 const username = String(body.username || "").trim();
3751 if (!username) {
3752 return c.redirect("/admin/digests?error=Username+required");
3753 }
3754 const [target] = await db
3755 .select({ id: users.id, username: users.username })
3756 .from(users)
3757 .where(eq(users.username, username))
3758 .limit(1);
3759 if (!target) {
3760 return c.redirect("/admin/digests?error=User+not+found");
3761 }
3762 const result = await sendDigestForUser(target.id);
3763 await audit({
3764 userId: user.id,
3765 action: "admin.digests.preview",
3766 targetType: "user",
3767 targetId: target.id,
3768 metadata: {
3769 ok: result.ok,
3770 skipped: "skipped" in result ? result.skipped : null,
3771 },
3772 });
3773 if (result.ok) {
3774 return c.redirect(
3775 `/admin/digests?result=${encodeURIComponent(
3776 `Digest sent to ${target.username}.`
3777 )}`
3778 );
3779 }
3780 return c.redirect(
3781 `/admin/digests?error=${encodeURIComponent(
3782 `Not sent: ${"skipped" in result ? result.skipped : "unknown reason"}`
3783 )}`
3784 );
3785});
3786
b5dd694Claude3787const AUTOPILOT_TASK_CATALOG = [
3788 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3789 { name: "merge-queue", desc: "Advance serialised merge queues" },
3790 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3791 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3792 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3793 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3794 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3795 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3796 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
44ed968Claude3797 { name: "preview-expiry", desc: "Expire stale branch-preview rows past their TTL" },
f65f600Claude3798 { name: "onboarding-drip", desc: "Send T+1d and T+3d onboarding drip emails to new users" },
b5dd694Claude3799] as const;
3800
8e9f1d9Claude3801admin.get("/admin/autopilot", async (c) => {
3802 const g = await gate(c);
3803 if (g instanceof Response) return g;
3804 const { user } = g;
3805 const tick = getLastTick();
3806 const total = getTickCount();
3807 const disabled = process.env.AUTOPILOT_DISABLED === "1";
3808 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
3809 const intervalMs =
3810 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
3811 ? Number(intervalRaw)
3812 : 5 * 60 * 1000;
3813 const msg = c.req.query("result") || c.req.query("error");
3814 const isErr = !!c.req.query("error");
3815 return c.html(
3816 <Layout title="Autopilot — admin" user={user}>
8929744Claude3817 <div class="adm-autopilot-wrap">
3818 <section class="adm-autopilot-hero">
3819 <div class="adm-autopilot-hero-orb" aria-hidden="true" />
3820 <div class="adm-autopilot-hero-inner">
3821 <div class="adm-autopilot-hero-text">
3822 <div class="adm-autopilot-eyebrow">
3823 <span class="adm-autopilot-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
3824 Site admin · Maintenance loop
3825 </div>
3826 <h1 class="adm-autopilot-title">
3827 <span class="adm-autopilot-title-grad">Autopilot</span>.
3828 </h1>
3829 <p class="adm-autopilot-sub">
3830 Periodic platform-maintenance loop — mirror sync, merge-queue
c315551Claude3831 progress, weekly digests, advisory rescans, wait-timer release,
3832 scheduled workflows (cron), AI-gated auto-merge sweep, and
3833 ai:build issue → PR dispatch.
8929744Claude3834 </p>
3835 </div>
3836 <a href="/admin" class="adm-autopilot-back">
07f4b70Claude3837 {Icons.arrowLeft} Back
3838 </a>
3839 </div>
3840 </section>
3841
8e9f1d9Claude3842 {msg && (
8929744Claude3843 <div class={"adm-autopilot-banner " + (isErr ? "is-error" : "is-ok")}>
8e9f1d9Claude3844 {decodeURIComponent(msg)}
3845 </div>
3846 )}
07f4b70Claude3847
8929744Claude3848 <div class="adm-autopilot-statgrid">
3849 <div class="adm-autopilot-stat">
3850 <div class="adm-autopilot-stat-head">
3851 <span class="adm-autopilot-stat-label">Status</span>
3852 <span class={"adm-autopilot-pill " + (disabled ? "is-off" : "is-on")}>
07f4b70Claude3853 <span class="dot" aria-hidden="true" />
3854 {disabled ? "disabled" : "running"}
3855 </span>
3856 </div>
8929744Claude3857 <div class="adm-autopilot-stat-value" style="font-size:22px">
8e9f1d9Claude3858 {disabled ? "disabled" : "running"}
3859 </div>
8929744Claude3860 <div class="adm-autopilot-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
8e9f1d9Claude3861 </div>
8929744Claude3862 <div class="adm-autopilot-stat">
3863 <div class="adm-autopilot-stat-head">
3864 <span class="adm-autopilot-stat-label">Interval</span>
3865 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.refresh}</span>
07f4b70Claude3866 </div>
8929744Claude3867 <div class="adm-autopilot-stat-value">{Math.round(intervalMs / 1000)}s</div>
3868 <div class="adm-autopilot-stat-hint">between ticks</div>
8e9f1d9Claude3869 </div>
8929744Claude3870 <div class="adm-autopilot-stat">
3871 <div class="adm-autopilot-stat-head">
3872 <span class="adm-autopilot-stat-label">Ticks this process</span>
3873 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.pulse}</span>
07f4b70Claude3874 </div>
8929744Claude3875 <div class="adm-autopilot-stat-value">{total}</div>
3876 <div class="adm-autopilot-stat-hint">since boot</div>
8e9f1d9Claude3877 </div>
8929744Claude3878 <div class="adm-autopilot-stat">
3879 <div class="adm-autopilot-stat-head">
3880 <span class="adm-autopilot-stat-label">Last tick</span>
3881 <span class="adm-autopilot-stat-icon" aria-hidden="true">{Icons.bot}</span>
07f4b70Claude3882 </div>
8929744Claude3883 <div class="adm-autopilot-stat-value is-mono">
8e9f1d9Claude3884 {tick ? tick.finishedAt : "never"}
3885 </div>
3886 </div>
3887 </div>
07f4b70Claude3888
8929744Claude3889 <div class="adm-autopilot-actions">
3890 <form method="post" action="/admin/autopilot/run">
3891 <button class="adm-autopilot-btn adm-autopilot-btn-primary" type="submit">
3892 {Icons.bot}
3893 Run tick now
3894 </button>
3895 </form>
3896 <span class="adm-autopilot-action-hint">
8e9f1d9Claude3897 Executes all sub-tasks synchronously and records the result.
3898 </span>
8929744Claude3899 </div>
07f4b70Claude3900
8929744Claude3901 <div class="adm-autopilot-h3">
07f4b70Claude3902 <h3>Last tick tasks</h3>
3903 {tick && (
8929744Claude3904 <span class="adm-autopilot-h3-meta">
07f4b70Claude3905 {tick.tasks.filter((t) => t.ok).length}/{tick.tasks.length} ok
3906 </span>
3907 )}
3908 </div>
8e9f1d9Claude3909 {tick ? (
8929744Claude3910 <div class="adm-autopilot-tasks">
3911 {tick.tasks.map((t) => (
3912 <div class={"adm-autopilot-task " + (t.ok ? "is-ok" : "is-fail")}>
3913 <div class="adm-autopilot-task-head">
3914 <span
3915 class={"adm-autopilot-task-light " + (t.ok ? "is-ok" : "is-fail")}
3916 aria-label={t.ok ? "ok" : "failed"}
3917 />
3918 <span class="adm-autopilot-task-name">{t.name}</span>
3919 <span class={"adm-autopilot-task-status " + (t.ok ? "is-ok" : "is-fail")}>
8e9f1d9Claude3920 {t.ok ? "ok" : "failed"}
8929744Claude3921 </span>
3922 </div>
3923 <div class="adm-autopilot-task-meta">
3924 <span>duration</span>
3925 <span>{t.durationMs}ms</span>
3926 </div>
3927 {t.error && (
3928 <div class="adm-autopilot-task-err">{t.error}</div>
3929 )}
3930 </div>
3931 ))}
3932 </div>
8e9f1d9Claude3933 ) : (
8929744Claude3934 <div class="adm-autopilot-empty">
3935 <div class="adm-autopilot-empty-orb" aria-hidden="true" />
3936 <div class="adm-autopilot-empty-inner">
3937 <div class="adm-autopilot-empty-icon" aria-hidden="true">{Icons.bot}</div>
3938 <div class="adm-autopilot-empty-title">No ticks yet</div>
3939 <div class="adm-autopilot-empty-sub">
3940 The first tick fires after the interval elapses. Click "Run tick now" to fire one immediately.
3941 </div>
3942 </div>
07f4b70Claude3943 </div>
8e9f1d9Claude3944 )}
b5dd694Claude3945 <div class="adm-autopilot-h3" style="margin-top:28px">
3946 <h3>Configured tasks</h3>
44ed968Claude3947 <span class="adm-autopilot-h3-meta">10 tasks · runs every tick</span>
b5dd694Claude3948 </div>
3949 <div class="adm-autopilot-tasks">
3950 {AUTOPILOT_TASK_CATALOG.map((t) => {
3951 const last = tick?.tasks.find((r) => r.name === t.name);
3952 return (
3953 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3954 <div class="adm-autopilot-task-head">
3955 <span
3956 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3957 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3958 />
3959 <span class="adm-autopilot-task-name">{t.name}</span>
3960 {last && (
3961 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3962 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3963 </span>
3964 )}
3965 </div>
3966 <div class="adm-autopilot-task-meta">
3967 <span>{t.desc}</span>
3968 </div>
3969 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3970 </div>
3971 );
3972 })}
3973 </div>
3974
8929744Claude3975 <p class="adm-autopilot-foot">
8e9f1d9Claude3976 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
3977 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
3978 </p>
3979 </div>
07f4b70Claude3980 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
8929744Claude3981 <style dangerouslySetInnerHTML={{ __html: admAutopilotStyles }} />
8e9f1d9Claude3982 </Layout>
3983 );
3984});
3985
988380aClaude3986admin.post("/admin/demo/reseed", async (c) => {
3987 const g = await gate(c);
3988 if (g instanceof Response) return g;
3989 const { user } = g;
3990 try {
3991 const result = await ensureDemoContent({ force: true });
3992 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}` : ""}`;
3993 await audit({
3994 userId: user.id,
3995 action: "admin.demo.reseed",
3996 targetType: "user",
3997 targetId: result.demoUser?.id ?? "demo",
3998 metadata: {
3999 createdUser: result.created.user,
4000 createdRepos: result.created.repos,
4001 createdIssues: result.created.issues,
4002 createdPrs: result.created.prs,
4003 errors: result.errors.slice(0, 5),
4004 },
4005 });
4006 return c.redirect(`/admin?result=${encodeURIComponent(summary)}`);
4007 } catch (err) {
4008 const message = err instanceof Error ? err.message : String(err);
4009 return c.redirect(
4010 `/admin?error=${encodeURIComponent("Demo reseed failed: " + message)}`
4011 );
4012 }
4013});
4014
4015// Public jump-to-demo — redirects to the first demo repo if present,
4016// otherwise to /explore. Useful as a landing-page-linkable "try it" URL.
4017admin.get("/demo", (c) => {
4018 return c.redirect(`/${DEMO_USERNAME}/hello-python`);
4019});
4020
8e9f1d9Claude4021admin.post("/admin/autopilot/run", async (c) => {
4022 const g = await gate(c);
4023 if (g instanceof Response) return g;
4024 const { user } = g;
4025 let summary = "";
4026 try {
4027 const result = await runAutopilotTick();
4028 const ok = result.tasks.filter((t) => t.ok).length;
4029 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
4030 await audit({
4031 userId: user.id,
4032 action: "admin.autopilot.run",
4033 targetType: "system",
4034 targetId: "autopilot",
4035 metadata: { ok, total: result.tasks.length },
4036 });
4037 return c.redirect(
4038 `/admin/autopilot?result=${encodeURIComponent(summary)}`
4039 );
4040 } catch (err) {
4041 const message = err instanceof Error ? err.message : String(err);
4042 return c.redirect(
4043 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
4044 );
4045 }
4046});
4047
b218e63Claude4048// ─── AI Cost Breakdown (/admin/ai-costs) ─────────────────────────────────────
4049
4050admin.get("/admin/ai-costs", async (c) => {
4051 const g = await gate(c);
4052 if (g instanceof Response) return g;
4053 const { user } = g;
4054
4055 // Start of current month (UTC)
4056 const now = new Date();
4057 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
4058
4059 // Total spend this month
4060 const [totalRow] = await db
4061 .select({ total: sql<number>`coalesce(sum(cents_estimate), 0)::int` })
4062 .from(aiCostEvents)
4063 .where(gte(aiCostEvents.occurredAt, monthStart));
4064 const totalCents = Number(totalRow?.total ?? 0);
4065
4066 // Breakdown by category
4067 const byCategory = await db
4068 .select({
4069 category: aiCostEvents.category,
4070 cents: sql<number>`sum(cents_estimate)::int`,
4071 calls: sql<number>`count(*)::int`,
4072 })
4073 .from(aiCostEvents)
4074 .where(gte(aiCostEvents.occurredAt, monthStart))
4075 .groupBy(aiCostEvents.category)
4076 .orderBy(sql`sum(cents_estimate) desc`);
4077
4078 // Top 10 spenders (join users)
4079 const topSpenders = await db
4080 .select({
4081 username: users.username,
4082 cents: sql<number>`sum(${aiCostEvents.centsEstimate})::int`,
4083 calls: sql<number>`count(*)::int`,
4084 })
4085 .from(aiCostEvents)
4086 .innerJoin(users, eq(aiCostEvents.ownerUserId, users.id))
4087 .where(gte(aiCostEvents.occurredAt, monthStart))
4088 .groupBy(users.username)
4089 .orderBy(sql`sum(${aiCostEvents.centsEstimate}) desc`)
4090 .limit(10);
4091
4092 const maxCents = Math.max(1, ...byCategory.map((r) => Number(r.cents)));
4093 const maxSpenderCents = Math.max(1, ...topSpenders.map((r) => Number(r.cents)));
4094
4095 function fmtCents(c: number): string {
4096 if (c >= 100) return `$${(c / 100).toFixed(2)}`;
4097 return `${c}¢`;
4098 }
4099
4100 const monthName = now.toLocaleString("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
4101
4102 return c.html(
4103 <Layout title="Admin — AI Costs" user={user}>
4104 <div class="adm-analytics-wrap">
4105 <section class="adm-analytics-hero">
4106 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4107 <div class="adm-analytics-hero-inner">
4108 <div class="adm-analytics-hero-text">
4109 <div class="adm-analytics-eyebrow">
4110 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.dollarSign}</span>
4111 Site admin · Analytics
4112 </div>
4113 <h1 class="adm-analytics-title">
4114 <span class="adm-analytics-title-grad">AI cost breakdown</span>.
4115 </h1>
4116 <p class="adm-analytics-sub">
4117 Per-call AI spend for {monthName} — by feature category and top spenders.
4118 </p>
4119 </div>
4120 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4121 </div>
4122 </section>
4123
4124 <div class="adm-analytics-statgrid">
4125 <div class="adm-analytics-stat">
4126 <div class="adm-analytics-stat-label">Total spend this month</div>
4127 <div class="adm-analytics-stat-value">{fmtCents(totalCents)}</div>
4128 <div class="adm-analytics-stat-hint">{monthName}</div>
4129 </div>
4130 <div class="adm-analytics-stat">
4131 <div class="adm-analytics-stat-label">Categories active</div>
4132 <div class="adm-analytics-stat-value">{byCategory.length}</div>
4133 <div class="adm-analytics-stat-hint">feature buckets with spend</div>
4134 </div>
4135 <div class="adm-analytics-stat">
4136 <div class="adm-analytics-stat-label">Total AI calls</div>
4137 <div class="adm-analytics-stat-value">
4138 {byCategory.reduce((s, r) => s + Number(r.calls), 0)}
4139 </div>
4140 <div class="adm-analytics-stat-hint">recorded events</div>
4141 </div>
4142 </div>
4143
4144 <div class="adm-analytics-h3">
4145 <h3>By category</h3>
4146 <span class="adm-analytics-h3-meta">{monthName}</span>
4147 </div>
4148 {byCategory.length === 0 ? (
4149 <div class="adm-analytics-empty">No AI cost events recorded this month.</div>
4150 ) : (
4151 <table class="adm-analytics-table">
4152 <thead>
4153 <tr>
4154 <th>Category</th>
4155 <th>Spend</th>
4156 <th>Calls</th>
4157 <th style="width:200px">Distribution</th>
4158 </tr>
4159 </thead>
4160 <tbody>
4161 {byCategory.map((row) => {
4162 const pct = Math.round((Number(row.cents) / maxCents) * 100);
4163 return (
4164 <tr>
4165 <td><code>{row.category ?? "other"}</code></td>
4166 <td>{fmtCents(Number(row.cents))}</td>
4167 <td>{Number(row.calls).toLocaleString()}</td>
4168 <td>
4169 <div class="adm-analytics-bar-cell">
4170 <div class="adm-analytics-bar-track">
4171 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4172 </div>
4173 <span class="adm-analytics-bar-label">{pct}%</span>
4174 </div>
4175 </td>
4176 </tr>
4177 );
4178 })}
4179 </tbody>
4180 </table>
4181 )}
4182
4183 <div class="adm-analytics-h3">
4184 <h3>Top 10 spenders</h3>
4185 <span class="adm-analytics-h3-meta">{monthName}</span>
4186 </div>
4187 {topSpenders.length === 0 ? (
4188 <div class="adm-analytics-empty">No per-user spend recorded this month.</div>
4189 ) : (
4190 <table class="adm-analytics-table">
4191 <thead>
4192 <tr>
4193 <th>User</th>
4194 <th>Spend</th>
4195 <th>Calls</th>
4196 <th style="width:200px">Share</th>
4197 </tr>
4198 </thead>
4199 <tbody>
4200 {topSpenders.map((row, i) => {
4201 const pct = Math.round((Number(row.cents) / maxSpenderCents) * 100);
4202 return (
4203 <tr>
4204 <td>
4205 <a href={`/${row.username}`} style="color:var(--accent);text-decoration:none;font-weight:600">
4206 #{i + 1} {row.username}
4207 </a>
4208 </td>
4209 <td>{fmtCents(Number(row.cents))}</td>
4210 <td>{Number(row.calls).toLocaleString()}</td>
4211 <td>
4212 <div class="adm-analytics-bar-cell">
4213 <div class="adm-analytics-bar-track">
4214 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4215 </div>
4216 <span class="adm-analytics-bar-label">{pct}%</span>
4217 </div>
4218 </td>
4219 </tr>
4220 );
4221 })}
4222 </tbody>
4223 </table>
4224 )}
4225 </div>
4226 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4227 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4228 </Layout>
4229 );
4230});
4231
4232// ─── Autopilot Health (/admin/autopilot/health) ──────────────────────────────
4233
4234const ALL_AUTOPILOT_TASKS = [
4235 "mirror-sync",
4236 "merge-queue",
4237 "weekly-digest",
4238 "advisory-rescan",
4239 "wait-timer-release",
4240 "scheduled-workflows",
4241 "auto-merge-sweep",
4242 "ai-build-from-issues",
4243 "sleep-mode-digest",
4244 "stale-pr-sweep",
4245] as const;
4246
4247admin.get("/admin/autopilot/health", async (c) => {
4248 const g = await gate(c);
4249 if (g instanceof Response) return g;
4250 const { user } = g;
4251
4252 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
4253
4254 // Pull the last 200 audit log rows that match autopilot task patterns
4255 const logRows = await db
4256 .select({
4257 action: auditLog.action,
4258 createdAt: auditLog.createdAt,
4259 metadata: auditLog.metadata,
4260 })
4261 .from(auditLog)
4262 .where(gte(auditLog.createdAt, since24h))
4263 .orderBy(desc(auditLog.createdAt))
4264 .limit(500);
4265
4266 // Build per-task health from the last autopilot tick (in-memory)
4267 const tick = getLastTick();
4268
4269 type TaskHealth = {
4270 name: string;
4271 lastRun: string | null;
4272 lastDurationMs: number | null;
4273 lastOk: boolean | null;
4274 lastError: string | null;
4275 successCount24h: number;
4276 errorCount24h: number;
4277 };
4278
4279 const health: TaskHealth[] = ALL_AUTOPILOT_TASKS.map((taskName) => {
4280 // Count audit events in last 24h that look like this task
4281 const successCount24h = logRows.filter(
4282 (r) => r.action === `autopilot.task.ok.${taskName}`
4283 ).length;
4284 const errorCount24h = logRows.filter(
4285 (r) => r.action === `autopilot.task.error.${taskName}`
4286 ).length;
4287
4288 // Get last tick result for this task (in-memory)
4289 const last = tick?.tasks.find((t) => t.name === taskName);
4290
4291 return {
4292 name: taskName,
4293 lastRun: tick?.finishedAt ?? null,
4294 lastDurationMs: last?.durationMs ?? null,
4295 lastOk: last?.ok ?? null,
4296 lastError: last?.error ?? null,
4297 successCount24h,
4298 errorCount24h,
4299 };
4300 });
4301
4302 const total = getTickCount();
4303 const disabled = process.env.AUTOPILOT_DISABLED === "1";
4304
4305 return c.html(
4306 <Layout title="Autopilot Health — admin" user={user}>
4307 <div class="adm-analytics-wrap">
4308 <section class="adm-analytics-hero">
4309 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4310 <div class="adm-analytics-hero-inner">
4311 <div class="adm-analytics-hero-text">
4312 <div class="adm-analytics-eyebrow">
4313 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.bot}</span>
4314 Site admin · Autopilot
4315 </div>
4316 <h1 class="adm-analytics-title">
4317 <span class="adm-analytics-title-grad">Autopilot health</span>.
4318 </h1>
4319 <p class="adm-analytics-sub">
4320 Last-tick status, duration, and 24h success/error counts for each autopilot task.
4321 </p>
4322 </div>
4323 <a href="/admin/autopilot" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4324 </div>
4325 </section>
4326
4327 <div class="adm-analytics-statgrid">
4328 <div class="adm-analytics-stat">
4329 <div class="adm-analytics-stat-label">Status</div>
4330 <div class="adm-analytics-stat-value" style="font-size:22px">{disabled ? "disabled" : "running"}</div>
4331 <div class="adm-analytics-stat-hint">{disabled ? "AUTOPILOT_DISABLED=1" : "loop active"}</div>
4332 </div>
4333 <div class="adm-analytics-stat">
4334 <div class="adm-analytics-stat-label">Ticks (this process)</div>
4335 <div class="adm-analytics-stat-value">{total}</div>
4336 <div class="adm-analytics-stat-hint">since boot</div>
4337 </div>
4338 <div class="adm-analytics-stat">
4339 <div class="adm-analytics-stat-label">Last tick</div>
4340 <div class="adm-analytics-stat-value" style="font-size:14px;font-family:var(--font-mono);line-height:1.3">
4341 {tick?.finishedAt ?? "—"}
4342 </div>
4343 </div>
4344 <div class="adm-analytics-stat">
4345 <div class="adm-analytics-stat-label">Tasks OK (last tick)</div>
4346 <div class="adm-analytics-stat-value">
4347 {tick ? `${tick.tasks.filter((t) => t.ok).length}/${tick.tasks.length}` : "—"}
4348 </div>
4349 </div>
4350 </div>
4351
4352 <div class="adm-analytics-h3">
4353 <h3>Per-task health</h3>
4354 <span class="adm-analytics-h3-meta">last tick + 24h audit window</span>
4355 </div>
4356 <table class="adm-analytics-table">
4357 <thead>
4358 <tr>
4359 <th>Task</th>
4360 <th>Last status</th>
4361 <th>Duration (last)</th>
4362 <th>OK (24h)</th>
4363 <th>Errors (24h)</th>
4364 </tr>
4365 </thead>
4366 <tbody>
4367 {health.map((t) => (
4368 <tr>
4369 <td><code>{t.name}</code></td>
4370 <td>
4371 {t.lastOk === null ? (
4372 <span style="color:var(--text-muted)">—</span>
4373 ) : t.lastOk ? (
4374 <span class="adm-analytics-pill is-ok">ok</span>
4375 ) : (
4376 <span class="adm-analytics-pill is-err" title={t.lastError ?? ""}>failed</span>
4377 )}
4378 </td>
4379 <td>{t.lastDurationMs !== null ? `${t.lastDurationMs}ms` : "—"}</td>
4380 <td>
4381 {t.successCount24h > 0 ? (
4382 <span class="adm-analytics-pill is-ok">{t.successCount24h}</span>
4383 ) : (
4384 <span style="color:var(--text-muted)">—</span>
4385 )}
4386 </td>
4387 <td>
4388 {t.errorCount24h > 0 ? (
4389 <span class="adm-analytics-pill is-err">{t.errorCount24h}</span>
4390 ) : (
4391 <span style="color:var(--text-muted)">0</span>
4392 )}
4393 </td>
4394 </tr>
4395 ))}
4396 </tbody>
4397 </table>
4398
4399 {tick?.tasks.some((t) => !t.ok && t.error) && (
4400 <>
4401 <div class="adm-analytics-h3" style="margin-top:28px">
4402 <h3>Last-tick errors</h3>
4403 </div>
4404 {tick.tasks.filter((t) => !t.ok && t.error).map((t) => (
4405 <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">
4406 <code style="color:#fecaca;font-weight:600">{t.name}</code>
4407 <div style="margin-top:6px;color:#fecaca;line-height:1.5;word-break:break-word">{t.error}</div>
4408 </div>
4409 ))}
4410 </>
4411 )}
4412
4413 <p style="margin-top:24px;font-size:12.5px;color:var(--text-muted)">
4414 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).
4415 </p>
4416 </div>
4417 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4418 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4419 </Layout>
4420 );
4421});
4422
4423// ─── User Growth Chart (/admin/growth) ───────────────────────────────────────
4424
4425admin.get("/admin/growth", async (c) => {
4426 const g = await gate(c);
4427 if (g instanceof Response) return g;
4428 const { user } = g;
4429
4430 const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
4431
4432 // Daily signups last 30 days
4433 const dailySignups = await db
4434 .select({
4435 day: sql<string>`date_trunc('day', created_at)::date::text`,
4436 count: sql<number>`count(*)::int`,
4437 })
4438 .from(users)
4439 .where(gte(users.createdAt, since30d))
4440 .groupBy(sql`date_trunc('day', created_at)`)
4441 .orderBy(sql`date_trunc('day', created_at)`);
4442
4443 // Activation rate: users who created at least 1 repo (last 30d signups)
4444 const activatedRows = await db
4445 .select({
4446 userId: users.id,
4447 })
4448 .from(users)
4449 .innerJoin(repositories, eq(repositories.ownerId, users.id))
4450 .where(gte(users.createdAt, since30d))
4451 .groupBy(users.id);
4452
4453 // Total signups in window
4454 const totalSignups = dailySignups.reduce((s, r) => s + Number(r.count), 0);
4455 const activated = activatedRows.length;
4456 const activationRate = totalSignups > 0 ? Math.round((activated / totalSignups) * 100) : 0;
4457
4458 // All-time user count
4459 const [allTimeRow] = await db
4460 .select({ n: sql<number>`count(*)::int` })
4461 .from(users);
4462 const allTime = Number(allTimeRow?.n ?? 0);
4463
4464 // Build a 30-slot array (fill missing days with 0)
4465 const dayMap = new Map<string, number>();
4466 dailySignups.forEach((r) => dayMap.set(r.day, Number(r.count)));
4467
4468 const slots: { label: string; count: number }[] = [];
4469 for (let i = 29; i >= 0; i--) {
4470 const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
4471 const key = d.toISOString().slice(0, 10);
4472 const label = d.toLocaleString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4473 slots.push({ label, count: dayMap.get(key) ?? 0 });
4474 }
4475
4476 const maxCount = Math.max(1, ...slots.map((s) => s.count));
4477
4478 return c.html(
4479 <Layout title="Admin — User Growth" user={user}>
4480 <div class="adm-analytics-wrap">
4481 <section class="adm-analytics-hero">
4482 <div class="adm-analytics-hero-orb" aria-hidden="true" />
4483 <div class="adm-analytics-hero-inner">
4484 <div class="adm-analytics-hero-text">
4485 <div class="adm-analytics-eyebrow">
4486 <span class="adm-analytics-eyebrow-pill" aria-hidden="true">{Icons.trendingUp}</span>
4487 Site admin · Analytics
4488 </div>
4489 <h1 class="adm-analytics-title">
4490 <span class="adm-analytics-title-grad">User growth</span>.
4491 </h1>
4492 <p class="adm-analytics-sub">
4493 Daily signups and activation rate over the last 30 days.
4494 </p>
4495 </div>
4496 <a href="/admin" class="adm-analytics-back">{Icons.arrowLeft} Back</a>
4497 </div>
4498 </section>
4499
4500 <div class="adm-analytics-statgrid">
4501 <div class="adm-analytics-stat">
4502 <div class="adm-analytics-stat-label">Total users</div>
4503 <div class="adm-analytics-stat-value">{allTime}</div>
4504 <div class="adm-analytics-stat-hint">all time</div>
4505 </div>
4506 <div class="adm-analytics-stat">
4507 <div class="adm-analytics-stat-label">New signups (30d)</div>
4508 <div class="adm-analytics-stat-value">{totalSignups}</div>
4509 <div class="adm-analytics-stat-hint">last 30 days</div>
4510 </div>
4511 <div class="adm-analytics-stat">
4512 <div class="adm-analytics-stat-label">Activation rate (30d)</div>
4513 <div class="adm-analytics-stat-value">{activationRate}%</div>
4514 <div class="adm-analytics-stat-hint">created ≥1 repo</div>
4515 </div>
4516 <div class="adm-analytics-stat">
4517 <div class="adm-analytics-stat-label">Activated users (30d)</div>
4518 <div class="adm-analytics-stat-value">{activated}</div>
4519 <div class="adm-analytics-stat-hint">out of {totalSignups} new</div>
4520 </div>
4521 </div>
4522
4523 <div class="adm-analytics-h3">
4524 <h3>Daily signups — last 30 days</h3>
4525 <span class="adm-analytics-h3-meta">peak: {maxCount}</span>
4526 </div>
4527
4528 {totalSignups === 0 ? (
4529 <div class="adm-analytics-empty">No signups in the last 30 days.</div>
4530 ) : (
4531 <table class="adm-analytics-table">
4532 <thead>
4533 <tr>
4534 <th>Date</th>
4535 <th>Signups</th>
4536 <th style="width:280px">Bar</th>
4537 </tr>
4538 </thead>
4539 <tbody>
4540 {slots.filter((s) => s.count > 0 || true).map((s) => {
4541 const pct = Math.round((s.count / maxCount) * 100);
4542 return (
4543 <tr>
4544 <td style="font-family:var(--font-mono);font-size:12px;color:var(--text)">{s.label}</td>
4545 <td>{s.count}</td>
4546 <td>
4547 {s.count > 0 ? (
4548 <div class="adm-analytics-bar-cell">
4549 <div class="adm-analytics-bar-track">
4550 <div class="adm-analytics-bar-fill" style={`width:${pct}%`} />
4551 </div>
4552 <span class="adm-analytics-bar-label">{s.count}</span>
4553 </div>
4554 ) : (
4555 <span style="color:var(--text-faint);font-size:11px">—</span>
4556 )}
4557 </td>
4558 </tr>
4559 );
4560 })}
4561 </tbody>
4562 </table>
4563 )}
4564
4565 <div class="adm-analytics-h3" style="margin-top:28px">
4566 <h3>Activation breakdown</h3>
4567 <span class="adm-analytics-h3-meta">30d cohort</span>
4568 </div>
4569 <table class="adm-analytics-table" style="max-width:500px">
4570 <thead>
4571 <tr>
4572 <th>Segment</th>
4573 <th>Count</th>
4574 <th style="width:200px">Rate</th>
4575 </tr>
4576 </thead>
4577 <tbody>
4578 <tr>
4579 <td>New signups (30d)</td>
4580 <td>{totalSignups}</td>
4581 <td>100%</td>
4582 </tr>
4583 <tr>
4584 <td>Activated (created ≥1 repo)</td>
4585 <td>{activated}</td>
4586 <td>
4587 <div class="adm-analytics-bar-cell">
4588 <div class="adm-analytics-bar-track">
4589 <div class="adm-analytics-bar-fill" style={`width:${activationRate}%`} />
4590 </div>
4591 <span class="adm-analytics-bar-label">{activationRate}%</span>
4592 </div>
4593 </td>
4594 </tr>
4595 <tr>
4596 <td>Not activated</td>
4597 <td>{totalSignups - activated}</td>
4598 <td>{100 - activationRate}%</td>
4599 </tr>
4600 </tbody>
4601 </table>
4602 </div>
4603 <style dangerouslySetInnerHTML={{ __html: adminStyles }} />
4604 <style dangerouslySetInnerHTML={{ __html: admAnalyticsStyles }} />
4605 </Layout>
4606 );
4607});
4608
8f50ed0Claude4609export default admin;