Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

admin-command.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-command.tsxBlame800 lines · 1 contributor
f9814d2Claude1/**
2 * Admin Command Center — /admin/command
3 *
4 * Single-pane operational view for site admins. Shows the live health of
5 * every system in one place so you never have to click through 20 separate
6 * pages to understand what's wrong.
7 *
8 * Panels:
9 * 1. Platform status bar — 5 color-coded health indicators
10 * 2. Failing gates right now — repos with gate failures (last 2h)
11 * 3. Deployment health — last 24h, failed/blocked surfaced first
12 * 4. AI engine activity — reviews, merges, repairs, triages (24h)
13 * 5. Auth anomalies — failed-login clusters (brute-force signal)
14 * 6. Recent audit errors — security/delete/error actions (last hour)
15 * 7. Quick-action links to every admin sub-page
16 *
17 * All queries are wrapped in try/catch — a DB hiccup on one panel never
18 * breaks the whole page. Each failed panel degrades to "unavailable."
19 *
20 * Auto-refreshes every 30 seconds via a <meta> refresh so operators
21 * keeping this open in a tab always see live state.
22 */
23
24import { Hono } from "hono";
25import { and, desc, eq, gte, lt, ne, sql } from "drizzle-orm";
26import { db } from "../db";
27import {
28 auditLog,
29 deployments,
30 gateRuns,
31 loginAttempts,
32 prComments,
33 pullRequests,
34 repositories,
35 users,
36 issueComments,
37} from "../db/schema";
38import { Layout } from "../views/layout";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41import { isSiteAdmin } from "../lib/admin";
42
43const app = new Hono<AuthEnv>();
44app.use("*", softAuth);
45
46// ---------------------------------------------------------------------------
47// Helpers
48// ---------------------------------------------------------------------------
49
50function statusPill(
51 ok: boolean | null,
52 label: string,
53 detail?: string
54): JSX.Element {
55 const cls = ok === null ? "cmd-pill cmd-pill-warn" : ok ? "cmd-pill cmd-pill-ok" : "cmd-pill cmd-pill-err";
56 return (
57 <span class={cls} title={detail ?? ""}>
58 <span class="cmd-pill-dot" />
59 {label}
60 </span>
61 );
62}
63
64function relativeTime(d: Date): string {
65 const sec = Math.floor((Date.now() - d.getTime()) / 1000);
66 if (sec < 60) return `${sec}s ago`;
67 if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
68 if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
69 return `${Math.floor(sec / 86400)}d ago`;
70}
71
72// ---------------------------------------------------------------------------
73// GET /admin/command
74// ---------------------------------------------------------------------------
75
76app.get("/admin/command", async (c) => {
77 const auth = c.get("user");
78 if (!auth) return c.redirect("/login?next=/admin/command");
79 if (!(await isSiteAdmin(auth.id))) return c.redirect("/admin?error=Not+a+site+admin");
80
81 const now = new Date();
82 const h1 = new Date(now.getTime() - 1 * 60 * 60 * 1000);
83 const h2 = new Date(now.getTime() - 2 * 60 * 60 * 1000);
84 const h24 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
85 const h6 = new Date(now.getTime() - 6 * 60 * 60 * 1000);
86
87 // ── 1. Platform status indicators ────────────────────────────────────────
88 let gateFailRate: number | null = null;
89 let deployFailRate: number | null = null;
90 let aiActivity: number | null = null;
91 let authFailures: number | null = null;
92 let recentErrors: number | null = null;
93
94 try {
95 const [total] = await db
96 .select({ n: sql<number>`count(*)::int` })
97 .from(gateRuns)
98 .where(gte(gateRuns.createdAt, h6));
99 const [failed] = await db
100 .select({ n: sql<number>`count(*)::int` })
101 .from(gateRuns)
102 .where(and(gte(gateRuns.createdAt, h6), eq(gateRuns.status, "failed")));
103 const t = Number(total?.n || 0);
104 gateFailRate = t === 0 ? 0 : Math.round((Number(failed?.n || 0) / t) * 100);
105 } catch (_) {}
106
107 try {
108 const [dfail] = await db
109 .select({ n: sql<number>`count(*)::int` })
110 .from(deployments)
111 .where(and(gte(deployments.createdAt, h24), eq(deployments.status, "failed")));
112 const [dtotal] = await db
113 .select({ n: sql<number>`count(*)::int` })
114 .from(deployments)
115 .where(gte(deployments.createdAt, h24));
116 const dt = Number(dtotal?.n || 0);
117 deployFailRate = dt === 0 ? 0 : Math.round((Number(dfail?.n || 0) / dt) * 100);
118 } catch (_) {}
119
120 try {
121 const [ai] = await db
122 .select({ n: sql<number>`count(*)::int` })
123 .from(prComments)
124 .where(and(gte(prComments.createdAt, h24), eq(prComments.isAiReview, true)));
125 aiActivity = Number(ai?.n || 0);
126 } catch (_) {}
127
128 try {
129 const [af] = await db
130 .select({ n: sql<number>`count(*)::int` })
131 .from(loginAttempts)
132 .where(and(gte(loginAttempts.createdAt, h1), eq(loginAttempts.success, false)));
133 authFailures = Number(af?.n || 0);
134 } catch (_) {}
135
136 try {
137 const [ae] = await db
138 .select({ n: sql<number>`count(*)::int` })
139 .from(auditLog)
140 .where(
141 and(
142 gte(auditLog.createdAt, h1),
143 sql`${auditLog.action} IN ('repo.delete','push.rejected','auth.lockout','admin.security','error')`
144 )
145 );
146 recentErrors = Number(ae?.n || 0);
147 } catch (_) {}
148
149 // ── 2. Failing gates (last 2h) ────────────────────────────────────────────
150 type FailingGate = {
151 id: string;
152 gateName: string;
153 status: string;
154 repoName: string;
155 ownerUsername: string;
156 prId: string | null;
157 createdAt: Date;
158 };
159 let failingGates: FailingGate[] = [];
160 try {
161 failingGates = await db
162 .select({
163 id: gateRuns.id,
164 gateName: gateRuns.gateName,
165 status: gateRuns.status,
166 repoName: repositories.name,
167 ownerUsername: users.username,
168 prId: gateRuns.pullRequestId,
169 createdAt: gateRuns.createdAt,
170 })
171 .from(gateRuns)
172 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
173 .innerJoin(users, eq(repositories.ownerId, users.id))
174 .where(and(gte(gateRuns.createdAt, h2), eq(gateRuns.status, "failed")))
175 .orderBy(desc(gateRuns.createdAt))
176 .limit(20);
177 } catch (_) {}
178
179 // ── 3. Deployment health (last 24h) ──────────────────────────────────────
180 type DeployRow = {
181 id: string;
182 status: string;
183 environment: string;
184 ref: string;
185 repoName: string;
186 ownerUsername: string;
187 blockedReason: string | null;
188 createdAt: Date;
189 };
190 let recentDeploys: DeployRow[] = [];
191 try {
192 recentDeploys = await db
193 .select({
194 id: deployments.id,
195 status: deployments.status,
196 environment: deployments.environment,
197 ref: deployments.ref,
198 repoName: repositories.name,
199 ownerUsername: users.username,
200 blockedReason: deployments.blockedReason,
201 createdAt: deployments.createdAt,
202 })
203 .from(deployments)
204 .innerJoin(repositories, eq(deployments.repositoryId, repositories.id))
205 .innerJoin(users, eq(repositories.ownerId, users.id))
206 .where(gte(deployments.createdAt, h24))
207 .orderBy(desc(deployments.createdAt))
208 .limit(15);
209 } catch (_) {}
210
211 // ── 4. AI engine activity (24h) ───────────────────────────────────────────
212 let aiReviews = 0, autoMerges = 0, issueTriages = 0, ciRepairs = 0;
213 try {
214 const [r] = await db
215 .select({ n: sql<number>`count(*)::int` })
216 .from(prComments)
217 .where(and(gte(prComments.createdAt, h24), eq(prComments.isAiReview, true)));
218 aiReviews = Number(r?.n || 0);
219
220 const [m] = await db
221 .select({ n: sql<number>`count(*)::int` })
222 .from(auditLog)
223 .where(
224 and(
225 gte(auditLog.createdAt, h24),
226 eq(auditLog.action, "auto_merge.merged")
227 )
228 );
229 autoMerges = Number(m?.n || 0);
230
231 const [it] = await db
232 .select({ n: sql<number>`count(*)::int` })
233 .from(issueComments)
234 .where(
235 and(
236 gte(issueComments.createdAt, h24),
237 sql`${issueComments.body} LIKE '%gluecron:issue-triage%'`
238 )
239 );
240 issueTriages = Number(it?.n || 0);
241
242 const [cr] = await db
243 .select({ n: sql<number>`count(*)::int` })
244 .from(auditLog)
245 .where(
246 and(
247 gte(auditLog.createdAt, h24),
248 sql`${auditLog.action} LIKE 'ci_autofix%'`
249 )
250 );
251 ciRepairs = Number(cr?.n || 0);
252 } catch (_) {}
253
254 // ── 5. Auth anomalies (failed login clusters, last hour) ─────────────────
255 type AuthAnomaly = { email: string; failCount: number; lastSeen: Date };
256 let authAnomalies: AuthAnomaly[] = [];
257 try {
258 const rows = await db
259 .select({
260 email: loginAttempts.email,
261 failCount: sql<number>`count(*)::int`,
262 lastSeen: sql<Date>`max(${loginAttempts.createdAt})`,
263 })
264 .from(loginAttempts)
265 .where(and(gte(loginAttempts.createdAt, h1), eq(loginAttempts.success, false)))
266 .groupBy(loginAttempts.email)
267 .having(sql`count(*) >= 3`)
268 .orderBy(sql`count(*) desc`)
269 .limit(10);
270 authAnomalies = rows.map((r) => ({
271 email: r.email,
272 failCount: Number(r.failCount),
273 lastSeen: new Date(r.lastSeen),
274 }));
275 } catch (_) {}
276
277 // ── 6. Recent audit errors (last hour) ───────────────────────────────────
278 type AuditEntry = {
279 id: string;
280 action: string;
281 username: string | null;
282 ip: string | null;
283 createdAt: Date;
284 };
285 let auditErrors: AuditEntry[] = [];
286 try {
287 auditErrors = await db
288 .select({
289 id: auditLog.id,
290 action: auditLog.action,
291 username: users.username,
292 ip: auditLog.ip,
293 createdAt: auditLog.createdAt,
294 })
295 .from(auditLog)
296 .leftJoin(users, eq(auditLog.userId, users.id))
297 .where(
298 and(
299 gte(auditLog.createdAt, h1),
300 sql`${auditLog.action} IN ('repo.delete','push.rejected','auth.lockout','admin.security.alert','error','deploy.failed','gate.force_pass')`
301 )
302 )
303 .orderBy(desc(auditLog.createdAt))
304 .limit(20);
305 } catch (_) {}
306
307 // ── 7. Recently active repos with open failing PRs ────────────────────────
308 type NeedAttention = {
309 ownerUsername: string;
310 repoName: string;
311 failedGates: number;
312 openPrs: number;
313 };
314 let needAttention: NeedAttention[] = [];
315 try {
316 const rows = await db
317 .select({
318 ownerUsername: users.username,
319 repoName: repositories.name,
320 failedGates: sql<number>`count(distinct ${gateRuns.id})::int`,
321 openPrs: sql<number>`count(distinct ${pullRequests.id})::int`,
322 })
323 .from(gateRuns)
324 .innerJoin(repositories, eq(gateRuns.repositoryId, repositories.id))
325 .innerJoin(users, eq(repositories.ownerId, users.id))
326 .leftJoin(
327 pullRequests,
328 and(
329 eq(pullRequests.repositoryId, repositories.id),
330 eq(pullRequests.state, "open")
331 )
332 )
333 .where(and(gte(gateRuns.createdAt, h24), eq(gateRuns.status, "failed")))
334 .groupBy(users.username, repositories.name)
335 .orderBy(sql`count(distinct ${gateRuns.id}) desc`)
336 .limit(8);
337 needAttention = rows.map((r) => ({
338 ownerUsername: r.ownerUsername,
339 repoName: r.repoName,
340 failedGates: Number(r.failedGates),
341 openPrs: Number(r.openPrs),
342 }));
343 } catch (_) {}
344
345 // ── Derived health indicators ─────────────────────────────────────────────
346 const gatesOk = gateFailRate === null ? null : gateFailRate <= 15;
347 const deploysOk = deployFailRate === null ? null : deployFailRate <= 10;
348 const aiOk = aiActivity === null ? null : true; // AI is "ok" as long as we got a count
349 const authOk = authFailures === null ? null : authFailures <= 20;
350 const errorsOk = recentErrors === null ? null : recentErrors === 0;
351
352 const overallOk =
353 gatesOk !== false && deploysOk !== false && authOk !== false && errorsOk !== false;
354
355 // ── Render ─────────────────────────────────────────────────────────────────
356 const styles = `
357 .cmd-wrap { max-width: 1680px; margin: 0 auto; padding: 0 var(--space-4); }
358
359 .cmd-header {
360 display: flex;
361 align-items: center;
362 justify-content: space-between;
363 gap: var(--space-4);
364 margin-bottom: var(--space-5);
365 padding: var(--space-4) var(--space-5);
366 background: var(--bg-elevated);
367 border: 1px solid var(--border);
368 border-radius: 14px;
369 }
370 .cmd-header-left { display: flex; align-items: center; gap: var(--space-3); }
371 .cmd-header-eyebrow { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.1em; }
372 .cmd-header-title { font-size: 22px; font-weight: 700; color: var(--text); margin: 2px 0 0; }
373 .cmd-overall { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
374 .cmd-overall-ok { color: var(--success); }
375 .cmd-overall-err { color: var(--danger); }
376
377 .cmd-status-bar {
378 display: flex;
379 gap: var(--space-3);
380 flex-wrap: wrap;
381 margin-bottom: var(--space-5);
382 }
383 .cmd-pill {
384 display: inline-flex;
385 align-items: center;
386 gap: 6px;
387 padding: 5px 12px;
388 border-radius: 20px;
389 font-size: 12px;
390 font-weight: 600;
391 border: 1px solid transparent;
392 }
393 .cmd-pill-ok { background: rgba(35,134,54,0.12); border-color: rgba(35,134,54,0.25); color: #3fb950; }
394 .cmd-pill-err { background: rgba(248,81,73,0.12); border-color: rgba(248,81,73,0.25); color: #f85149; }
395 .cmd-pill-warn { background: rgba(210,153,34,0.12); border-color: rgba(210,153,34,0.25); color: #d29922; }
396 .cmd-pill-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
397
398 .cmd-grid {
399 display: grid;
400 grid-template-columns: 1fr 1fr;
401 gap: var(--space-4);
402 margin-bottom: var(--space-4);
403 }
404 @media (max-width: 900px) { .cmd-grid { grid-template-columns: 1fr; } }
405 .cmd-grid-wide { grid-column: 1 / -1; }
406
407 .cmd-panel {
408 background: var(--bg-elevated);
409 border: 1px solid var(--border);
410 border-radius: 12px;
411 overflow: hidden;
412 }
413 .cmd-panel-head {
414 display: flex;
415 align-items: center;
416 justify-content: space-between;
417 padding: var(--space-3) var(--space-4);
418 border-bottom: 1px solid var(--border);
419 font-size: 12px;
420 font-weight: 700;
421 text-transform: uppercase;
422 letter-spacing: 0.07em;
423 color: var(--text-muted);
424 }
425 .cmd-panel-head-count {
426 font-size: 11px;
427 background: var(--bg-inset);
428 border: 1px solid var(--border);
429 border-radius: 10px;
430 padding: 1px 8px;
431 color: var(--text-muted);
432 font-weight: 500;
433 letter-spacing: 0;
434 }
435 .cmd-panel-body { padding: var(--space-3) 0; }
436 .cmd-empty { padding: var(--space-3) var(--space-4); color: var(--text-muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
437 .cmd-empty::before { content: '✓'; color: var(--success); font-weight: 700; }
438
439 .cmd-row {
440 display: flex;
441 align-items: flex-start;
442 gap: var(--space-3);
443 padding: var(--space-2) var(--space-4);
444 transition: background 120ms;
445 }
446 .cmd-row:hover { background: var(--bg-hover); }
447 .cmd-row-icon { font-size: 13px; margin-top: 1px; flex-shrink: 0; width: 16px; text-align: center; }
448 .cmd-row-main { flex: 1; min-width: 0; }
449 .cmd-row-title {
450 font-size: 13px;
451 color: var(--text);
452 font-weight: 500;
453 white-space: nowrap;
454 overflow: hidden;
455 text-overflow: ellipsis;
456 }
457 .cmd-row-title a { color: inherit; text-decoration: none; }
458 .cmd-row-title a:hover { color: var(--accent); text-decoration: underline; }
459 .cmd-row-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
460 .cmd-row-time { font-size: 11px; color: var(--text-muted); flex-shrink: 0; white-space: nowrap; }
461
462 .cmd-badge {
463 display: inline-block;
464 padding: 1px 7px;
465 border-radius: 9px;
466 font-size: 11px;
467 font-weight: 600;
468 }
469 .cmd-badge-fail { background: rgba(248,81,73,0.15); color: #f85149; }
470 .cmd-badge-ok { background: rgba(35,134,54,0.15); color: #3fb950; }
471 .cmd-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
472 .cmd-badge-pending { background: rgba(130,80,223,0.15); color: #9a7fde; }
473 .cmd-badge-running { background: rgba(88,166,255,0.15); color: #58a6ff; }
474
475 .cmd-ai-grid {
476 display: grid;
477 grid-template-columns: repeat(4, 1fr);
478 gap: var(--space-3);
479 padding: var(--space-4);
480 }
481 @media (max-width: 700px) { .cmd-ai-grid { grid-template-columns: repeat(2, 1fr); } }
482 .cmd-ai-card {
483 background: var(--bg-inset);
484 border: 1px solid var(--border);
485 border-radius: 10px;
486 padding: var(--space-3);
487 text-align: center;
488 }
489 .cmd-ai-value { font-size: 28px; font-weight: 700; color: var(--text); line-height: 1; margin-bottom: 4px; }
490 .cmd-ai-label { font-size: 11px; color: var(--text-muted); }
491
492 .cmd-links {
493 display: flex;
494 flex-wrap: wrap;
495 gap: var(--space-2);
496 padding: var(--space-4);
497 }
498 .cmd-link {
499 display: inline-flex;
500 align-items: center;
501 gap: 6px;
502 padding: 6px 12px;
503 background: var(--bg-inset);
504 border: 1px solid var(--border);
505 border-radius: 8px;
506 font-size: 12px;
507 color: var(--text-secondary);
508 text-decoration: none;
509 transition: border-color 120ms, color 120ms;
510 }
511 .cmd-link:hover { border-color: var(--accent); color: var(--accent); }
512
513 .cmd-refresh-note { font-size: 11px; color: var(--text-muted); }
514 `;
515
516 const deployStatusBadge = (status: string) => {
517 if (status === "success" || status === "succeeded") return <span class="cmd-badge cmd-badge-ok">{status}</span>;
518 if (status === "failed") return <span class="cmd-badge cmd-badge-fail">failed</span>;
519 if (status === "running") return <span class="cmd-badge cmd-badge-running">running</span>;
520 if (status === "pending" || status === "waiting_timer") return <span class="cmd-badge cmd-badge-pending">{status}</span>;
521 if (status === "blocked") return <span class="cmd-badge cmd-badge-warn">blocked</span>;
522 return <span class="cmd-badge cmd-badge-warn">{status}</span>;
523 };
524
525 return c.html(
526 <Layout title="Command Center — Admin" user={auth}>
527 <style>{styles}</style>
528 {/* Auto-refresh every 30s */}
529 <meta http-equiv="refresh" content="30" />
530
531 <div class="cmd-wrap">
532 {/* Header */}
533 <div class="cmd-header">
534 <div class="cmd-header-left">
535 <div>
536 <div class="cmd-header-eyebrow">Site Administration</div>
537 <div class="cmd-header-title">Command Center</div>
538 </div>
539 </div>
540 <div class="cmd-overall">
541 {overallOk ? (
542 <span class="cmd-overall-ok">● All systems nominal</span>
543 ) : (
544 <span class="cmd-overall-err">● Attention required</span>
545 )}
546 <span class="cmd-refresh-note">· refreshes every 30s</span>
547 </div>
548 </div>
549
550 {/* Status bar */}
551 <div class="cmd-status-bar">
552 {statusPill(
553 gatesOk,
554 gateFailRate === null ? "Gates: unknown" : `Gates: ${gateFailRate}% fail (6h)`,
555 "Gate run failure rate over the last 6 hours"
556 )}
557 {statusPill(
558 deploysOk,
559 deployFailRate === null ? "Deploys: unknown" : `Deploys: ${deployFailRate}% fail (24h)`,
560 "Deployment failure rate over the last 24 hours"
561 )}
562 {statusPill(
563 aiOk,
564 aiActivity === null ? "AI: unknown" : `AI: ${aiActivity} reviews (24h)`,
565 "AI review comments posted in the last 24 hours"
566 )}
567 {statusPill(
568 authOk,
569 authFailures === null ? "Auth: unknown" : `Auth: ${authFailures} failed logins (1h)`,
570 "Failed login attempts in the last hour"
571 )}
572 {statusPill(
573 errorsOk,
574 recentErrors === null ? "Errors: unknown" : recentErrors === 0 ? "No critical events (1h)" : `${recentErrors} critical events (1h)`,
575 "Security/delete/error audit events in the last hour"
576 )}
577 </div>
578
579 <div class="cmd-grid">
580 {/* Failing gates */}
581 <div class="cmd-panel">
582 <div class="cmd-panel-head">
583 <span>Failing gates</span>
584 <span class="cmd-panel-head-count">{failingGates.length} in last 2h</span>
585 </div>
586 <div class="cmd-panel-body">
587 {failingGates.length === 0 ? (
588 <div class="cmd-empty">No gate failures in the last 2 hours</div>
589 ) : (
590 failingGates.map((g) => (
591 <div class="cmd-row" key={g.id}>
592 <div class="cmd-row-icon">⛔</div>
593 <div class="cmd-row-main">
594 <div class="cmd-row-title">
595 <a href={`/${g.ownerUsername}/${g.repoName}`}>
596 {g.ownerUsername}/{g.repoName}
597 </a>
598 {" — "}
599 <span style="color:var(--text-muted)">{g.gateName}</span>
600 </div>
601 <div class="cmd-row-sub">
602 {g.prId ? (
603 <a href={`/${g.ownerUsername}/${g.repoName}/pulls`} style="color:inherit">
604 View PR →
605 </a>
606 ) : "push gate"}
607 </div>
608 </div>
609 <div class="cmd-row-time">{relativeTime(g.createdAt)}</div>
610 </div>
611 ))
612 )}
613 </div>
614 </div>
615
616 {/* Deployment health */}
617 <div class="cmd-panel">
618 <div class="cmd-panel-head">
619 <span>Deployments</span>
620 <span class="cmd-panel-head-count">{recentDeploys.length} in 24h</span>
621 </div>
622 <div class="cmd-panel-body">
623 {recentDeploys.length === 0 ? (
624 <div class="cmd-empty">No deployments in the last 24 hours</div>
625 ) : (
626 recentDeploys.map((d) => (
627 <div class="cmd-row" key={d.id}>
628 <div class="cmd-row-icon">
629 {d.status === "success" || d.status === "succeeded" ? "🟢" :
630 d.status === "failed" ? "🔴" :
631 d.status === "running" ? "🔵" : "🟡"}
632 </div>
633 <div class="cmd-row-main">
634 <div class="cmd-row-title">
635 <a href={`/${d.ownerUsername}/${d.repoName}/deployments`}>
636 {d.ownerUsername}/{d.repoName}
637 </a>
638 {" "}{deployStatusBadge(d.status)}
639 </div>
640 <div class="cmd-row-sub">
641 {d.environment} · {d.ref.replace("refs/heads/", "")}
642 {d.blockedReason ? ` · ${d.blockedReason.slice(0, 60)}` : ""}
643 </div>
644 </div>
645 <div class="cmd-row-time">{relativeTime(d.createdAt)}</div>
646 </div>
647 ))
648 )}
649 </div>
650 </div>
651
652 {/* Repos needing attention */}
653 <div class="cmd-panel">
654 <div class="cmd-panel-head">
655 <span>Repos needing attention</span>
656 <span class="cmd-panel-head-count">{needAttention.length} repos (24h)</span>
657 </div>
658 <div class="cmd-panel-body">
659 {needAttention.length === 0 ? (
660 <div class="cmd-empty">All repos healthy in the last 24 hours</div>
661 ) : (
662 needAttention.map((r) => (
663 <div class="cmd-row" key={`${r.ownerUsername}/${r.repoName}`}>
664 <div class="cmd-row-icon">⚠</div>
665 <div class="cmd-row-main">
666 <div class="cmd-row-title">
667 <a href={`/${r.ownerUsername}/${r.repoName}`}>
668 {r.ownerUsername}/{r.repoName}
669 </a>
670 </div>
671 <div class="cmd-row-sub">
672 {r.failedGates} gate failure{r.failedGates === 1 ? "" : "s"} ·{" "}
673 {r.openPrs} open PR{r.openPrs === 1 ? "" : "s"}
674 </div>
675 </div>
676 <div class="cmd-row-time">
677 <a href={`/${r.ownerUsername}/${r.repoName}/gates`} style="color:var(--accent);font-size:11px">
678 Gates →
679 </a>
680 </div>
681 </div>
682 ))
683 )}
684 </div>
685 </div>
686
687 {/* Auth anomalies */}
688 <div class="cmd-panel">
689 <div class="cmd-panel-head">
690 <span>Auth anomalies</span>
691 <span class="cmd-panel-head-count">≥3 failures in 1h</span>
692 </div>
693 <div class="cmd-panel-body">
694 {authAnomalies.length === 0 ? (
695 <div class="cmd-empty">No login clusters detected this hour</div>
696 ) : (
697 authAnomalies.map((a) => (
698 <div class="cmd-row" key={a.email}>
699 <div class="cmd-row-icon">🔐</div>
700 <div class="cmd-row-main">
701 <div class="cmd-row-title">{a.email}</div>
702 <div class="cmd-row-sub">{a.failCount} failed attempts</div>
703 </div>
704 <div class="cmd-row-time">{relativeTime(a.lastSeen)}</div>
705 </div>
706 ))
707 )}
708 </div>
709 </div>
710
711 {/* AI engine stats — full width */}
712 <div class="cmd-panel cmd-grid-wide">
713 <div class="cmd-panel-head">
714 <span>AI engine — last 24h</span>
715 </div>
716 <div class="cmd-ai-grid">
717 <div class="cmd-ai-card">
718 <div class="cmd-ai-value">{aiReviews}</div>
719 <div class="cmd-ai-label">Code reviews</div>
720 </div>
721 <div class="cmd-ai-card">
722 <div class="cmd-ai-value">{autoMerges}</div>
723 <div class="cmd-ai-label">Auto-merges</div>
724 </div>
725 <div class="cmd-ai-card">
726 <div class="cmd-ai-value">{issueTriages}</div>
727 <div class="cmd-ai-label">Issue triages</div>
728 </div>
729 <div class="cmd-ai-card">
730 <div class="cmd-ai-value">{ciRepairs}</div>
731 <div class="cmd-ai-label">CI repairs</div>
732 </div>
733 </div>
734 </div>
735
736 {/* Recent audit errors — full width */}
737 <div class="cmd-panel cmd-grid-wide">
738 <div class="cmd-panel-head">
739 <span>Critical audit events</span>
740 <span class="cmd-panel-head-count">last hour</span>
741 </div>
742 <div class="cmd-panel-body">
743 {auditErrors.length === 0 ? (
744 <div class="cmd-empty">No critical events in the last hour</div>
745 ) : (
746 auditErrors.map((e) => (
747 <div class="cmd-row" key={e.id}>
748 <div class="cmd-row-icon">📋</div>
749 <div class="cmd-row-main">
750 <div class="cmd-row-title">
751 <code style="font-size:12px">{e.action}</code>
752 {e.username ? ` · ${e.username}` : ""}
753 </div>
754 <div class="cmd-row-sub">{e.ip || "no IP"}</div>
755 </div>
756 <div class="cmd-row-time">{relativeTime(e.createdAt)}</div>
757 </div>
758 ))
759 )}
760 </div>
761 </div>
762
763 {/* Quick links — full width */}
764 <div class="cmd-panel cmd-grid-wide">
765 <div class="cmd-panel-head">
766 <span>Quick access</span>
767 </div>
768 <div class="cmd-links">
769 {[
770 ["/admin", "Dashboard"],
771 ["/admin/ops", "Operations"],
772 ["/admin/deploys", "Deploy log"],
773 ["/admin/env-health", "Feature health"],
774 ["/admin/diagnose", "AI diagnostics"],
775 ["/admin/security", "Security"],
776 ["/admin/users", "Users"],
777 ["/admin/repos", "Repositories"],
778 ["/admin/ai-costs", "AI costs"],
779 ["/admin/autopilot", "Autopilot"],
780 ["/admin/integrations", "Integrations"],
781 ["/admin/growth", "User growth"],
782 ["/admin/flags", "Site flags"],
783 ["/admin/billing", "Billing"],
784 ["/admin/digests", "Email digests"],
785 ["/admin/sso", "SSO config"],
786 ["/status", "Public status"],
787 ].map(([href, label]) => (
788 <a href={href} class="cmd-link" key={href}>
789 {label}
790 </a>
791 ))}
792 </div>
793 </div>
794 </div>
795 </div>
796 </Layout>
797 );
798});
799
800export default app;