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

dashboard.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.

dashboard.tsxBlame1200 lines · 4 contributors
f1ab587Claude1/**
2 * Command Center — the live dashboard.
3 *
4 * This is the developer's mission control. One screen that shows:
5 * - All your repos with health scores at a glance
6 * - Live push feed (what just happened, risk scores, repairs)
7 * - CI status for every repo
8 * - Security alerts
9 * - Quick actions (rollback, repair, deploy)
10 *
11 * Plus intelligence settings — toggle auto-repair, scanning, etc.
12 *
13 * GitHub gives you a feed of stars and follows.
14 * gluecron gives you a COMMAND CENTER.
15 */
16
17import { Hono } from "hono";
18import { eq, desc, and } from "drizzle-orm";
c63b860Claude19import { getCookie, setCookie } from "hono/cookie";
f1ab587Claude20import { db } from "../db";
21import {
22 repositories,
23 users,
24 activityFeed,
25 issues,
26 pullRequests,
27} from "../db/schema";
28import { Layout } from "../views/layout";
febd4f0Claude29import { LiveFeed } from "../views/live-feed";
f1ab587Claude30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 computeHealthScore,
34 detectCIConfig,
35} from "../lib/intelligence";
36import {
37 repoExists,
38 getDefaultBranch,
39 listCommits,
40 listBranches,
41} from "../git/repository";
46d6165Claude42import {
43 computeAiSavingsForUser,
44 computeLifetimeAiSavingsForUser,
45 type AiSavingsReport,
46 type AiSavingsLifetimeReport,
47} from "../lib/ai-hours-saved";
f1ab587Claude48
49const dashboard = new Hono<AuthEnv>();
50
51dashboard.use("*", softAuth);
52
53// ─── COMMAND CENTER ──────────────────────────────────────────
54
55dashboard.get("/dashboard", requireAuth, async (c) => {
56 const user = c.get("user")!;
57
c63b860Claude58 // Block P2 — banner dismiss handler. Set a session cookie and re-redirect
59 // to the bare /dashboard URL so refreshing doesn't keep firing the dismiss.
60 if (c.req.query("p2_dismiss") === "1") {
61 setCookie(c, "p2_verify_dismissed", "1", {
62 path: "/",
63 httpOnly: true,
64 sameSite: "Lax",
65 });
66 return c.redirect("/dashboard");
67 }
68
f1dc7c7Claude69 // ── Loading skeleton (flag-gated) ──
70 // Render an SSR'd structural preview when `?skeleton=1` is present.
71 // Keeps the user oriented on first paint while DB warms up. Behind a
72 // flag until we wire it to streamed/replaced content — we don't ship
73 // a flash before the real markup lands.
74 if (c.req.query("skeleton") === "1") {
75 return c.html(
76 <Layout title="Command Center" user={user}>
77 <DashboardSkeleton />
78 </Layout>
79 );
80 }
81
f1ab587Claude82 // Get all user's repos
83 const repos = await db
84 .select()
85 .from(repositories)
86 .where(eq(repositories.ownerId, user.id))
87 .orderBy(desc(repositories.updatedAt));
88
89 // Compute health scores for all repos (in parallel)
90 const repoData = await Promise.all(
91 repos.map(async (repo) => {
92 let healthScore = 0;
93 let healthGrade = "?" as string;
94 let recentCommits = 0;
95 let branchCount = 0;
96 let ciConfig = null;
97
98 try {
99 if (await repoExists(user.username, repo.name)) {
100 const ref =
101 (await getDefaultBranch(user.username, repo.name)) || "main";
102 const [health, commits, branches, ci] = await Promise.all([
103 computeHealthScore(user.username, repo.name).catch(() => null),
104 listCommits(user.username, repo.name, ref, 5).catch(() => []),
105 listBranches(user.username, repo.name).catch(() => []),
106 detectCIConfig(user.username, repo.name, ref).catch(() => null),
107 ]);
108 if (health) {
109 healthScore = health.score;
110 healthGrade = health.grade;
111 }
112 recentCommits = commits.length;
113 branchCount = branches.length;
114 ciConfig = ci;
115 }
116 } catch {
117 // best effort
118 }
119
120 return {
121 repo,
122 healthScore,
123 healthGrade,
124 recentCommits,
125 branchCount,
126 ciConfig,
127 };
128 })
129 );
130
46d6165Claude131 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
132 // parallel; both helpers swallow DB errors so the dashboard always renders.
133 const [savingsWeek, savingsLifetime] = await Promise.all([
134 computeAiSavingsForUser(user.id, { windowHours: 168 }),
135 computeLifetimeAiSavingsForUser(user.id),
136 ]);
137
f1ab587Claude138 // Get recent activity
139 let recentActivity: Array<{
140 action: string;
141 repoName: string;
142 metadata: string | null;
143 createdAt: Date;
144 }> = [];
145
146 try {
147 const repoIds = repos.map((r) => r.id);
148 if (repoIds.length > 0) {
149 const activity = await db
150 .select({
151 action: activityFeed.action,
152 metadata: activityFeed.metadata,
153 createdAt: activityFeed.createdAt,
154 repoId: activityFeed.repositoryId,
155 })
156 .from(activityFeed)
157 .where(eq(activityFeed.userId, user.id))
158 .orderBy(desc(activityFeed.createdAt))
159 .limit(20);
160
161 recentActivity = activity.map((a) => ({
162 action: a.action,
163 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
164 metadata: a.metadata,
165 createdAt: a.createdAt,
166 }));
167 }
168 } catch {
169 // DB not required for dashboard
170 }
171
172 const gradeColor = (grade: string) =>
173 grade === "A+" || grade === "A"
174 ? "var(--green)"
175 : grade === "B"
176 ? "#58a6ff"
177 : grade === "C"
178 ? "var(--yellow)"
179 : grade === "?"
180 ? "var(--text-muted)"
181 : "var(--red)";
182
c63b860Claude183 // Block P2 — email verification banner. Shows when the user hasn't
184 // verified yet AND they haven't dismissed it this session. Also surfaces
185 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
186 // and the post-register hint (`?welcome=1`).
187 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
188 const showVerifyBanner =
189 !(user as any).emailVerifiedAt && !verifyDismissed;
190 const verifyQuery = c.req.query("verify");
191 const welcomeQuery = c.req.query("welcome");
192
f1ab587Claude193 return c.html(
194 <Layout title="Command Center" user={user}>
c63b860Claude195 {showVerifyBanner && (
196 <div
dc26881CC LABS App197 style="background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.45); color: #e3b341; padding: var(--space-3) var(--space-4); border-radius: 8px; margin-bottom: var(--space-4); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: 14px"
c63b860Claude198 data-p2-verify-banner=""
199 >
200 <div style="flex: 1 1 auto; min-width: 0">
201 {welcomeQuery === "1" ? (
202 <span>
203 Welcome to Gluecron! Check your inbox to verify your email.
204 </span>
205 ) : verifyQuery === "sent" ? (
206 <span>
207 Verification link sent. It may take a minute to arrive.
208 </span>
209 ) : verifyQuery === "rate_limited" ? (
210 <span>
211 You've requested too many verification emails. Try again later.
212 </span>
826eccfTest User213 ) : verifyQuery === "not_configured" ? (
214 <span>
215 Email delivery isn't configured on this instance yet — your
216 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
217 <code>RESEND_API_KEY</code>. Until then the verification link
218 is written to the server log.
219 </span>
c63b860Claude220 ) : (
221 <span>Verify your email to keep using Gluecron.</span>
222 )}
223 </div>
224 <form
225 method="post"
226 action="/verify-email/resend"
dc26881CC LABS App227 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude228 >
229 <input
230 type="hidden"
231 name="_csrf"
232 value={(c.get("csrfToken") as string | undefined) || ""}
233 />
234 <button
235 type="submit"
236 class="btn"
237 style="padding: 4px 10px; font-size: 12px"
238 >
239 Resend verification link
240 </button>
241 <a
242 href="/dashboard?p2_dismiss=1"
243 class="btn"
244 style="padding: 4px 10px; font-size: 12px"
245 aria-label="Dismiss verification banner"
246 >
247 Dismiss
248 </a>
249 </form>
250 </div>
251 )}
a004c46Claude252 <div class="dash-hero">
253 <div class="dash-hero-bg" aria-hidden="true">
254 <div class="dash-hero-orb" />
f1ab587Claude255 </div>
a004c46Claude256 <div class="dash-hero-inner">
257 <div class="dash-hero-text">
258 <div class="dash-hero-eyebrow">
259 {(() => {
260 const hour = new Date().getHours();
261 if (hour < 5) return "Late night,";
262 if (hour < 12) return "Good morning,";
263 if (hour < 17) return "Good afternoon,";
264 if (hour < 21) return "Good evening,";
265 return "Late night,";
266 })()}{" "}
267 <span class="dash-hero-username">{user.username}</span>
268 </div>
269 <h1 class="dash-hero-title">
270 Your{" "}
271 <span class="gradient-text">command center</span>.
272 </h1>
273 <p class="dash-hero-sub">
274 {repos.length === 0
275 ? "Create your first repository to start shipping with AI."
276 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
277 </p>
278 </div>
279 <div class="dash-hero-actions">
280 <a href="/new" class="btn btn-primary">+ New repo</a>
281 <a href="/import" class="btn">Import from GitHub</a>
282 <a href="/settings" class="btn">Settings</a>
283 </div>
f1ab587Claude284 </div>
285 </div>
a004c46Claude286 <style
287 dangerouslySetInnerHTML={{
288 __html: `
289 .dash-hero {
290 position: relative;
291 margin-bottom: var(--space-6);
292 padding: var(--space-5) var(--space-6) var(--space-5);
293 background: var(--bg-elevated);
294 border: 1px solid var(--border);
295 border-radius: 16px;
296 overflow: hidden;
297 }
298 .dash-hero::before {
299 content: '';
300 position: absolute;
301 top: 0; left: 0; right: 0;
302 height: 2px;
303 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
304 opacity: 0.7;
305 pointer-events: none;
306 }
307 .dash-hero-bg {
308 position: absolute;
309 inset: -20% -10% auto auto;
310 width: 380px;
311 height: 380px;
312 pointer-events: none;
313 z-index: 0;
314 }
315 .dash-hero-orb {
316 position: absolute;
317 inset: 0;
318 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
319 filter: blur(80px);
320 opacity: 0.7;
321 animation: dashHeroOrb 14s ease-in-out infinite;
322 }
323 @keyframes dashHeroOrb {
324 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
325 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
326 }
327 @media (prefers-reduced-motion: reduce) {
328 .dash-hero-orb { animation: none; }
329 }
330 .dash-hero-inner {
331 position: relative;
332 z-index: 1;
333 display: flex;
334 justify-content: space-between;
335 align-items: flex-end;
336 gap: var(--space-4);
337 flex-wrap: wrap;
338 }
339 .dash-hero-text { flex: 1; min-width: 280px; }
340 .dash-hero-eyebrow {
341 font-size: 13px;
342 color: var(--text-muted);
343 margin-bottom: var(--space-2);
344 letter-spacing: -0.005em;
345 }
346 .dash-hero-username {
347 color: var(--accent);
348 font-weight: 600;
349 }
350 .dash-hero-title {
351 font-size: clamp(28px, 4vw, 40px);
352 font-family: var(--font-display);
353 font-weight: 800;
354 letter-spacing: -0.028em;
355 line-height: 1.05;
356 margin: 0 0 var(--space-2);
357 color: var(--text-strong);
358 }
359 .dash-hero-title .gradient-text {
360 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
361 -webkit-background-clip: text;
362 background-clip: text;
363 -webkit-text-fill-color: transparent;
364 color: transparent;
365 }
366 .dash-hero-sub {
367 font-size: 15px;
368 color: var(--text-muted);
369 margin: 0;
370 line-height: 1.5;
371 max-width: 580px;
372 }
373 .dash-hero-actions {
374 display: flex;
375 gap: var(--space-2);
376 flex-wrap: wrap;
377 }
378 @media (max-width: 720px) {
379 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
380 .dash-hero-actions { width: 100%; }
f1dc7c7Claude381 .dash-hero-actions .btn { flex: 1; min-width: 0; min-height: 44px; }
382 .dash-hero { padding: var(--space-4); }
383 .dash-hero-text { min-width: 0; }
384 .dash-hero-bg { width: 220px; height: 220px; inset: -10% -20% auto auto; }
385 .ai-hours-saved-tabs { flex-wrap: wrap; }
a004c46Claude386 }
387 `,
388 }}
389 />
f1ab587Claude390
46d6165Claude391 {/* ─── L9: AI hours-saved hero widget ─── */}
392 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
393
f1ab587Claude394 {/* ─── Stats Bar ─── */}
dc26881CC LABS App395 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude396 <StatBox
397 label="Repositories"
398 value={String(repos.length)}
399 color="var(--text-link)"
400 />
401 <StatBox
402 label="Avg Health"
403 value={
404 repos.length > 0
405 ? String(
406 Math.round(
407 repoData.reduce((s, r) => s + r.healthScore, 0) /
408 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
409 )
410 )
411 : "—"
412 }
413 color="var(--green)"
414 />
415 <StatBox
416 label="Total Stars"
417 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
418 color="var(--yellow)"
419 />
420 <StatBox
421 label="Open Issues"
422 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
423 color="var(--red)"
424 />
425 </div>
426
427 {/* ─── Repo Grid ─── */}
428 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
429 {repos.length === 0 ? (
dc26881CC LABS App430 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude431 <div style="text-align:center;margin-bottom:20px">
432 <h2 style="margin-bottom:6px">Get started</h2>
433 <p style="color:var(--text-muted);font-size:14px;margin:0">
434 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
435 </p>
436 </div>
437 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App438 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude439 <div style="flex:1">
440 <div style="font-size:15px;font-weight:600">Create a new repository</div>
441 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
442 Start from scratch with green-ecosystem defaults.
443 </div>
444 </div>
445 <a href="/new" class="btn btn-primary">Create repo</a>
446 </div>
dc26881CC LABS App447 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude448 <div style="flex:1">
449 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
450 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
451 Mirror an existing repo — history, branches, tags.
452 </div>
453 </div>
454 <a href="/import" class="btn">Import repo</a>
455 </div>
dc26881CC LABS App456 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude457 <div style="flex:1">
458 <div style="font-size:15px;font-weight:600">Browse public repos</div>
459 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
460 See what others are building, fork what you like.
461 </div>
462 </div>
463 <a href="/explore" class="btn">Browse</a>
464 </div>
465 </div>
dc26881CC LABS App466 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude467 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
468 Push an existing project (preview)
469 </div>
470 <pre style="margin:0;font-size:12px;overflow-x:auto;color:var(--text-muted)"><code># Once you create a repo, you'll see your real clone URL here.
471git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
472git push -u gluecron main</code></pre>
473 </div>
f1ab587Claude474 </div>
475 ) : (
dc26881CC LABS App476 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude477 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
478 <div class="card" style="padding: 0; overflow: hidden">
479 {/* Health bar at top */}
480 <div
481 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
482 />
dc26881CC LABS App483 <div style="padding: var(--space-4)">
f1ab587Claude484 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
485 <div>
486 <h3 style="font-size: 16px; margin-bottom: 2px">
487 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
488 </h3>
489 {repo.description && (
490 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
491 {repo.description}
492 </p>
493 )}
494 </div>
495 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
496 <div
497 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
498 >
499 {healthGrade}
500 </div>
501 <div style="font-size: 10px; color: var(--text-muted)">
502 {healthScore}/100
503 </div>
504 </div>
505 </div>
506
dc26881CC LABS App507 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude508 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
509 <span>{"\u2606"} {repo.starCount}</span>
510 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
511 </div>
512
513 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App514 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude515 {ciConfig.detected.slice(0, 3).map((d) => (
516 <span
517 class="badge"
518 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
519 >
520 {d}
521 </span>
522 ))}
523 </div>
524 )}
525
dc26881CC LABS App526 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude527 <a
528 href={`/${user.username}/${repo.name}/health`}
529 class="btn btn-sm"
530 style="font-size: 11px; padding: 2px 8px"
531 >
532 Health
533 </a>
534 <a
535 href={`/${user.username}/${repo.name}/dependencies`}
536 class="btn btn-sm"
537 style="font-size: 11px; padding: 2px 8px"
538 >
539 Deps
540 </a>
541 <a
542 href={`/${user.username}/${repo.name}/coupling`}
543 class="btn btn-sm"
544 style="font-size: 11px; padding: 2px 8px"
545 >
546 Insights
547 </a>
548 <a
549 href={`/${user.username}/${repo.name}/settings`}
550 class="btn btn-sm"
551 style="font-size: 11px; padding: 2px 8px"
552 >
553 Settings
554 </a>
555 </div>
556 </div>
557 </div>
558 ))}
559 </div>
560 )}
561
562 {/* ─── Activity Feed ─── */}
563 {recentActivity.length > 0 && (
564 <>
565 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
566 <div class="issue-list">
567 {recentActivity.map((a) => (
568 <div class="issue-item">
dc26881CC LABS App569 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude570 <ActivityIcon action={a.action} />
571 <div>
572 <span style="font-size: 14px">
573 {formatAction(a.action)} in{" "}
574 <a href={`/${user.username}/${a.repoName}`}>
575 {a.repoName}
576 </a>
577 </span>
578 <div style="font-size: 12px; color: var(--text-muted)">
579 {formatRelative(a.createdAt)}
580 </div>
581 </div>
582 </div>
583 </div>
584 ))}
585 </div>
586 </>
587 )}
588
7a14837Claude589 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
590 <HealthCoach repoData={repoData} username={user.username} />
591
febd4f0Claude592 {/* ─── Live Activity (SSE) ─── */}
593 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
594
f1ab587Claude595 {/* ─── Quick Links ─── */}
dc26881CC LABS App596 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude597 <a href="/explore" class="btn">Browse public repos</a>
598 <a href="/settings/tokens" class="btn">API tokens</a>
599 <a href="/settings/keys" class="btn">SSH keys</a>
600 </div>
601 </Layout>
602 );
603});
604
605// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
606
607dashboard.get(
608 "/:owner/:repo/settings/intelligence",
609 requireAuth,
610 async (c) => {
611 const { owner: ownerName, repo: repoName } = c.req.param();
612 const user = c.get("user")!;
613 const success = c.req.query("success");
614
615 return c.html(
616 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
617 <div style="max-width: 600px">
618 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
619 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
620 Control what gluecron does automatically when code is pushed to{" "}
621 <strong>{ownerName}/{repoName}</strong>.
622 </p>
623 {success && (
624 <div class="auth-success">{decodeURIComponent(success)}</div>
625 )}
626 <form
0316dbbClaude627 method="post"
f1ab587Claude628 action={`/${ownerName}/${repoName}/settings/intelligence`}
629 >
630 <ToggleSetting
631 name="auto_repair"
632 label="Auto-Repair"
633 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
634 defaultChecked={true}
635 />
636 <ToggleSetting
637 name="security_scan"
638 label="Security Scanning"
639 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
640 defaultChecked={true}
641 />
642 <ToggleSetting
643 name="health_score"
644 label="Health Score"
645 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
646 defaultChecked={true}
647 />
648 <ToggleSetting
649 name="push_analysis"
650 label="Push Risk Analysis"
651 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
652 defaultChecked={true}
653 />
654 <ToggleSetting
655 name="dep_analysis"
656 label="Dependency Analysis"
657 description="Build import graph, detect unused deps, find circular dependencies"
658 defaultChecked={true}
659 />
660 <ToggleSetting
661 name="gatetest"
662 label="GateTest Integration"
663 description="Send push events to GateTest for external security scanning"
664 defaultChecked={true}
665 />
666 <ToggleSetting
90fa787Claude667 name="deploy_webhook"
668 label="Auto-Deploy Webhook"
669 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude670 defaultChecked={true}
671 />
672
673 <button
674 type="submit"
675 class="btn btn-primary"
676 style="margin-top: 12px"
677 >
678 Save settings
679 </button>
680 </form>
681 </div>
682 </Layout>
683 );
684 }
685);
686
687dashboard.post(
688 "/:owner/:repo/settings/intelligence",
689 requireAuth,
690 async (c) => {
691 const { owner: ownerName, repo: repoName } = c.req.param();
692 // In production, these would be saved to DB per-repo
693 // For now, acknowledge the settings
694 return c.redirect(
695 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
696 );
697 }
698);
699
700// ─── PUSH LOG ────────────────────────────────────────────────
701
702dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
703 const { owner, repo } = c.req.param();
704 const user = c.get("user");
705
706 if (!(await repoExists(owner, repo))) return c.notFound();
707 const ref = (await getDefaultBranch(owner, repo)) || "main";
708 const commits = await listCommits(owner, repo, ref, 30);
709
710 return c.html(
711 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
712 <div style="max-width: 900px">
713 <h2 style="margin-bottom: 4px">Push Log</h2>
714 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
715 Every push analyzed in real-time — risk scores, repairs, security alerts
716 </p>
717 <div class="issue-list">
718 {commits.map((commit) => {
719 // Determine if this was an auto-repair commit
720 const isRepair =
721 commit.author === "gluecron[bot]" ||
722 commit.message.includes("auto-repair");
723 const isRollback = commit.message.startsWith("revert: rollback");
724
725 return (
726 <div class="issue-item" style="flex-direction: column; align-items: stretch">
727 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App728 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude729 {isRepair ? (
730 <span
731 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
732 title="Auto-repair"
733 >
734 {"⚡"}
735 </span>
736 ) : isRollback ? (
737 <span
738 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
739 title="Rollback"
740 >
741 {"↩"}
742 </span>
743 ) : (
744 <span
745 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
746 >
747 {"→"}
748 </span>
749 )}
750 <div>
751 <a
752 href={`/${owner}/${repo}/commit/${commit.sha}`}
753 style="font-weight: 600; font-size: 14px"
754 >
755 {commit.message}
756 </a>
757 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
758 {commit.author} —{" "}
759 {new Date(commit.date).toLocaleString("en-US", {
760 month: "short",
761 day: "numeric",
762 hour: "2-digit",
763 minute: "2-digit",
764 })}
765 </div>
766 </div>
767 </div>
768 <a
769 href={`/${owner}/${repo}/commit/${commit.sha}`}
770 class="commit-sha"
771 >
772 {commit.sha.slice(0, 7)}
773 </a>
774 </div>
775 {isRepair && (
776 <div
dc26881CC LABS App777 style="margin-top: var(--space-2); padding: var(--space-2) var(--space-3); background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
f1ab587Claude778 >
779 Automatically repaired by gluecron
780 </div>
781 )}
782 </div>
783 );
784 })}
785 </div>
786 </div>
787 </Layout>
788 );
789});
790
791// ─── COMPONENTS ──────────────────────────────────────────────
792
46d6165Claude793/**
794 * Block L9 — pure formatter used by the dashboard widget AND tests.
795 * Turns the breakdown into the small stat-pill array shown under the
796 * big number. Exported so the markup contract is testable without
797 * importing JSX.
798 */
799export function formatSavingsPills(b: {
800 prsAutoMerged: number;
801 issuesBuiltByAi: number;
802 aiReviewsPosted: number;
803 aiTriagesPosted: number;
804 aiCommitMsgs: number;
805 secretsAutoRepaired: number;
806 gateAutoRepairs: number;
807}): string[] {
808 const pills: string[] = [];
809 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
810 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
811 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
812 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
813 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
814 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
815 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
816 return pills;
817}
818
819const AiHoursSavedWidget = ({
820 week,
821 lifetime,
822}: {
823 week: AiSavingsReport;
824 lifetime: AiSavingsLifetimeReport;
825}) => {
826 const weekPills = formatSavingsPills(week.breakdown);
827 const lifetimePills = formatSavingsPills(lifetime.breakdown);
828 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
829 return (
830 <div
831 class="card ai-hours-saved-widget"
832 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
833 >
dc26881CC LABS App834 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
835 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude836 <div style="flex:1;min-width:240px">
837 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
838 AI working for you
839 </div>
840 <div
841 data-testid="ai-hours-saved-this-week"
842 style="font-size: 56px; font-weight: 800; line-height: 1; background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent"
843 >
844 {week.hoursSaved.toFixed(1)}h
845 </div>
846 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
847 Claude saved you{" "}
848 <strong style="color: var(--text)">
849 {week.hoursSaved.toFixed(1)} hours
850 </strong>{" "}
851 this week.
852 {lifetime.hoursSaved > week.hoursSaved && (
853 <span>
854 {" — "}
855 <strong style="color: var(--text)">
856 {lifetime.hoursSaved.toFixed(1)}h
857 </strong>{" "}
858 all-time.
859 </span>
860 )}
861 </div>
862 </div>
863 <div
864 class="ai-hours-saved-tabs"
865 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
866 >
867 <span
868 data-tab="this-week"
869 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
870 >
871 This week
872 </span>
873 <span
874 data-tab="all-time"
875 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
876 >
877 All-time
878 </span>
879 </div>
880 </div>
881
882 {hasAnyWeek ? (
dc26881CC LABS App883 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude884 {weekPills.map((p) => (
885 <span
886 class="badge"
887 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
888 >
889 {p}
890 </span>
891 ))}
892 </div>
893 ) : (
894 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
895 No AI activity this week yet — open a PR, label an issue{" "}
896 <code>ai:build</code>, or let auto-merge sweep your branches.
897 The counter will start climbing.
898 </div>
899 )}
900
901 <details style="margin-top:16px">
902 <summary
903 data-testid="ai-hours-saved-formula-toggle"
904 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
905 >
906 How is this calculated?
907 </summary>
908 <div
909 style="margin-top:10px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:12px;color:var(--text-muted);font-family:var(--font-mono, monospace);line-height:1.6"
910 >
911 <div>hoursSaved =</div>
912 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
913 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
914 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
915 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
916 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
917 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
918 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
919 <div style="margin-top:6px;color:var(--text)">
920 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
921 {week.windowHours}h window)
922 </div>
923 <div style="margin-top:8px;font-size:11px">
924 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
925 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
926 Constants are conservative on purpose — audit-friendly is
927 the brand.
928 </div>
929 {lifetimePills.length > 0 && (
930 <div style="margin-top:8px;font-size:11px">
931 All-time breakdown: {lifetimePills.join(" · ")}
932 </div>
933 )}
934 </div>
935 </details>
936 </div>
937 </div>
938 );
939};
940
7a14837Claude941/**
942 * Pure helper: pick the bottom-N repos by health score and return a
943 * prioritized "fix this next" plan. Health=0 repos (couldn't be
944 * computed) are excluded so the coach doesn't recommend ghost repos.
945 *
946 * Exported under __test for unit testing without touching the DB.
947 */
948export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
949 repoData: T[],
950 topN = 3
951): T[] {
952 return repoData
953 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
954 .sort((a, b) => a.healthScore - b.healthScore)
955 .slice(0, topN);
956}
957
958/** Module-scoped color picker for grade chips. Mirrors the inner
959 * `gradeColor` defined in the request handler scope, exposed at module
960 * level so HealthCoach (also module-scope) can reach it. */
961function moduleGradeColor(grade: string): string {
962 if (grade === "A+" || grade === "A") return "var(--green)";
963 if (grade === "B") return "#58a6ff";
964 if (grade === "C") return "var(--yellow)";
965 if (grade === "?") return "var(--text-muted)";
966 return "var(--red)";
967}
968
969const HealthCoach = ({
970 repoData,
971 username,
972}: {
973 repoData: Array<{
974 repo: { name: string; description: string | null };
975 healthScore: number;
976 healthGrade: string;
977 }>;
978 username: string;
979}) => {
980 const picks = pickRepoCoachPicks(repoData, 3);
981 if (picks.length === 0) {
982 return (
983 <div
984 class="card"
dc26881CC LABS App985 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude986 >
987 <h3 style="margin: 0 0 4px; font-size: 15px">
988 {"✨"} AI Health Coach
989 </h3>
990 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
991 All your repos are healthy (score &ge; 90). Nothing to triage.
992 </p>
993 </div>
994 );
995 }
996 return (
997 <div
998 class="card"
999 style="margin-bottom: 32px; padding: 0; overflow: hidden"
1000 >
1001 <div
dc26881CC LABS App1002 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude1003 >
1004 <div>
1005 <h3 style="margin: 0; font-size: 15px">
1006 {"✨"} AI Health Coach
1007 </h3>
1008 <p
dc26881CC LABS App1009 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude1010 >
1011 Top {picks.length} repos that would benefit from attention
1012 this week.
1013 </p>
1014 </div>
1015 </div>
1016 <ul style="list-style: none; margin: 0; padding: 0">
1017 {picks.map((p) => (
1018 <li
dc26881CC LABS App1019 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1020 >
1021 <div
1022 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1023 >
1024 {p.healthGrade}
1025 </div>
1026 <div style="flex: 1; min-width: 0">
1027 <a
1028 href={`/${username}/${p.repo.name}`}
1029 style="font-weight: 500"
1030 >
1031 {p.repo.name}
1032 </a>
1033 <div
1034 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1035 >
1036 Health score {p.healthScore}/100 — open the repo to see
1037 breakdown + AI suggestions.
1038 </div>
1039 </div>
1040 <a
f077ea5Claude1041 href={`/${username}/${p.repo.name}/health`}
7a14837Claude1042 class="btn"
1043 style="font-size: 12px; padding: 4px 10px"
f077ea5Claude1044 title="Open health score with AI suggestions"
7a14837Claude1045 >
1046 Coach me
1047 </a>
1048 </li>
1049 ))}
1050 </ul>
1051 </div>
1052 );
1053};
1054
f1ab587Claude1055const StatBox = ({
1056 label,
1057 value,
1058 color,
1059}: {
1060 label: string;
1061 value: string;
1062 color: string;
1063}) => (
1064 <div
dc26881CC LABS App1065 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1066 >
1067 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1068 {value}
1069 </div>
1070 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1071 {label}
1072 </div>
1073 </div>
1074);
1075
1076const ToggleSetting = ({
1077 name,
1078 label,
1079 description,
1080 defaultChecked,
1081}: {
1082 name: string;
1083 label: string;
1084 description: string;
1085 defaultChecked: boolean;
1086}) => (
1087 <div
dc26881CC LABS App1088 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1089 >
1090 <div style="flex: 1">
1091 <div style="font-size: 15px; font-weight: 600">{label}</div>
1092 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1093 {description}
1094 </div>
1095 </div>
1096 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1097 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1098 <span class="toggle-slider" />
1099 </label>
1100 </div>
1101);
1102
1103const ActivityIcon = ({ action }: { action: string }) => {
1104 const icons: Record<string, string> = {
1105 push: "→",
1106 issue_open: "\u25CB",
1107 issue_close: "\u2713",
1108 pr_open: "\u25CB",
1109 pr_merge: "\u2B8C",
1110 star: "\u2605",
1111 fork: "\u2442",
1112 comment: "\u{1F4AC}",
1113 };
1114 return (
1115 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1116 {icons[action] || "•"}
1117 </span>
1118 );
1119};
1120
1121function formatAction(action: string): string {
1122 const labels: Record<string, string> = {
1123 push: "Pushed code",
1124 issue_open: "Opened issue",
1125 issue_close: "Closed issue",
1126 pr_open: "Opened pull request",
1127 pr_merge: "Merged pull request",
1128 star: "Starred",
1129 fork: "Forked",
1130 comment: "Commented",
1131 };
1132 return labels[action] || action;
1133}
1134
1135function formatRelative(date: Date | string): string {
1136 const d = typeof date === "string" ? new Date(date) : date;
1137 const now = new Date();
1138 const diffMs = now.getTime() - d.getTime();
1139 const diffMins = Math.floor(diffMs / 60000);
1140 if (diffMins < 1) return "just now";
1141 if (diffMins < 60) return `${diffMins}m ago`;
1142 const diffHours = Math.floor(diffMins / 60);
1143 if (diffHours < 24) return `${diffHours}h ago`;
1144 const diffDays = Math.floor(diffHours / 24);
1145 if (diffDays < 30) return `${diffDays}d ago`;
1146 return d.toLocaleDateString("en-US", {
1147 month: "short",
1148 day: "numeric",
1149 });
1150}
1151
f1dc7c7Claude1152// ─── Loading skeleton (flag-gated; renders when ?skeleton=1) ──────────
1153const DashboardSkeleton = () => (
1154 <>
1155 <style
1156 dangerouslySetInnerHTML={{
1157 __html: `
1158 .dash-skel { background: linear-gradient(90deg, var(--bg-secondary) 0%, var(--bg-elevated) 50%, var(--bg-secondary) 100%); background-size: 200% 100%; animation: dashSkelShimmer 1.4s infinite; border-radius: 6px; display: block; }
1159 @keyframes dashSkelShimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
1160 @media (prefers-reduced-motion: reduce) { .dash-skel { animation: none; } }
1161 .dash-skel-hero { height: 168px; border-radius: 16px; margin-bottom: var(--space-6); }
1162 .dash-skel-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8); }
1163 .dash-skel-stat { height: 86px; border-radius: var(--radius); }
1164 .dash-skel-h { height: 18px; width: 180px; margin: 0 0 16px; border-radius: 5px; }
1165 .dash-skel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8); }
1166 .dash-skel-card { height: 168px; border-radius: var(--radius); }
1167 .dash-skel-feed { display: flex; flex-direction: column; gap: 8px; }
1168 .dash-skel-feed-row { height: 52px; border-radius: var(--radius); }
1169 `,
1170 }}
1171 />
1172 <div class="dash-skel dash-skel-hero" aria-hidden="true" />
1173 <div class="dash-skel-stats" aria-hidden="true">
1174 <div class="dash-skel dash-skel-stat" />
1175 <div class="dash-skel dash-skel-stat" />
1176 <div class="dash-skel dash-skel-stat" />
1177 <div class="dash-skel dash-skel-stat" />
1178 </div>
1179 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1180 <div class="dash-skel-grid" aria-hidden="true">
1181 <div class="dash-skel dash-skel-card" />
1182 <div class="dash-skel dash-skel-card" />
1183 <div class="dash-skel dash-skel-card" />
1184 <div class="dash-skel dash-skel-card" />
1185 </div>
1186 <div class="dash-skel dash-skel-h" aria-hidden="true" />
1187 <div class="dash-skel-feed" aria-hidden="true">
1188 <div class="dash-skel dash-skel-feed-row" />
1189 <div class="dash-skel dash-skel-feed-row" />
1190 <div class="dash-skel dash-skel-feed-row" />
1191 <div class="dash-skel dash-skel-feed-row" />
1192 <div class="dash-skel dash-skel-feed-row" />
1193 </div>
1194 <span style="position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0" role="status" aria-live="polite">
1195 Loading your command center…
1196 </span>
1197 </>
1198);
1199
f1ab587Claude1200export default dashboard;