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

org-health.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.

org-health.tsxBlame548 lines · 2 contributors
b1070a5Claude1/**
2 * Org-level team health dashboard.
3 *
4 * GET /orgs/:slug/health — ranked health page (worst-first)
5 * POST /orgs/:slug/health/recompute — invalidate caches + redirect
6 *
7 * Reuses `computeOrgHealth` from src/lib/org-health.ts and the
8 * `getHealthScore` signal breakdown already built in src/lib/repo-health.ts.
9 * No new tables — leverages repo_health_cache + bus_factor_cache etc.
10 */
11
12import { Hono } from "hono";
13import { eq, and } from "drizzle-orm";
14import { db } from "../db";
15import { organizations, orgMembers } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import {
20 computeOrgHealth,
21 invalidateOrgHealthAndRepos,
22 type OrgRepoHealth,
23} from "../lib/org-health";
24import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs";
25
26const orgHealthRoutes = new Hono<AuthEnv>();
27orgHealthRoutes.use("*", softAuth);
28
29// ---------------------------------------------------------------------------
30// Helpers
31// ---------------------------------------------------------------------------
32
33function scoreColor(score: number): string {
e589f77ccantynz-alt34 if (score >= 80) return "var(--green)";
35 if (score >= 50) return "var(--yellow)";
36 return "var(--red)";
b1070a5Claude37}
38
39function scorePillClass(score: number): string {
40 if (score >= 80) return "oh-pill--good";
41 if (score >= 50) return "oh-pill--warn";
42 return "oh-pill--bad";
43}
44
45function trendArrow(trend: "up" | "down" | "stable"): string {
46 if (trend === "up") return "↑";
47 if (trend === "down") return "↓";
48 return "→";
49}
50
51function trendClass(trend: "up" | "down" | "stable"): string {
52 if (trend === "up") return "oh-trend--up";
53 if (trend === "down") return "oh-trend--down";
54 return "oh-trend--stable";
55}
56
57function badgeStyle(score: number): string {
58 const color = scoreColor(score);
59 return `background:rgba(0,0,0,0.25);color:${color};border:1px solid ${color}40;padding:2px 6px;border-radius:4px;font-size:11px;font-family:var(--font-mono);font-weight:600`;
60}
61
62// ---------------------------------------------------------------------------
63// Scoped CSS — every class prefixed `.oh-`
64// ---------------------------------------------------------------------------
65
66const styles = `
67 .oh-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
68
69 /* Hero */
70 .oh-hero {
71 position: relative;
72 margin-bottom: var(--space-5);
73 padding: var(--space-5) var(--space-6);
74 background: var(--bg-elevated);
75 border: 1px solid var(--border);
76 border-radius: 16px;
77 overflow: hidden;
78 }
79 .oh-hero::before {
80 content: '';
81 position: absolute;
82 top: 0; left: 0; right: 0;
83 height: 2px;
84 background: linear-gradient(90deg, transparent 0%, #34d399 30%, #fbbf24 70%, transparent 100%);
85 opacity: 0.75;
86 pointer-events: none;
87 }
88 .oh-hero-orb {
89 position: absolute;
90 inset: -30% -15% auto auto;
91 width: 420px; height: 420px;
92 background: radial-gradient(circle, rgba(52,211,153,0.18), rgba(251,191,36,0.08) 45%, transparent 70%);
93 filter: blur(80px);
94 opacity: 0.6;
95 pointer-events: none;
96 z-index: 0;
97 }
98 .oh-hero-inner {
99 position: relative;
100 z-index: 1;
101 display: flex;
102 align-items: flex-start;
103 justify-content: space-between;
104 gap: var(--space-4);
105 flex-wrap: wrap;
106 }
107 .oh-hero-text { max-width: 720px; }
108 .oh-eyebrow {
109 display: inline-flex;
110 align-items: center;
111 gap: 8px;
112 text-transform: uppercase;
113 font-family: var(--font-mono);
114 font-size: 11px;
115 letter-spacing: 0.18em;
116 color: var(--text-muted);
117 font-weight: 600;
118 margin-bottom: 14px;
119 }
120 .oh-eyebrow-dot {
121 width: 8px; height: 8px;
122 border-radius: 9999px;
123 background: linear-gradient(135deg, #34d399, #fbbf24);
124 box-shadow: 0 0 0 3px rgba(52,211,153,0.18);
125 }
126 .oh-title {
127 font-family: var(--font-display);
128 font-size: clamp(26px, 4vw, 38px);
129 font-weight: 800;
130 letter-spacing: -0.025em;
131 line-height: 1.05;
132 margin: 0 0 var(--space-2);
133 color: var(--text-strong);
134 }
135 .oh-title-grad {
136 background-image: linear-gradient(135deg, #6ee7b7 0%, #34d399 50%, #fbbf24 100%);
137 -webkit-background-clip: text;
138 background-clip: text;
139 -webkit-text-fill-color: transparent;
140 color: transparent;
141 }
142 .oh-sub {
143 font-size: 15px;
144 color: var(--text-muted);
145 margin: 0;
146 line-height: 1.55;
147 }
148 .oh-back {
149 display: inline-flex;
150 align-items: center;
151 gap: 6px;
152 padding: 8px 14px;
153 border-radius: 10px;
154 border: 1px solid var(--border-strong, var(--border));
155 background: transparent;
156 color: var(--text);
157 font-size: 13px;
158 font-weight: 600;
159 text-decoration: none;
160 transition: background 120ms ease, border-color 120ms ease;
161 white-space: nowrap;
162 }
163 .oh-back:hover {
164 background: rgba(52,211,153,0.06);
165 border-color: rgba(52,211,153,0.45);
166 text-decoration: none;
167 }
168
169 /* Avg score pill */
170 .oh-pill {
171 display: inline-flex;
172 align-items: center;
173 justify-content: center;
174 width: 64px; height: 64px;
175 border-radius: 9999px;
176 font-family: var(--font-display);
177 font-size: 22px;
178 font-weight: 800;
179 border: 3px solid;
180 margin-top: 14px;
181 font-variant-numeric: tabular-nums;
182 }
e589f77ccantynz-alt183 .oh-pill--good { color: var(--green); border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
184 .oh-pill--warn { color: var(--yellow); border-color: rgba(251,191,36,0.5); background: rgba(251,191,36,0.08); }
185 .oh-pill--bad { color: var(--red); border-color: rgba(248,113,113,0.5); background: rgba(248,113,113,0.08); }
b1070a5Claude186
187 /* AI summary card */
188 .oh-ai-card {
189 margin-bottom: var(--space-5);
190 padding: var(--space-4) var(--space-5);
191 background: rgba(59,130,246,0.05);
192 border: 1px solid rgba(59,130,246,0.3);
193 border-radius: 14px;
194 font-size: 14px;
195 line-height: 1.65;
196 color: var(--text);
197 white-space: pre-wrap;
198 }
199 .oh-ai-label {
200 display: flex;
201 align-items: center;
202 gap: 7px;
203 font-size: 10.5px;
204 font-weight: 700;
205 letter-spacing: 0.14em;
206 text-transform: uppercase;
207 color: #93c5fd;
208 margin-bottom: var(--space-2);
209 }
210 .oh-ai-dot {
211 width: 6px; height: 6px;
212 border-radius: 9999px;
213 background: #3b82f6;
214 box-shadow: 0 0 0 3px rgba(59,130,246,0.2);
215 }
216
217 /* Repo table */
218 .oh-table-wrap {
219 background: var(--bg-elevated);
220 border: 1px solid var(--border);
221 border-radius: 14px;
222 overflow: hidden;
223 margin-bottom: var(--space-5);
224 }
225 .oh-table-head {
226 padding: var(--space-3) var(--space-4);
227 display: flex;
228 align-items: center;
229 justify-content: space-between;
230 border-bottom: 1px solid var(--border);
231 flex-wrap: wrap;
232 gap: var(--space-2);
233 }
234 .oh-table-title {
235 margin: 0;
236 font-family: var(--font-display);
237 font-size: 16px;
238 font-weight: 700;
239 letter-spacing: -0.015em;
240 color: var(--text-strong);
241 }
242 .oh-table-sub { font-size: 12px; color: var(--text-muted); }
243 table.oh-table {
244 width: 100%;
245 border-collapse: collapse;
246 font-size: 13px;
247 }
248 .oh-table th {
249 padding: 10px 14px;
250 text-align: left;
251 font-size: 10.5px;
252 font-weight: 700;
253 letter-spacing: 0.12em;
254 text-transform: uppercase;
255 color: var(--text-muted);
256 border-bottom: 1px solid var(--border);
257 white-space: nowrap;
258 }
259 .oh-table td {
260 padding: 10px 14px;
261 border-bottom: 1px solid var(--border);
262 vertical-align: middle;
263 }
264 .oh-table tr:last-child td { border-bottom: none; }
265 .oh-table tr:hover td { background: rgba(255,255,255,0.02); }
266 .oh-rank {
267 width: 28px; height: 28px;
268 border-radius: 8px;
269 background: rgba(140,109,255,0.10);
270 color: #b69dff;
271 font-family: var(--font-mono);
272 font-size: 12px;
273 font-weight: 700;
274 display: inline-flex;
275 align-items: center;
276 justify-content: center;
277 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
278 }
279 .oh-repo-link {
280 color: var(--text-strong);
281 font-weight: 600;
282 text-decoration: none;
283 font-size: 13.5px;
284 }
285 .oh-repo-link:hover { color: var(--accent); }
286
287 /* Score bar */
288 .oh-bar-track {
289 width: 120px;
290 height: 6px;
291 background: rgba(255,255,255,0.08);
292 border-radius: 3px;
293 overflow: hidden;
294 display: inline-block;
295 vertical-align: middle;
296 margin-right: 8px;
297 }
298
299 /* Trend arrows */
e589f77ccantynz-alt300 .oh-trend--up { color: var(--green); font-weight: 700; }
301 .oh-trend--down { color: var(--red); font-weight: 700; }
b1070a5Claude302 .oh-trend--stable { color: var(--text-muted); }
303
304 /* Recompute button */
305 .oh-recompute-form { margin-top: var(--space-2); }
306 .oh-recompute-btn {
307 display: inline-flex;
308 align-items: center;
309 gap: 6px;
310 padding: 8px 16px;
311 border-radius: 10px;
312 border: 1px solid var(--border-strong, var(--border));
313 background: transparent;
314 color: var(--text);
315 font-size: 13px;
316 font-weight: 600;
317 cursor: pointer;
318 transition: background 120ms ease, border-color 120ms ease;
319 }
320 .oh-recompute-btn:hover {
321 background: rgba(52,211,153,0.07);
322 border-color: rgba(52,211,153,0.4);
323 }
324
325 /* Empty state */
326 .oh-empty {
327 padding: var(--space-6) var(--space-4);
328 text-align: center;
329 color: var(--text-muted);
330 border: 1px dashed var(--border-strong, var(--border));
331 border-radius: 14px;
332 }
333 .oh-empty strong {
334 display: block;
335 font-size: 16px;
336 font-weight: 700;
337 color: var(--text-strong);
338 margin-bottom: 8px;
339 }
340
341 /* Generated-at footer */
342 .oh-footer {
343 font-size: 11.5px;
344 color: var(--text-muted);
345 margin-top: var(--space-3);
346 font-family: var(--font-mono);
347 }
348`;
349
350// ---------------------------------------------------------------------------
351// GET /orgs/:slug/health
352// ---------------------------------------------------------------------------
353
354orgHealthRoutes.get("/orgs/:slug/health", async (c) => {
355 const slug = c.req.param("slug");
356 const user = c.get("user");
357
358 const { org, role } = await loadOrgForUser(slug, user?.id);
359 if (!org) return c.notFound();
360
361 const report = await computeOrgHealth(org.id, org.slug);
362 // Patch org name in case the lib used slug as placeholder
363 const orgName = org.name;
364
365 const isAdmin = !!role && orgRoleAtLeast(role, "admin");
366 const avgScore = report.avgScore;
367
368 return c.html(
369 <Layout title={`${orgName} — Engineering Health`} user={user ?? null}>
370 <div class="oh-wrap">
371 {/* Hero */}
372 <section class="oh-hero">
373 <div class="oh-hero-orb" aria-hidden="true" />
374 <div class="oh-hero-inner">
375 <div class="oh-hero-text">
376 <div class="oh-eyebrow">
377 <span class="oh-eyebrow-dot" aria-hidden="true" />
378 Team health · {slug}
379 </div>
380 <h2 class="oh-title">
381 <span class="oh-title-grad">{orgName}</span>{" "}
382 Engineering Health
383 </h2>
384 <p class="oh-sub">
385 All repositories ranked worst-first by composite health score.
386 Fix the bottom of the list to move the org average.
387 </p>
388 <div
389 class={"oh-pill " + scorePillClass(avgScore)}
390 title={`Org average: ${avgScore}/100`}
391 >
392 {avgScore}
393 </div>
394 </div>
395 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-3)">
396 <a href={`/orgs/${slug}`} class="oh-back">
397 ← Back to {slug}
398 </a>
399 {isAdmin && (
400 <form
401 method="post"
402 action={`/orgs/${slug}/health/recompute`}
403 class="oh-recompute-form"
404 >
405 <button type="submit" class="oh-recompute-btn">
406 ↺ Recompute
407 </button>
408 </form>
409 )}
410 </div>
411 </div>
412 </section>
413
414 {/* AI Summary */}
415 {report.aiSummary && (
416 <div class="oh-ai-card">
417 <div class="oh-ai-label">
418 <span class="oh-ai-dot" aria-hidden="true" />
419 AI Engineering Summary
420 </div>
421 {report.aiSummary}
422 </div>
423 )}
424
425 {/* Repo table */}
426 <div class="oh-table-wrap">
427 <div class="oh-table-head">
428 <h3 class="oh-table-title">Repository Health — Worst First</h3>
429 <span class="oh-table-sub">{report.repos.length} repo{report.repos.length === 1 ? "" : "s"}</span>
430 </div>
431
432 {report.repos.length === 0 ? (
433 <div class="oh-empty">
434 <strong>No repositories found</strong>
435 <p>This org has no active repositories yet, or health data is still loading.</p>
436 </div>
437 ) : (
438 <table class="oh-table">
439 <thead>
440 <tr>
441 <th style="width:40px">#</th>
442 <th>Repository</th>
443 <th style="width:200px">Score</th>
444 <th style="width:60px">Trend</th>
445 <th style="width:55px">CI</th>
446 <th style="width:80px">BusFactor</th>
447 <th style="width:55px">CVEs</th>
448 <th style="width:60px">Review</th>
449 <th style="width:55px">Debt</th>
450 </tr>
451 </thead>
452 <tbody>
453 {report.repos.map((r: OrgRepoHealth, i: number) => {
454 const b = r.breakdown;
455 const barColor = scoreColor(r.score);
456 return (
457 <tr key={r.repoId}>
458 <td>
459 <span class="oh-rank">{i + 1}</span>
460 </td>
461 <td>
462 <a
463 href={`/${slug}/${r.repoName}`}
464 class="oh-repo-link"
465 >
466 {slug}/{r.repoName}
467 </a>
468 </td>
469 <td>
470 <span class="oh-bar-track">
471 <div
472 style={`width:${r.score}%;background:${barColor};height:6px;border-radius:3px`}
473 />
474 </span>
475 <span
476 style={`color:${barColor};font-family:var(--font-mono);font-size:13px;font-weight:700`}
477 >
478 {r.score}
479 </span>
480 <span style="color:var(--text-muted);font-size:11px">/100</span>
481 </td>
482 <td>
483 <span class={trendClass(r.trend)} title={`Trend: ${r.trend}`}>
484 {trendArrow(r.trend)}
485 </span>
486 </td>
487 <td>
488 <span style={badgeStyle(b.ciGreenRate.score)}>
489 {b.ciGreenRate.score}
490 </span>
491 </td>
492 <td>
493 <span style={badgeStyle(b.busFactor.score)}>
494 {b.busFactor.score}
495 </span>
496 </td>
497 <td>
498 <span style={badgeStyle(b.openCves.score)}>
499 {b.openCves.score}
500 </span>
501 </td>
502 <td>
503 <span style={badgeStyle(b.reviewVelocity.score)}>
504 {b.reviewVelocity.score}
505 </span>
506 </td>
507 <td>
508 <span style={badgeStyle(b.techDebt.score)}>
509 {b.techDebt.score}
510 </span>
511 </td>
512 </tr>
513 );
514 })}
515 </tbody>
516 </table>
517 )}
518 </div>
519
520 <div class="oh-footer">
521 Generated {report.generatedAt.toISOString().replace("T", " ").slice(0, 19)} UTC · cached 1h
522 </div>
523 </div>
524 <style dangerouslySetInnerHTML={{ __html: styles }} />
525 </Layout>
526 );
527});
528
529// ---------------------------------------------------------------------------
530// POST /orgs/:slug/health/recompute — admin only, invalidate + redirect
531// ---------------------------------------------------------------------------
532
533orgHealthRoutes.post("/orgs/:slug/health/recompute", requireAuth, async (c) => {
534 const slug = c.req.param("slug");
535 const user = c.get("user")!;
536
537 const { org, role } = await loadOrgForUser(slug, user.id);
538 if (!org) return c.notFound();
539 if (!role || !orgRoleAtLeast(role, "admin")) {
540 return c.text("Forbidden", 403);
541 }
542
543 await invalidateOrgHealthAndRepos(org.id);
544 return c.redirect(`/orgs/${slug}/health`);
545});
546
547export { orgHealthRoutes };
548export default orgHealthRoutes;