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

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.tsxBlame1135 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
f1ab587Claude69 // Get all user's repos
70 const repos = await db
71 .select()
72 .from(repositories)
73 .where(eq(repositories.ownerId, user.id))
74 .orderBy(desc(repositories.updatedAt));
75
76 // Compute health scores for all repos (in parallel)
77 const repoData = await Promise.all(
78 repos.map(async (repo) => {
79 let healthScore = 0;
80 let healthGrade = "?" as string;
81 let recentCommits = 0;
82 let branchCount = 0;
83 let ciConfig = null;
84
85 try {
86 if (await repoExists(user.username, repo.name)) {
87 const ref =
88 (await getDefaultBranch(user.username, repo.name)) || "main";
89 const [health, commits, branches, ci] = await Promise.all([
90 computeHealthScore(user.username, repo.name).catch(() => null),
91 listCommits(user.username, repo.name, ref, 5).catch(() => []),
92 listBranches(user.username, repo.name).catch(() => []),
93 detectCIConfig(user.username, repo.name, ref).catch(() => null),
94 ]);
95 if (health) {
96 healthScore = health.score;
97 healthGrade = health.grade;
98 }
99 recentCommits = commits.length;
100 branchCount = branches.length;
101 ciConfig = ci;
102 }
103 } catch {
104 // best effort
105 }
106
107 return {
108 repo,
109 healthScore,
110 healthGrade,
111 recentCommits,
112 branchCount,
113 ciConfig,
114 };
115 })
116 );
117
46d6165Claude118 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
119 // parallel; both helpers swallow DB errors so the dashboard always renders.
120 const [savingsWeek, savingsLifetime] = await Promise.all([
121 computeAiSavingsForUser(user.id, { windowHours: 168 }),
122 computeLifetimeAiSavingsForUser(user.id),
123 ]);
124
f1ab587Claude125 // Get recent activity
126 let recentActivity: Array<{
127 action: string;
128 repoName: string;
129 metadata: string | null;
130 createdAt: Date;
131 }> = [];
132
133 try {
134 const repoIds = repos.map((r) => r.id);
135 if (repoIds.length > 0) {
136 const activity = await db
137 .select({
138 action: activityFeed.action,
139 metadata: activityFeed.metadata,
140 createdAt: activityFeed.createdAt,
141 repoId: activityFeed.repositoryId,
142 })
143 .from(activityFeed)
144 .where(eq(activityFeed.userId, user.id))
145 .orderBy(desc(activityFeed.createdAt))
146 .limit(20);
147
148 recentActivity = activity.map((a) => ({
149 action: a.action,
150 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
151 metadata: a.metadata,
152 createdAt: a.createdAt,
153 }));
154 }
155 } catch {
156 // DB not required for dashboard
157 }
158
159 const gradeColor = (grade: string) =>
160 grade === "A+" || grade === "A"
161 ? "var(--green)"
162 : grade === "B"
163 ? "#58a6ff"
164 : grade === "C"
165 ? "var(--yellow)"
166 : grade === "?"
167 ? "var(--text-muted)"
168 : "var(--red)";
169
c63b860Claude170 // Block P2 — email verification banner. Shows when the user hasn't
171 // verified yet AND they haven't dismissed it this session. Also surfaces
172 // transient resend feedback (`?verify=sent` / `?verify=rate_limited`)
173 // and the post-register hint (`?welcome=1`).
174 const verifyDismissed = getCookie(c, "p2_verify_dismissed") === "1";
175 const showVerifyBanner =
176 !(user as any).emailVerifiedAt && !verifyDismissed;
177 const verifyQuery = c.req.query("verify");
178 const welcomeQuery = c.req.query("welcome");
179
f1ab587Claude180 return c.html(
181 <Layout title="Command Center" user={user}>
c63b860Claude182 {showVerifyBanner && (
183 <div
dc26881CC LABS App184 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"
c63b860Claude185 data-p2-verify-banner=""
186 >
187 <div style="flex: 1 1 auto; min-width: 0">
188 {welcomeQuery === "1" ? (
189 <span>
190 Welcome to Gluecron! Check your inbox to verify your email.
191 </span>
192 ) : verifyQuery === "sent" ? (
193 <span>
194 Verification link sent. It may take a minute to arrive.
195 </span>
196 ) : verifyQuery === "rate_limited" ? (
197 <span>
198 You've requested too many verification emails. Try again later.
199 </span>
826eccfTest User200 ) : verifyQuery === "not_configured" ? (
201 <span>
202 Email delivery isn't configured on this instance yet — your
203 site admin needs to set <code>EMAIL_PROVIDER=resend</code> and{" "}
204 <code>RESEND_API_KEY</code>. Until then the verification link
205 is written to the server log.
206 </span>
c63b860Claude207 ) : (
208 <span>Verify your email to keep using Gluecron.</span>
209 )}
210 </div>
211 <form
212 method="post"
213 action="/verify-email/resend"
dc26881CC LABS App214 style="display: inline-flex; gap: var(--space-2); align-items: center; margin: 0"
c63b860Claude215 >
216 <input
217 type="hidden"
218 name="_csrf"
219 value={(c.get("csrfToken") as string | undefined) || ""}
220 />
221 <button
222 type="submit"
223 class="btn"
224 style="padding: 4px 10px; font-size: 12px"
225 >
226 Resend verification link
227 </button>
228 <a
229 href="/dashboard?p2_dismiss=1"
230 class="btn"
231 style="padding: 4px 10px; font-size: 12px"
232 aria-label="Dismiss verification banner"
233 >
234 Dismiss
235 </a>
236 </form>
237 </div>
238 )}
a004c46Claude239 <div class="dash-hero">
240 <div class="dash-hero-bg" aria-hidden="true">
241 <div class="dash-hero-orb" />
f1ab587Claude242 </div>
a004c46Claude243 <div class="dash-hero-inner">
244 <div class="dash-hero-text">
245 <div class="dash-hero-eyebrow">
246 {(() => {
247 const hour = new Date().getHours();
248 if (hour < 5) return "Late night,";
249 if (hour < 12) return "Good morning,";
250 if (hour < 17) return "Good afternoon,";
251 if (hour < 21) return "Good evening,";
252 return "Late night,";
253 })()}{" "}
254 <span class="dash-hero-username">{user.username}</span>
255 </div>
256 <h1 class="dash-hero-title">
257 Your{" "}
258 <span class="gradient-text">command center</span>.
259 </h1>
260 <p class="dash-hero-sub">
261 {repos.length === 0
262 ? "Create your first repository to start shipping with AI."
263 : `${repos.length} repo${repos.length === 1 ? "" : "s"} · real-time health, AI activity, and gate status across everything you own.`}
264 </p>
265 </div>
266 <div class="dash-hero-actions">
267 <a href="/new" class="btn btn-primary">+ New repo</a>
268 <a href="/import" class="btn">Import from GitHub</a>
269 <a href="/settings" class="btn">Settings</a>
270 </div>
f1ab587Claude271 </div>
272 </div>
a004c46Claude273 <style
274 dangerouslySetInnerHTML={{
275 __html: `
276 .dash-hero {
277 position: relative;
278 margin-bottom: var(--space-6);
279 padding: var(--space-5) var(--space-6) var(--space-5);
280 background: var(--bg-elevated);
281 border: 1px solid var(--border);
282 border-radius: 16px;
283 overflow: hidden;
284 }
285 .dash-hero::before {
286 content: '';
287 position: absolute;
288 top: 0; left: 0; right: 0;
289 height: 2px;
290 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
291 opacity: 0.7;
292 pointer-events: none;
293 }
294 .dash-hero-bg {
295 position: absolute;
296 inset: -20% -10% auto auto;
297 width: 380px;
298 height: 380px;
299 pointer-events: none;
300 z-index: 0;
301 }
302 .dash-hero-orb {
303 position: absolute;
304 inset: 0;
305 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
306 filter: blur(80px);
307 opacity: 0.7;
308 animation: dashHeroOrb 14s ease-in-out infinite;
309 }
310 @keyframes dashHeroOrb {
311 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
312 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.8; }
313 }
314 @media (prefers-reduced-motion: reduce) {
315 .dash-hero-orb { animation: none; }
316 }
317 .dash-hero-inner {
318 position: relative;
319 z-index: 1;
320 display: flex;
321 justify-content: space-between;
322 align-items: flex-end;
323 gap: var(--space-4);
324 flex-wrap: wrap;
325 }
326 .dash-hero-text { flex: 1; min-width: 280px; }
327 .dash-hero-eyebrow {
328 font-size: 13px;
329 color: var(--text-muted);
330 margin-bottom: var(--space-2);
331 letter-spacing: -0.005em;
332 }
333 .dash-hero-username {
334 color: var(--accent);
335 font-weight: 600;
336 }
337 .dash-hero-title {
338 font-size: clamp(28px, 4vw, 40px);
339 font-family: var(--font-display);
340 font-weight: 800;
341 letter-spacing: -0.028em;
342 line-height: 1.05;
343 margin: 0 0 var(--space-2);
344 color: var(--text-strong);
345 }
346 .dash-hero-title .gradient-text {
347 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
348 -webkit-background-clip: text;
349 background-clip: text;
350 -webkit-text-fill-color: transparent;
351 color: transparent;
352 }
353 .dash-hero-sub {
354 font-size: 15px;
355 color: var(--text-muted);
356 margin: 0;
357 line-height: 1.5;
358 max-width: 580px;
359 }
360 .dash-hero-actions {
361 display: flex;
362 gap: var(--space-2);
363 flex-wrap: wrap;
364 }
365 @media (max-width: 720px) {
366 .dash-hero-inner { flex-direction: column; align-items: flex-start; }
367 .dash-hero-actions { width: 100%; }
368 .dash-hero-actions .btn { flex: 1; min-width: 0; }
369 }
370 `,
371 }}
372 />
f1ab587Claude373
46d6165Claude374 {/* ─── L9: AI hours-saved hero widget ─── */}
375 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
376
f1ab587Claude377 {/* ─── Stats Bar ─── */}
dc26881CC LABS App378 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--space-3); margin-bottom: var(--space-8)">
f1ab587Claude379 <StatBox
380 label="Repositories"
381 value={String(repos.length)}
382 color="var(--text-link)"
383 />
384 <StatBox
385 label="Avg Health"
386 value={
387 repos.length > 0
388 ? String(
389 Math.round(
390 repoData.reduce((s, r) => s + r.healthScore, 0) /
391 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
392 )
393 )
394 : "—"
395 }
396 color="var(--green)"
397 />
398 <StatBox
399 label="Total Stars"
400 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
401 color="var(--yellow)"
402 />
403 <StatBox
404 label="Open Issues"
405 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
406 color="var(--red)"
407 />
408 </div>
409
410 {/* ─── Repo Grid ─── */}
411 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
412 {repos.length === 0 ? (
dc26881CC LABS App413 <div class="empty-state" style="text-align:left;padding:var(--space-6)">
80bed05Claude414 <div style="text-align:center;margin-bottom:20px">
415 <h2 style="margin-bottom:6px">Get started</h2>
416 <p style="color:var(--text-muted);font-size:14px;margin:0">
417 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
418 </p>
419 </div>
420 <div class="panel" style="margin-bottom:20px;text-align:left">
dc26881CC LABS App421 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude422 <div style="flex:1">
423 <div style="font-size:15px;font-weight:600">Create a new repository</div>
424 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
425 Start from scratch with green-ecosystem defaults.
426 </div>
427 </div>
428 <a href="/new" class="btn btn-primary">Create repo</a>
429 </div>
dc26881CC LABS App430 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude431 <div style="flex:1">
432 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
433 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
434 Mirror an existing repo — history, branches, tags.
435 </div>
436 </div>
437 <a href="/import" class="btn">Import repo</a>
438 </div>
dc26881CC LABS App439 <div class="panel-item" style="justify-content:space-between;padding:var(--space-4);gap:var(--space-3)">
80bed05Claude440 <div style="flex:1">
441 <div style="font-size:15px;font-weight:600">Browse public repos</div>
442 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
443 See what others are building, fork what you like.
444 </div>
445 </div>
446 <a href="/explore" class="btn">Browse</a>
447 </div>
448 </div>
dc26881CC LABS App449 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)">
80bed05Claude450 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
451 Push an existing project (preview)
452 </div>
453 <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.
454git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
455git push -u gluecron main</code></pre>
456 </div>
f1ab587Claude457 </div>
458 ) : (
dc26881CC LABS App459 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: var(--space-4); margin-bottom: var(--space-8)">
f1ab587Claude460 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
461 <div class="card" style="padding: 0; overflow: hidden">
462 {/* Health bar at top */}
463 <div
464 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
465 />
dc26881CC LABS App466 <div style="padding: var(--space-4)">
f1ab587Claude467 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
468 <div>
469 <h3 style="font-size: 16px; margin-bottom: 2px">
470 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
471 </h3>
472 {repo.description && (
473 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
474 {repo.description}
475 </p>
476 )}
477 </div>
478 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
479 <div
480 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
481 >
482 {healthGrade}
483 </div>
484 <div style="font-size: 10px; color: var(--text-muted)">
485 {healthScore}/100
486 </div>
487 </div>
488 </div>
489
dc26881CC LABS App490 <div style="display: flex; gap: var(--space-4); font-size: 12px; color: var(--text-muted); margin-top: var(--space-2)">
f1ab587Claude491 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
492 <span>{"\u2606"} {repo.starCount}</span>
493 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
494 </div>
495
496 {ciConfig && ciConfig.commands.length > 0 && (
dc26881CC LABS App497 <div style="margin-top: var(--space-2); display: flex; gap: 6px; flex-wrap: wrap">
f1ab587Claude498 {ciConfig.detected.slice(0, 3).map((d) => (
499 <span
500 class="badge"
501 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
502 >
503 {d}
504 </span>
505 ))}
506 </div>
507 )}
508
dc26881CC LABS App509 <div style="display: flex; gap: 6px; margin-top: var(--space-3)">
f1ab587Claude510 <a
511 href={`/${user.username}/${repo.name}/health`}
512 class="btn btn-sm"
513 style="font-size: 11px; padding: 2px 8px"
514 >
515 Health
516 </a>
517 <a
518 href={`/${user.username}/${repo.name}/dependencies`}
519 class="btn btn-sm"
520 style="font-size: 11px; padding: 2px 8px"
521 >
522 Deps
523 </a>
524 <a
525 href={`/${user.username}/${repo.name}/coupling`}
526 class="btn btn-sm"
527 style="font-size: 11px; padding: 2px 8px"
528 >
529 Insights
530 </a>
531 <a
532 href={`/${user.username}/${repo.name}/settings`}
533 class="btn btn-sm"
534 style="font-size: 11px; padding: 2px 8px"
535 >
536 Settings
537 </a>
538 </div>
539 </div>
540 </div>
541 ))}
542 </div>
543 )}
544
545 {/* ─── Activity Feed ─── */}
546 {recentActivity.length > 0 && (
547 <>
548 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
549 <div class="issue-list">
550 {recentActivity.map((a) => (
551 <div class="issue-item">
dc26881CC LABS App552 <div style="display: flex; gap: var(--space-2); align-items: center">
f1ab587Claude553 <ActivityIcon action={a.action} />
554 <div>
555 <span style="font-size: 14px">
556 {formatAction(a.action)} in{" "}
557 <a href={`/${user.username}/${a.repoName}`}>
558 {a.repoName}
559 </a>
560 </span>
561 <div style="font-size: 12px; color: var(--text-muted)">
562 {formatRelative(a.createdAt)}
563 </div>
564 </div>
565 </div>
566 </div>
567 ))}
568 </div>
569 </>
570 )}
571
7a14837Claude572 {/* ─── AI Health Coach (move #10 from STRATEGY) ─── */}
573 <HealthCoach repoData={repoData} username={user.username} />
574
febd4f0Claude575 {/* ─── Live Activity (SSE) ─── */}
576 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
577
f1ab587Claude578 {/* ─── Quick Links ─── */}
dc26881CC LABS App579 <div style="margin-top: var(--space-8); display: flex; gap: var(--space-4); flex-wrap: wrap">
f1ab587Claude580 <a href="/explore" class="btn">Browse public repos</a>
581 <a href="/settings/tokens" class="btn">API tokens</a>
582 <a href="/settings/keys" class="btn">SSH keys</a>
583 </div>
584 </Layout>
585 );
586});
587
588// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
589
590dashboard.get(
591 "/:owner/:repo/settings/intelligence",
592 requireAuth,
593 async (c) => {
594 const { owner: ownerName, repo: repoName } = c.req.param();
595 const user = c.get("user")!;
596 const success = c.req.query("success");
597
598 return c.html(
599 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
600 <div style="max-width: 600px">
601 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
602 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
603 Control what gluecron does automatically when code is pushed to{" "}
604 <strong>{ownerName}/{repoName}</strong>.
605 </p>
606 {success && (
607 <div class="auth-success">{decodeURIComponent(success)}</div>
608 )}
609 <form
0316dbbClaude610 method="post"
f1ab587Claude611 action={`/${ownerName}/${repoName}/settings/intelligence`}
612 >
613 <ToggleSetting
614 name="auto_repair"
615 label="Auto-Repair"
616 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
617 defaultChecked={true}
618 />
619 <ToggleSetting
620 name="security_scan"
621 label="Security Scanning"
622 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
623 defaultChecked={true}
624 />
625 <ToggleSetting
626 name="health_score"
627 label="Health Score"
628 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
629 defaultChecked={true}
630 />
631 <ToggleSetting
632 name="push_analysis"
633 label="Push Risk Analysis"
634 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
635 defaultChecked={true}
636 />
637 <ToggleSetting
638 name="dep_analysis"
639 label="Dependency Analysis"
640 description="Build import graph, detect unused deps, find circular dependencies"
641 defaultChecked={true}
642 />
643 <ToggleSetting
644 name="gatetest"
645 label="GateTest Integration"
646 description="Send push events to GateTest for external security scanning"
647 defaultChecked={true}
648 />
649 <ToggleSetting
90fa787Claude650 name="deploy_webhook"
651 label="Auto-Deploy Webhook"
652 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude653 defaultChecked={true}
654 />
655
656 <button
657 type="submit"
658 class="btn btn-primary"
659 style="margin-top: 12px"
660 >
661 Save settings
662 </button>
663 </form>
664 </div>
665 </Layout>
666 );
667 }
668);
669
670dashboard.post(
671 "/:owner/:repo/settings/intelligence",
672 requireAuth,
673 async (c) => {
674 const { owner: ownerName, repo: repoName } = c.req.param();
675 // In production, these would be saved to DB per-repo
676 // For now, acknowledge the settings
677 return c.redirect(
678 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
679 );
680 }
681);
682
683// ─── PUSH LOG ────────────────────────────────────────────────
684
685dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
686 const { owner, repo } = c.req.param();
687 const user = c.get("user");
688
689 if (!(await repoExists(owner, repo))) return c.notFound();
690 const ref = (await getDefaultBranch(owner, repo)) || "main";
691 const commits = await listCommits(owner, repo, ref, 30);
692
693 return c.html(
694 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
695 <div style="max-width: 900px">
696 <h2 style="margin-bottom: 4px">Push Log</h2>
697 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
698 Every push analyzed in real-time — risk scores, repairs, security alerts
699 </p>
700 <div class="issue-list">
701 {commits.map((commit) => {
702 // Determine if this was an auto-repair commit
703 const isRepair =
704 commit.author === "gluecron[bot]" ||
705 commit.message.includes("auto-repair");
706 const isRollback = commit.message.startsWith("revert: rollback");
707
708 return (
709 <div class="issue-item" style="flex-direction: column; align-items: stretch">
710 <div style="display: flex; justify-content: space-between; align-items: start">
dc26881CC LABS App711 <div style="display: flex; gap: var(--space-2); align-items: start">
f1ab587Claude712 {isRepair ? (
713 <span
714 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
715 title="Auto-repair"
716 >
717 {"⚡"}
718 </span>
719 ) : isRollback ? (
720 <span
721 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
722 title="Rollback"
723 >
724 {"↩"}
725 </span>
726 ) : (
727 <span
728 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
729 >
730 {"→"}
731 </span>
732 )}
733 <div>
734 <a
735 href={`/${owner}/${repo}/commit/${commit.sha}`}
736 style="font-weight: 600; font-size: 14px"
737 >
738 {commit.message}
739 </a>
740 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
741 {commit.author} —{" "}
742 {new Date(commit.date).toLocaleString("en-US", {
743 month: "short",
744 day: "numeric",
745 hour: "2-digit",
746 minute: "2-digit",
747 })}
748 </div>
749 </div>
750 </div>
751 <a
752 href={`/${owner}/${repo}/commit/${commit.sha}`}
753 class="commit-sha"
754 >
755 {commit.sha.slice(0, 7)}
756 </a>
757 </div>
758 {isRepair && (
759 <div
dc26881CC LABS App760 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)"
f1ab587Claude761 >
762 Automatically repaired by gluecron
763 </div>
764 )}
765 </div>
766 );
767 })}
768 </div>
769 </div>
770 </Layout>
771 );
772});
773
774// ─── COMPONENTS ──────────────────────────────────────────────
775
46d6165Claude776/**
777 * Block L9 — pure formatter used by the dashboard widget AND tests.
778 * Turns the breakdown into the small stat-pill array shown under the
779 * big number. Exported so the markup contract is testable without
780 * importing JSX.
781 */
782export function formatSavingsPills(b: {
783 prsAutoMerged: number;
784 issuesBuiltByAi: number;
785 aiReviewsPosted: number;
786 aiTriagesPosted: number;
787 aiCommitMsgs: number;
788 secretsAutoRepaired: number;
789 gateAutoRepairs: number;
790}): string[] {
791 const pills: string[] = [];
792 if (b.prsAutoMerged) pills.push(`${b.prsAutoMerged} PR${b.prsAutoMerged === 1 ? "" : "s"} auto-merged`);
793 if (b.issuesBuiltByAi) pills.push(`${b.issuesBuiltByAi} issue${b.issuesBuiltByAi === 1 ? "" : "s"} built`);
794 if (b.aiReviewsPosted) pills.push(`${b.aiReviewsPosted} AI review${b.aiReviewsPosted === 1 ? "" : "s"}`);
795 if (b.aiTriagesPosted) pills.push(`${b.aiTriagesPosted} triage${b.aiTriagesPosted === 1 ? "" : "s"}`);
796 const fixes = b.secretsAutoRepaired + b.gateAutoRepairs;
797 if (fixes) pills.push(`${fixes} auto-fix${fixes === 1 ? "" : "es"}`);
798 if (b.aiCommitMsgs) pills.push(`${b.aiCommitMsgs} commit msg${b.aiCommitMsgs === 1 ? "" : "s"}`);
799 return pills;
800}
801
802const AiHoursSavedWidget = ({
803 week,
804 lifetime,
805}: {
806 week: AiSavingsReport;
807 lifetime: AiSavingsLifetimeReport;
808}) => {
809 const weekPills = formatSavingsPills(week.breakdown);
810 const lifetimePills = formatSavingsPills(lifetime.breakdown);
811 const hasAnyWeek = week.hoursSaved > 0 || weekPills.length > 0;
812 return (
813 <div
814 class="card ai-hours-saved-widget"
815 style="margin-bottom: 24px; padding: 0; overflow: hidden; position: relative; background: var(--accent-gradient-faint, var(--bg-secondary)); border-color: var(--accent)"
816 >
dc26881CC LABS App817 <div style="padding: var(--space-6) var(--space-6) var(--space-5) var(--space-6)">
818 <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-4);flex-wrap:wrap">
46d6165Claude819 <div style="flex:1;min-width:240px">
820 <div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 4px">
821 AI working for you
822 </div>
823 <div
824 data-testid="ai-hours-saved-this-week"
825 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"
826 >
827 {week.hoursSaved.toFixed(1)}h
828 </div>
829 <div style="margin-top: 6px; font-size: 14px; color: var(--text-muted)">
830 Claude saved you{" "}
831 <strong style="color: var(--text)">
832 {week.hoursSaved.toFixed(1)} hours
833 </strong>{" "}
834 this week.
835 {lifetime.hoursSaved > week.hoursSaved && (
836 <span>
837 {" — "}
838 <strong style="color: var(--text)">
839 {lifetime.hoursSaved.toFixed(1)}h
840 </strong>{" "}
841 all-time.
842 </span>
843 )}
844 </div>
845 </div>
846 <div
847 class="ai-hours-saved-tabs"
848 style="display:flex;gap:4px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;padding:2px"
849 >
850 <span
851 data-tab="this-week"
852 style="padding:4px 10px;font-size:12px;font-weight:600;border-radius:4px;background:var(--bg);color:var(--text)"
853 >
854 This week
855 </span>
856 <span
857 data-tab="all-time"
858 style="padding:4px 10px;font-size:12px;color:var(--text-muted)"
859 >
860 All-time
861 </span>
862 </div>
863 </div>
864
865 {hasAnyWeek ? (
dc26881CC LABS App866 <div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-4)">
46d6165Claude867 {weekPills.map((p) => (
868 <span
869 class="badge"
870 style="font-size:12px;padding:4px 10px;background:rgba(140,109,255,0.10);border-color:var(--accent);color:var(--text)"
871 >
872 {p}
873 </span>
874 ))}
875 </div>
876 ) : (
877 <div style="margin-top:16px;font-size:13px;color:var(--text-muted)">
878 No AI activity this week yet — open a PR, label an issue{" "}
879 <code>ai:build</code>, or let auto-merge sweep your branches.
880 The counter will start climbing.
881 </div>
882 )}
883
884 <details style="margin-top:16px">
885 <summary
886 data-testid="ai-hours-saved-formula-toggle"
887 style="cursor:pointer;font-size:12px;color:var(--text-muted);user-select:none"
888 >
889 How is this calculated?
890 </summary>
891 <div
892 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"
893 >
894 <div>hoursSaved =</div>
895 <div>&nbsp;&nbsp;{week.breakdown.prsAutoMerged} PRs auto-merged × 0.30</div>
896 <div>+ {week.breakdown.issuesBuiltByAi} issues built by AI × 1.50</div>
897 <div>+ {week.breakdown.aiReviewsPosted} AI reviews × 0.25</div>
898 <div>+ {week.breakdown.aiTriagesPosted} AI triages × 0.10</div>
899 <div>+ {week.breakdown.aiCommitMsgs} AI commit msgs × 0.05</div>
900 <div>+ {week.breakdown.secretsAutoRepaired} secrets repaired × 0.50</div>
901 <div>+ {week.breakdown.gateAutoRepairs} gates repaired × 0.40</div>
902 <div style="margin-top:6px;color:var(--text)">
903 = {week.hoursSaved.toFixed(1)}h (this week,{" "}
904 {week.windowHours}h window)
905 </div>
906 <div style="margin-top:8px;font-size:11px">
907 Lifetime: {lifetime.hoursSaved.toFixed(1)}h since{" "}
908 {lifetime.sinceCreatedAt.toISOString().slice(0, 10)}.
909 Constants are conservative on purpose — audit-friendly is
910 the brand.
911 </div>
912 {lifetimePills.length > 0 && (
913 <div style="margin-top:8px;font-size:11px">
914 All-time breakdown: {lifetimePills.join(" · ")}
915 </div>
916 )}
917 </div>
918 </details>
919 </div>
920 </div>
921 );
922};
923
7a14837Claude924/**
925 * Pure helper: pick the bottom-N repos by health score and return a
926 * prioritized "fix this next" plan. Health=0 repos (couldn't be
927 * computed) are excluded so the coach doesn't recommend ghost repos.
928 *
929 * Exported under __test for unit testing without touching the DB.
930 */
931export function pickRepoCoachPicks<T extends { healthScore: number; repo: { name: string; description?: string | null }; healthGrade: string }>(
932 repoData: T[],
933 topN = 3
934): T[] {
935 return repoData
936 .filter((r) => r.healthScore > 0 && r.healthScore < 90)
937 .sort((a, b) => a.healthScore - b.healthScore)
938 .slice(0, topN);
939}
940
941/** Module-scoped color picker for grade chips. Mirrors the inner
942 * `gradeColor` defined in the request handler scope, exposed at module
943 * level so HealthCoach (also module-scope) can reach it. */
944function moduleGradeColor(grade: string): string {
945 if (grade === "A+" || grade === "A") return "var(--green)";
946 if (grade === "B") return "#58a6ff";
947 if (grade === "C") return "var(--yellow)";
948 if (grade === "?") return "var(--text-muted)";
949 return "var(--red)";
950}
951
952const HealthCoach = ({
953 repoData,
954 username,
955}: {
956 repoData: Array<{
957 repo: { name: string; description: string | null };
958 healthScore: number;
959 healthGrade: string;
960 }>;
961 username: string;
962}) => {
963 const picks = pickRepoCoachPicks(repoData, 3);
964 if (picks.length === 0) {
965 return (
966 <div
967 class="card"
dc26881CC LABS App968 style="margin-bottom: var(--space-8); padding: var(--space-4); background: rgba(63,185,80,0.08); border-color: var(--green)"
7a14837Claude969 >
970 <h3 style="margin: 0 0 4px; font-size: 15px">
971 {"✨"} AI Health Coach
972 </h3>
973 <p style="margin: 0; color: var(--text-muted); font-size: 13px">
974 All your repos are healthy (score &ge; 90). Nothing to triage.
975 </p>
976 </div>
977 );
978 }
979 return (
980 <div
981 class="card"
982 style="margin-bottom: 32px; padding: 0; overflow: hidden"
983 >
984 <div
dc26881CC LABS App985 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between"
7a14837Claude986 >
987 <div>
988 <h3 style="margin: 0; font-size: 15px">
989 {"✨"} AI Health Coach
990 </h3>
991 <p
dc26881CC LABS App992 style="margin: var(--space-1) 0 0; color: var(--text-muted); font-size: 12px"
7a14837Claude993 >
994 Top {picks.length} repos that would benefit from attention
995 this week.
996 </p>
997 </div>
998 </div>
999 <ul style="list-style: none; margin: 0; padding: 0">
1000 {picks.map((p) => (
1001 <li
dc26881CC LABS App1002 style="padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: var(--space-3)"
7a14837Claude1003 >
1004 <div
1005 style={`min-width: 40px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: 600; color: var(--bg); background: ${moduleGradeColor(p.healthGrade)}`}
1006 >
1007 {p.healthGrade}
1008 </div>
1009 <div style="flex: 1; min-width: 0">
1010 <a
1011 href={`/${username}/${p.repo.name}`}
1012 style="font-weight: 500"
1013 >
1014 {p.repo.name}
1015 </a>
1016 <div
1017 style="font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
1018 >
1019 Health score {p.healthScore}/100 — open the repo to see
1020 breakdown + AI suggestions.
1021 </div>
1022 </div>
1023 <a
1024 href={`/${username}/${p.repo.name}/explain`}
1025 class="btn"
1026 style="font-size: 12px; padding: 4px 10px"
1027 title="Run AI explain on this repo"
1028 >
1029 Coach me
1030 </a>
1031 </li>
1032 ))}
1033 </ul>
1034 </div>
1035 );
1036};
1037
f1ab587Claude1038const StatBox = ({
1039 label,
1040 value,
1041 color,
1042}: {
1043 label: string;
1044 value: string;
1045 color: string;
1046}) => (
1047 <div
dc26881CC LABS App1048 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); text-align: center"
f1ab587Claude1049 >
1050 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
1051 {value}
1052 </div>
1053 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
1054 {label}
1055 </div>
1056 </div>
1057);
1058
1059const ToggleSetting = ({
1060 name,
1061 label,
1062 description,
1063 defaultChecked,
1064}: {
1065 name: string;
1066 label: string;
1067 description: string;
1068 defaultChecked: boolean;
1069}) => (
1070 <div
dc26881CC LABS App1071 style="display: flex; justify-content: space-between; align-items: start; padding: var(--space-4) 0; border-bottom: 1px solid var(--border)"
f1ab587Claude1072 >
1073 <div style="flex: 1">
1074 <div style="font-size: 15px; font-weight: 600">{label}</div>
1075 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
1076 {description}
1077 </div>
1078 </div>
1079 <label class="toggle-switch">
2228c49copilot-swe-agent[bot]1080 <input type="checkbox" name={name} value="on" checked={defaultChecked} aria-label={label} />
f1ab587Claude1081 <span class="toggle-slider" />
1082 </label>
1083 </div>
1084);
1085
1086const ActivityIcon = ({ action }: { action: string }) => {
1087 const icons: Record<string, string> = {
1088 push: "→",
1089 issue_open: "\u25CB",
1090 issue_close: "\u2713",
1091 pr_open: "\u25CB",
1092 pr_merge: "\u2B8C",
1093 star: "\u2605",
1094 fork: "\u2442",
1095 comment: "\u{1F4AC}",
1096 };
1097 return (
1098 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
1099 {icons[action] || "•"}
1100 </span>
1101 );
1102};
1103
1104function formatAction(action: string): string {
1105 const labels: Record<string, string> = {
1106 push: "Pushed code",
1107 issue_open: "Opened issue",
1108 issue_close: "Closed issue",
1109 pr_open: "Opened pull request",
1110 pr_merge: "Merged pull request",
1111 star: "Starred",
1112 fork: "Forked",
1113 comment: "Commented",
1114 };
1115 return labels[action] || action;
1116}
1117
1118function formatRelative(date: Date | string): string {
1119 const d = typeof date === "string" ? new Date(date) : date;
1120 const now = new Date();
1121 const diffMs = now.getTime() - d.getTime();
1122 const diffMins = Math.floor(diffMs / 60000);
1123 if (diffMins < 1) return "just now";
1124 if (diffMins < 60) return `${diffMins}m ago`;
1125 const diffHours = Math.floor(diffMins / 60);
1126 if (diffHours < 24) return `${diffHours}h ago`;
1127 const diffDays = Math.floor(diffHours / 24);
1128 if (diffDays < 30) return `${diffDays}d ago`;
1129 return d.toLocaleDateString("en-US", {
1130 month: "short",
1131 day: "numeric",
1132 });
1133}
1134
1135export default dashboard;