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.tsxBlame653 lines · 1 contributor
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";
19import { db } from "../db";
20import {
21 repositories,
22 users,
23 activityFeed,
24 issues,
25 pullRequests,
26} from "../db/schema";
27import { Layout } from "../views/layout";
febd4f0Claude28import { LiveFeed } from "../views/live-feed";
f1ab587Claude29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import {
32 computeHealthScore,
33 detectCIConfig,
34} from "../lib/intelligence";
35import {
36 repoExists,
37 getDefaultBranch,
38 listCommits,
39 listBranches,
40} from "../git/repository";
41
42const dashboard = new Hono<AuthEnv>();
43
44dashboard.use("*", softAuth);
45
46// ─── COMMAND CENTER ──────────────────────────────────────────
47
48dashboard.get("/dashboard", requireAuth, async (c) => {
49 const user = c.get("user")!;
50
51 // Get all user's repos
52 const repos = await db
53 .select()
54 .from(repositories)
55 .where(eq(repositories.ownerId, user.id))
56 .orderBy(desc(repositories.updatedAt));
57
58 // Compute health scores for all repos (in parallel)
59 const repoData = await Promise.all(
60 repos.map(async (repo) => {
61 let healthScore = 0;
62 let healthGrade = "?" as string;
63 let recentCommits = 0;
64 let branchCount = 0;
65 let ciConfig = null;
66
67 try {
68 if (await repoExists(user.username, repo.name)) {
69 const ref =
70 (await getDefaultBranch(user.username, repo.name)) || "main";
71 const [health, commits, branches, ci] = await Promise.all([
72 computeHealthScore(user.username, repo.name).catch(() => null),
73 listCommits(user.username, repo.name, ref, 5).catch(() => []),
74 listBranches(user.username, repo.name).catch(() => []),
75 detectCIConfig(user.username, repo.name, ref).catch(() => null),
76 ]);
77 if (health) {
78 healthScore = health.score;
79 healthGrade = health.grade;
80 }
81 recentCommits = commits.length;
82 branchCount = branches.length;
83 ciConfig = ci;
84 }
85 } catch {
86 // best effort
87 }
88
89 return {
90 repo,
91 healthScore,
92 healthGrade,
93 recentCommits,
94 branchCount,
95 ciConfig,
96 };
97 })
98 );
99
100 // Get recent activity
101 let recentActivity: Array<{
102 action: string;
103 repoName: string;
104 metadata: string | null;
105 createdAt: Date;
106 }> = [];
107
108 try {
109 const repoIds = repos.map((r) => r.id);
110 if (repoIds.length > 0) {
111 const activity = await db
112 .select({
113 action: activityFeed.action,
114 metadata: activityFeed.metadata,
115 createdAt: activityFeed.createdAt,
116 repoId: activityFeed.repositoryId,
117 })
118 .from(activityFeed)
119 .where(eq(activityFeed.userId, user.id))
120 .orderBy(desc(activityFeed.createdAt))
121 .limit(20);
122
123 recentActivity = activity.map((a) => ({
124 action: a.action,
125 repoName: repos.find((r) => r.id === a.repoId)?.name || "unknown",
126 metadata: a.metadata,
127 createdAt: a.createdAt,
128 }));
129 }
130 } catch {
131 // DB not required for dashboard
132 }
133
134 const gradeColor = (grade: string) =>
135 grade === "A+" || grade === "A"
136 ? "var(--green)"
137 : grade === "B"
138 ? "#58a6ff"
139 : grade === "C"
140 ? "var(--yellow)"
141 : grade === "?"
142 ? "var(--text-muted)"
143 : "var(--red)";
144
145 return c.html(
146 <Layout title="Command Center" user={user}>
147 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px">
148 <div>
149 <h1 style="font-size: 28px; margin-bottom: 4px">Command Center</h1>
150 <p style="color: var(--text-muted); font-size: 14px">
151 Real-time overview of all your repositories
152 </p>
153 </div>
154 <div style="display: flex; gap: 8px">
155 <a href="/new" class="btn btn-primary">+ New repo</a>
156 <a href="/settings" class="btn">Settings</a>
157 </div>
158 </div>
159
160 {/* ─── Stats Bar ─── */}
161 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px">
162 <StatBox
163 label="Repositories"
164 value={String(repos.length)}
165 color="var(--text-link)"
166 />
167 <StatBox
168 label="Avg Health"
169 value={
170 repos.length > 0
171 ? String(
172 Math.round(
173 repoData.reduce((s, r) => s + r.healthScore, 0) /
174 Math.max(repoData.filter((r) => r.healthScore > 0).length, 1)
175 )
176 )
177 : "—"
178 }
179 color="var(--green)"
180 />
181 <StatBox
182 label="Total Stars"
183 value={String(repos.reduce((s, r) => s + r.starCount, 0))}
184 color="var(--yellow)"
185 />
186 <StatBox
187 label="Open Issues"
188 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
189 color="var(--red)"
190 />
191 </div>
192
193 {/* ─── Repo Grid ─── */}
194 <h2 style="font-size: 18px; margin-bottom: 16px">Your Repositories</h2>
195 {repos.length === 0 ? (
80bed05Claude196 <div class="empty-state" style="text-align:left;padding:24px">
197 <div style="text-align:center;margin-bottom:20px">
198 <h2 style="margin-bottom:6px">Get started</h2>
199 <p style="color:var(--text-muted);font-size:14px;margin:0">
200 Ship safer code with AI-native hosting, automated CI, and push-time gates. Pick a path:
201 </p>
202 </div>
203 <div class="panel" style="margin-bottom:20px;text-align:left">
204 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
205 <div style="flex:1">
206 <div style="font-size:15px;font-weight:600">Create a new repository</div>
207 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
208 Start from scratch with green-ecosystem defaults.
209 </div>
210 </div>
211 <a href="/new" class="btn btn-primary">Create repo</a>
212 </div>
213 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
214 <div style="flex:1">
215 <div style="font-size:15px;font-weight:600">Import from GitHub</div>
216 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
217 Mirror an existing repo — history, branches, tags.
218 </div>
219 </div>
220 <a href="/import" class="btn">Import repo</a>
221 </div>
222 <div class="panel-item" style="justify-content:space-between;padding:16px;gap:12px">
223 <div style="flex:1">
224 <div style="font-size:15px;font-weight:600">Browse public repos</div>
225 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
226 See what others are building, fork what you like.
227 </div>
228 </div>
229 <a href="/explore" class="btn">Browse</a>
230 </div>
231 </div>
232 <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px">
233 <div style="font-size:12px;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">
234 Push an existing project (preview)
235 </div>
236 <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.
237git remote add gluecron http://localhost:3000/{user.username}/&lt;your-repo&gt;.git
238git push -u gluecron main</code></pre>
239 </div>
f1ab587Claude240 </div>
241 ) : (
242 <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 16px; margin-bottom: 32px">
243 {repoData.map(({ repo, healthScore, healthGrade, recentCommits, branchCount, ciConfig }) => (
244 <div class="card" style="padding: 0; overflow: hidden">
245 {/* Health bar at top */}
246 <div
247 style={`height: 4px; background: ${gradeColor(healthGrade)}; width: ${healthScore}%; transition: width 0.3s`}
248 />
249 <div style="padding: 16px">
250 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px">
251 <div>
252 <h3 style="font-size: 16px; margin-bottom: 2px">
253 <a href={`/${user.username}/${repo.name}`}>{repo.name}</a>
254 </h3>
255 {repo.description && (
256 <p style="font-size: 12px; color: var(--text-muted); margin-bottom: 0">
257 {repo.description}
258 </p>
259 )}
260 </div>
261 <div style="text-align: center; flex-shrink: 0; margin-left: 12px">
262 <div
263 style={`font-size: 20px; font-weight: 800; color: ${gradeColor(healthGrade)}`}
264 >
265 {healthGrade}
266 </div>
267 <div style="font-size: 10px; color: var(--text-muted)">
268 {healthScore}/100
269 </div>
270 </div>
271 </div>
272
273 <div style="display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); margin-top: 8px">
274 <span>{branchCount} branch{branchCount !== 1 ? "es" : ""}</span>
275 <span>{"\u2606"} {repo.starCount}</span>
276 {repo.isPrivate && <span class="badge" style="font-size: 10px">Private</span>}
277 </div>
278
279 {ciConfig && ciConfig.commands.length > 0 && (
280 <div style="margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap">
281 {ciConfig.detected.slice(0, 3).map((d) => (
282 <span
283 class="badge"
284 style="font-size: 10px; background: rgba(31, 111, 235, 0.1); color: var(--text-link); border-color: var(--accent)"
285 >
286 {d}
287 </span>
288 ))}
289 </div>
290 )}
291
292 <div style="display: flex; gap: 6px; margin-top: 12px">
293 <a
294 href={`/${user.username}/${repo.name}/health`}
295 class="btn btn-sm"
296 style="font-size: 11px; padding: 2px 8px"
297 >
298 Health
299 </a>
300 <a
301 href={`/${user.username}/${repo.name}/dependencies`}
302 class="btn btn-sm"
303 style="font-size: 11px; padding: 2px 8px"
304 >
305 Deps
306 </a>
307 <a
308 href={`/${user.username}/${repo.name}/coupling`}
309 class="btn btn-sm"
310 style="font-size: 11px; padding: 2px 8px"
311 >
312 Insights
313 </a>
314 <a
315 href={`/${user.username}/${repo.name}/settings`}
316 class="btn btn-sm"
317 style="font-size: 11px; padding: 2px 8px"
318 >
319 Settings
320 </a>
321 </div>
322 </div>
323 </div>
324 ))}
325 </div>
326 )}
327
328 {/* ─── Activity Feed ─── */}
329 {recentActivity.length > 0 && (
330 <>
331 <h2 style="font-size: 18px; margin-bottom: 16px">Recent Activity</h2>
332 <div class="issue-list">
333 {recentActivity.map((a) => (
334 <div class="issue-item">
335 <div style="display: flex; gap: 8px; align-items: center">
336 <ActivityIcon action={a.action} />
337 <div>
338 <span style="font-size: 14px">
339 {formatAction(a.action)} in{" "}
340 <a href={`/${user.username}/${a.repoName}`}>
341 {a.repoName}
342 </a>
343 </span>
344 <div style="font-size: 12px; color: var(--text-muted)">
345 {formatRelative(a.createdAt)}
346 </div>
347 </div>
348 </div>
349 </div>
350 ))}
351 </div>
352 </>
353 )}
354
febd4f0Claude355 {/* ─── Live Activity (SSE) ─── */}
356 <LiveFeed topic={`user:${user.id}`} title="Live activity" />
357
f1ab587Claude358 {/* ─── Quick Links ─── */}
359 <div style="margin-top: 32px; display: flex; gap: 16px; flex-wrap: wrap">
360 <a href="/explore" class="btn">Browse public repos</a>
361 <a href="/settings/tokens" class="btn">API tokens</a>
362 <a href="/settings/keys" class="btn">SSH keys</a>
363 </div>
364 </Layout>
365 );
366});
367
368// ─── INTELLIGENCE SETTINGS PER REPO ──────────────────────────
369
370dashboard.get(
371 "/:owner/:repo/settings/intelligence",
372 requireAuth,
373 async (c) => {
374 const { owner: ownerName, repo: repoName } = c.req.param();
375 const user = c.get("user")!;
376 const success = c.req.query("success");
377
378 return c.html(
379 <Layout title={`Intelligence — ${ownerName}/${repoName}`} user={user}>
380 <div style="max-width: 600px">
381 <h2 style="margin-bottom: 20px">Intelligence Settings</h2>
382 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
383 Control what gluecron does automatically when code is pushed to{" "}
384 <strong>{ownerName}/{repoName}</strong>.
385 </p>
386 {success && (
387 <div class="auth-success">{decodeURIComponent(success)}</div>
388 )}
389 <form
0316dbbClaude390 method="post"
f1ab587Claude391 action={`/${ownerName}/${repoName}/settings/intelligence`}
392 >
393 <ToggleSetting
394 name="auto_repair"
395 label="Auto-Repair"
396 description="Automatically fix whitespace, missing .gitignore, broken JSON, and masked secrets on every push"
397 defaultChecked={true}
398 />
399 <ToggleSetting
400 name="security_scan"
401 label="Security Scanning"
402 description="Scan for hardcoded secrets, injection vulnerabilities, weak crypto, and other security issues"
403 defaultChecked={true}
404 />
405 <ToggleSetting
406 name="health_score"
407 label="Health Score"
408 description="Compute and track repository health score (security, testing, complexity, deps, docs, activity)"
409 defaultChecked={true}
410 />
411 <ToggleSetting
412 name="push_analysis"
413 label="Push Risk Analysis"
414 description="Analyze every push for breaking changes, removed exports, API changes, and compute risk score"
415 defaultChecked={true}
416 />
417 <ToggleSetting
418 name="dep_analysis"
419 label="Dependency Analysis"
420 description="Build import graph, detect unused deps, find circular dependencies"
421 defaultChecked={true}
422 />
423 <ToggleSetting
424 name="gatetest"
425 label="GateTest Integration"
426 description="Send push events to GateTest for external security scanning"
427 defaultChecked={true}
428 />
429 <ToggleSetting
90fa787Claude430 name="deploy_webhook"
431 label="Auto-Deploy Webhook"
432 description="POST to your configured deploy webhook when pushing to the default branch"
f1ab587Claude433 defaultChecked={true}
434 />
435
436 <button
437 type="submit"
438 class="btn btn-primary"
439 style="margin-top: 12px"
440 >
441 Save settings
442 </button>
443 </form>
444 </div>
445 </Layout>
446 );
447 }
448);
449
450dashboard.post(
451 "/:owner/:repo/settings/intelligence",
452 requireAuth,
453 async (c) => {
454 const { owner: ownerName, repo: repoName } = c.req.param();
455 // In production, these would be saved to DB per-repo
456 // For now, acknowledge the settings
457 return c.redirect(
458 `/${ownerName}/${repoName}/settings/intelligence?success=Settings+saved`
459 );
460 }
461);
462
463// ─── PUSH LOG ────────────────────────────────────────────────
464
465dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
466 const { owner, repo } = c.req.param();
467 const user = c.get("user");
468
469 if (!(await repoExists(owner, repo))) return c.notFound();
470 const ref = (await getDefaultBranch(owner, repo)) || "main";
471 const commits = await listCommits(owner, repo, ref, 30);
472
473 return c.html(
474 <Layout title={`Push Log — ${owner}/${repo}`} user={user}>
475 <div style="max-width: 900px">
476 <h2 style="margin-bottom: 4px">Push Log</h2>
477 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 20px">
478 Every push analyzed in real-time — risk scores, repairs, security alerts
479 </p>
480 <div class="issue-list">
481 {commits.map((commit) => {
482 // Determine if this was an auto-repair commit
483 const isRepair =
484 commit.author === "gluecron[bot]" ||
485 commit.message.includes("auto-repair");
486 const isRollback = commit.message.startsWith("revert: rollback");
487
488 return (
489 <div class="issue-item" style="flex-direction: column; align-items: stretch">
490 <div style="display: flex; justify-content: space-between; align-items: start">
491 <div style="display: flex; gap: 8px; align-items: start">
492 {isRepair ? (
493 <span
494 style="color: var(--green); font-size: 16px; flex-shrink: 0; margin-top: 2px"
495 title="Auto-repair"
496 >
497 {"⚡"}
498 </span>
499 ) : isRollback ? (
500 <span
501 style="color: var(--yellow); font-size: 16px; flex-shrink: 0; margin-top: 2px"
502 title="Rollback"
503 >
504 {"↩"}
505 </span>
506 ) : (
507 <span
508 style="color: var(--text-link); font-size: 16px; flex-shrink: 0; margin-top: 2px"
509 >
510 {"→"}
511 </span>
512 )}
513 <div>
514 <a
515 href={`/${owner}/${repo}/commit/${commit.sha}`}
516 style="font-weight: 600; font-size: 14px"
517 >
518 {commit.message}
519 </a>
520 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
521 {commit.author} —{" "}
522 {new Date(commit.date).toLocaleString("en-US", {
523 month: "short",
524 day: "numeric",
525 hour: "2-digit",
526 minute: "2-digit",
527 })}
528 </div>
529 </div>
530 </div>
531 <a
532 href={`/${owner}/${repo}/commit/${commit.sha}`}
533 class="commit-sha"
534 >
535 {commit.sha.slice(0, 7)}
536 </a>
537 </div>
538 {isRepair && (
539 <div
540 style="margin-top: 8px; padding: 8px 12px; background: rgba(63, 185, 80, 0.1); border-radius: var(--radius); font-size: 12px; color: var(--green)"
541 >
542 Automatically repaired by gluecron
543 </div>
544 )}
545 </div>
546 );
547 })}
548 </div>
549 </div>
550 </Layout>
551 );
552});
553
554// ─── COMPONENTS ──────────────────────────────────────────────
555
556const StatBox = ({
557 label,
558 value,
559 color,
560}: {
561 label: string;
562 value: string;
563 color: string;
564}) => (
565 <div
566 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center"
567 >
568 <div style={`font-size: 28px; font-weight: 700; color: ${color}`}>
569 {value}
570 </div>
571 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
572 {label}
573 </div>
574 </div>
575);
576
577const ToggleSetting = ({
578 name,
579 label,
580 description,
581 defaultChecked,
582}: {
583 name: string;
584 label: string;
585 description: string;
586 defaultChecked: boolean;
587}) => (
588 <div
589 style="display: flex; justify-content: space-between; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border)"
590 >
591 <div style="flex: 1">
592 <div style="font-size: 15px; font-weight: 600">{label}</div>
593 <div style="font-size: 13px; color: var(--text-muted); margin-top: 2px">
594 {description}
595 </div>
596 </div>
597 <label class="toggle-switch">
598 <input type="checkbox" name={name} value="on" checked={defaultChecked} />
599 <span class="toggle-slider" />
600 </label>
601 </div>
602);
603
604const ActivityIcon = ({ action }: { action: string }) => {
605 const icons: Record<string, string> = {
606 push: "→",
607 issue_open: "\u25CB",
608 issue_close: "\u2713",
609 pr_open: "\u25CB",
610 pr_merge: "\u2B8C",
611 star: "\u2605",
612 fork: "\u2442",
613 comment: "\u{1F4AC}",
614 };
615 return (
616 <span style="font-size: 16px; width: 20px; text-align: center; flex-shrink: 0">
617 {icons[action] || "•"}
618 </span>
619 );
620};
621
622function formatAction(action: string): string {
623 const labels: Record<string, string> = {
624 push: "Pushed code",
625 issue_open: "Opened issue",
626 issue_close: "Closed issue",
627 pr_open: "Opened pull request",
628 pr_merge: "Merged pull request",
629 star: "Starred",
630 fork: "Forked",
631 comment: "Commented",
632 };
633 return labels[action] || action;
634}
635
636function formatRelative(date: Date | string): string {
637 const d = typeof date === "string" ? new Date(date) : date;
638 const now = new Date();
639 const diffMs = now.getTime() - d.getTime();
640 const diffMins = Math.floor(diffMs / 60000);
641 if (diffMins < 1) return "just now";
642 if (diffMins < 60) return `${diffMins}m ago`;
643 const diffHours = Math.floor(diffMins / 60);
644 if (diffHours < 24) return `${diffHours}h ago`;
645 const diffDays = Math.floor(diffHours / 24);
646 if (diffDays < 30) return `${diffDays}d ago`;
647 return d.toLocaleDateString("en-US", {
648 month: "short",
649 day: "numeric",
650 });
651}
652
653export default dashboard;