Commit2d78948unknown_key
Add AI activity widget to dashboard — last hour view
Add AI activity widget to dashboard — last hour view Adds a compact "AI Activity — Last Hour" card to the Command Center dashboard. Queries audit_log for auto_merge.merged and ai_build.dispatched events, and gate_runs for repaired status, all scoped to the last 60 minutes and the logged-in user's repos. Shows per-action counts as summary pills plus a linked item list (up to 6 items). Falls back to "All quiet — AI is watching." when there is no activity. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
1 file changed+425−22d7894885c6a33a74d437917eb14f0eefbf4e83b
1 changed file+425−2
Modifiedsrc/routes/dashboard.tsx+425−2View fileUnifiedSplit
@@ -15,13 +15,15 @@
1515 */
1616
1717import { Hono } from "hono";
18import { eq, desc, and, inArray, ne, sql } from "drizzle-orm";
18import { eq, desc, and, inArray, ne, sql, gte } from "drizzle-orm";
1919import { getCookie, setCookie } from "hono/cookie";
2020import { db } from "../db";
2121import {
2222 repositories,
2323 users,
2424 activityFeed,
25 auditLog,
26 gateRuns,
2527 issues,
2628 pullRequests,
2729} from "../db/schema";
@@ -46,6 +48,229 @@ import {
4648 type AiSavingsLifetimeReport,
4749} from "../lib/ai-hours-saved";
4850
51// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
52
53const SECRET_GATE_NAMES_DASH = [
54 "Secret scan",
55 "Secret Scan",
56 "Security scan",
57 "Security Scan",
58];
59
60export type AiActivityItem = {
61 kind: "pr_auto_merged" | "spec_shipped" | "ci_healed" | "secret_repaired";
62 repoName: string;
63 repoOwner: string;
64 /** PR or issue number — null when it's a gate repair with no PR */
65 refNumber: number | null;
66 /** Short title for display (PR/issue title or gate name) */
67 label: string;
68 occurredAt: Date;
69};
70
71export type AiActivityReport = {
72 items: AiActivityItem[];
73 prsAutoMerged: number;
74 specsShipped: number;
75 ciHealed: number;
76 secretsRepaired: number;
77};
78
79/**
80 * Fetch autopilot-driven activity from the last 60 minutes for all repos
81 * owned by the given user. Never throws — on any DB error returns an
82 * empty report so the widget always renders.
83 */
84async function fetchAiActivityLastHour(
85 userId: string,
86 repoIds: string[],
87 repoMap: Map<string, { name: string; ownerUsername: string }>,
88): Promise<AiActivityReport> {
89 const empty: AiActivityReport = {
90 items: [],
91 prsAutoMerged: 0,
92 specsShipped: 0,
93 ciHealed: 0,
94 secretsRepaired: 0,
95 };
96 if (repoIds.length === 0) return empty;
97
98 const since = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
99
100 try {
101 // ── 1. Auto-merged PRs ─────────────────────────────────────
102 const autoMergeRows = await db
103 .select({
104 repositoryId: auditLog.repositoryId,
105 targetId: auditLog.targetId,
106 createdAt: auditLog.createdAt,
107 })
108 .from(auditLog)
109 .where(
110 and(
111 eq(auditLog.action, "auto_merge.merged"),
112 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
113 gte(auditLog.createdAt, since),
114 )
115 )
116 .orderBy(desc(auditLog.createdAt))
117 .limit(20);
118
119 // Fetch PR titles for auto-merged rows
120 const autoMergePrIds = autoMergeRows
121 .map((r) => r.targetId)
122 .filter(Boolean) as string[];
123 const prTitleMap = new Map<string, { number: number; title: string }>();
124 if (autoMergePrIds.length > 0) {
125 const prRows = await db
126 .select({ id: pullRequests.id, number: pullRequests.number, title: pullRequests.title })
127 .from(pullRequests)
128 .where(inArray(pullRequests.id, autoMergePrIds as [string, ...string[]]));
129 for (const r of prRows) prTitleMap.set(r.id, { number: r.number, title: r.title });
130 }
131
132 // ── 2. AI-built specs dispatched ───────────────────────────
133 const specRows = await db
134 .select({
135 repositoryId: auditLog.repositoryId,
136 targetId: auditLog.targetId,
137 createdAt: auditLog.createdAt,
138 })
139 .from(auditLog)
140 .where(
141 and(
142 eq(auditLog.action, "ai_build.dispatched"),
143 inArray(auditLog.repositoryId, repoIds as [string, ...string[]]),
144 gte(auditLog.createdAt, since),
145 )
146 )
147 .orderBy(desc(auditLog.createdAt))
148 .limit(20);
149
150 // Fetch issue numbers/titles for dispatched specs
151 const specIssueIds = specRows
152 .map((r) => r.targetId)
153 .filter(Boolean) as string[];
154 const issueTitleMap = new Map<string, { number: number; title: string }>();
155 if (specIssueIds.length > 0) {
156 const issueRows = await db
157 .select({ id: issues.id, number: issues.number, title: issues.title })
158 .from(issues)
159 .where(inArray(issues.id, specIssueIds as [string, ...string[]]));
160 for (const r of issueRows) issueTitleMap.set(r.id, { number: r.number, title: r.title });
161 }
162
163 // ── 3. Gate auto-repairs (secrets) ─────────────────────────
164 const secretRepairRows = await db
165 .select({
166 repositoryId: gateRuns.repositoryId,
167 gateName: gateRuns.gateName,
168 createdAt: gateRuns.createdAt,
169 })
170 .from(gateRuns)
171 .where(
172 and(
173 eq(gateRuns.status, "repaired"),
174 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
175 gte(gateRuns.createdAt, since),
176 inArray(gateRuns.gateName, SECRET_GATE_NAMES_DASH as [string, ...string[]]),
177 )
178 )
179 .orderBy(desc(gateRuns.createdAt))
180 .limit(20);
181
182 // ── 4. Gate auto-repairs (CI / other) ──────────────────────
183 const ciHealRows = await db
184 .select({
185 repositoryId: gateRuns.repositoryId,
186 gateName: gateRuns.gateName,
187 createdAt: gateRuns.createdAt,
188 })
189 .from(gateRuns)
190 .where(
191 and(
192 eq(gateRuns.status, "repaired"),
193 inArray(gateRuns.repositoryId, repoIds as [string, ...string[]]),
194 gte(gateRuns.createdAt, since),
195 sql`${gateRuns.gateName} NOT IN ('Secret scan','Secret Scan','Security scan','Security Scan')`,
196 )
197 )
198 .orderBy(desc(gateRuns.createdAt))
199 .limit(20);
200
201 // ── Build item list ────────────────────────────────────────
202 const items: AiActivityItem[] = [];
203
204 for (const r of autoMergeRows) {
205 const repo = repoMap.get(r.repositoryId ?? "");
206 if (!repo) continue;
207 const pr = r.targetId ? prTitleMap.get(r.targetId) : undefined;
208 items.push({
209 kind: "pr_auto_merged",
210 repoName: repo.name,
211 repoOwner: repo.ownerUsername,
212 refNumber: pr?.number ?? null,
213 label: pr?.title ?? "Pull request",
214 occurredAt: r.createdAt,
215 });
216 }
217
218 for (const r of specRows) {
219 const repo = repoMap.get(r.repositoryId ?? "");
220 if (!repo) continue;
221 const issue = r.targetId ? issueTitleMap.get(r.targetId) : undefined;
222 items.push({
223 kind: "spec_shipped",
224 repoName: repo.name,
225 repoOwner: repo.ownerUsername,
226 refNumber: issue?.number ?? null,
227 label: issue?.title ?? "Issue",
228 occurredAt: r.createdAt,
229 });
230 }
231
232 for (const r of secretRepairRows) {
233 const repo = repoMap.get(r.repositoryId ?? "");
234 if (!repo) continue;
235 items.push({
236 kind: "secret_repaired",
237 repoName: repo.name,
238 repoOwner: repo.ownerUsername,
239 refNumber: null,
240 label: r.gateName,
241 occurredAt: r.createdAt,
242 });
243 }
244
245 for (const r of ciHealRows) {
246 const repo = repoMap.get(r.repositoryId ?? "");
247 if (!repo) continue;
248 items.push({
249 kind: "ci_healed",
250 repoName: repo.name,
251 repoOwner: repo.ownerUsername,
252 refNumber: null,
253 label: r.gateName,
254 occurredAt: r.createdAt,
255 });
256 }
257
258 // Sort newest first
259 items.sort((a, b) => b.occurredAt.getTime() - a.occurredAt.getTime());
260
261 return {
262 items,
263 prsAutoMerged: autoMergeRows.length,
264 specsShipped: specRows.length,
265 ciHealed: ciHealRows.length,
266 secretsRepaired: secretRepairRows.length,
267 };
268 } catch (err) {
269 console.error("[dashboard] ai-activity-last-hour degraded:", err);
270 return empty;
271 }
272}
273
49274const dashboard = new Hono<AuthEnv>();
50275
51276dashboard.use("*", softAuth);
@@ -130,9 +355,15 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
130355
131356 // Block L9 — AI hours-saved counter. Pull both window + lifetime in
132357 // parallel; both helpers swallow DB errors so the dashboard always renders.
133 const [savingsWeek, savingsLifetime] = await Promise.all([
358 // Also fetch the last-hour autopilot activity for the new widget.
359 const repoIds = repos.map((r) => r.id);
360 const repoMap = new Map(
361 repos.map((r) => [r.id, { name: r.name, ownerUsername: user.username }])
362 );
363 const [savingsWeek, savingsLifetime, aiActivity] = await Promise.all([
134364 computeAiSavingsForUser(user.id, { windowHours: 168 }),
135365 computeLifetimeAiSavingsForUser(user.id),
366 fetchAiActivityLastHour(user.id, repoIds, repoMap),
136367 ]);
137368
138369 // Get recent activity
@@ -460,6 +691,9 @@ dashboard.get("/dashboard", requireAuth, async (c) => {
460691 {/* ─── L9: AI hours-saved hero widget ─── */}
461692 <AiHoursSavedWidget week={savingsWeek} lifetime={savingsLifetime} />
462693
694 {/* ─── AI Activity — Last Hour ─── */}
695 <AiActivityWidget activity={aiActivity} username={user.username} />
696
463697 {/* ─── Quick actions — surfaces the 5 most-leverage AI features so
464698 they're discoverable from the dashboard without diving into the
465699 AI dropdown in the top nav. Scoped CSS under .qa- prefix. ─── */}
@@ -924,6 +1158,195 @@ dashboard.get("/:owner/:repo/pushes", softAuth, async (c) => {
9241158
9251159// ─── COMPONENTS ──────────────────────────────────────────────
9261160
1161// ─── AI Activity — Last Hour widget ─────────────────────────────────────────
1162
1163const AI_ACTIVITY_ICON: Record<AiActivityItem["kind"], string> = {
1164 pr_auto_merged: "⮌", // ⬌ (merged)
1165 spec_shipped: "\u{1F4E6}", // 📦
1166 ci_healed: "⚡", // ⚡
1167 secret_repaired: "\u{1F512}", // 🔒
1168};
1169
1170const AI_ACTIVITY_LABEL: Record<AiActivityItem["kind"], string> = {
1171 pr_auto_merged: "PR auto-merged",
1172 spec_shipped: "Spec shipped",
1173 ci_healed: "CI healed",
1174 secret_repaired: "Secret repaired",
1175};
1176
1177const AI_ACTIVITY_COLOR: Record<AiActivityItem["kind"], string> = {
1178 pr_auto_merged: "var(--accent)",
1179 spec_shipped: "var(--green)",
1180 ci_healed: "#58a6ff",
1181 secret_repaired: "var(--yellow)",
1182};
1183
1184const AiActivityWidget = ({
1185 activity,
1186 username,
1187}: {
1188 activity: AiActivityReport;
1189 username: string;
1190}) => {
1191 const total =
1192 activity.prsAutoMerged +
1193 activity.specsShipped +
1194 activity.ciHealed +
1195 activity.secretsRepaired;
1196
1197 const summaryParts: string[] = [];
1198 if (activity.prsAutoMerged > 0)
1199 summaryParts.push(
1200 `${activity.prsAutoMerged} PR${activity.prsAutoMerged === 1 ? "" : "s"} auto-merged`
1201 );
1202 if (activity.specsShipped > 0)
1203 summaryParts.push(
1204 `${activity.specsShipped} spec${activity.specsShipped === 1 ? "" : "s"} shipped`
1205 );
1206 if (activity.ciHealed > 0)
1207 summaryParts.push(
1208 `${activity.ciHealed} CI run${activity.ciHealed === 1 ? "" : "s"} healed`
1209 );
1210 if (activity.secretsRepaired > 0)
1211 summaryParts.push(
1212 `${activity.secretsRepaired} secret${activity.secretsRepaired === 1 ? "" : "s"} repaired`
1213 );
1214
1215 // Show at most 6 items in the expanded list to keep the card compact.
1216 const shownItems = activity.items.slice(0, 6);
1217
1218 return (
1219 <div
1220 class="card"
1221 style="margin-bottom: var(--space-6); padding: 0; overflow: hidden"
1222 >
1223 {/* Card header */}
1224 <div
1225 style="display:flex;align-items:center;justify-content:space-between;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border);background:var(--bg-elevated)"
1226 >
1227 <div style="display:flex;align-items:center;gap:var(--space-2)">
1228 <span style="font-size:15px" aria-hidden="true">{"\u{1F916}"}</span>
1229 <div>
1230 <span style="font-size:14px;font-weight:600;color:var(--text-strong)">
1231 AI Activity
1232 </span>
1233 <span
1234 style="margin-left:6px;font-size:11px;color:var(--text-muted);font-weight:400"
1235 >
1236 last hour
1237 </span>
1238 </div>
1239 </div>
1240 {total > 0 && (
1241 <span
1242 style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:9999px;background:rgba(140,109,255,0.14);color:var(--accent);border:1px solid rgba(140,109,255,0.25)"
1243 >
1244 {total} action{total === 1 ? "" : "s"}
1245 </span>
1246 )}
1247 </div>
1248
1249 {total === 0 ? (
1250 /* Empty state */
1251 <div
1252 style="padding:var(--space-5) var(--space-4);text-align:center;color:var(--text-muted);font-size:13px"
1253 >
1254 <div style="font-size:22px;margin-bottom:6px" aria-hidden="true">
1255 {"\u{1F441}"}
1256 </div>
1257 All quiet — AI is watching.
1258 </div>
1259 ) : (
1260 <>
1261 {/* Summary pills */}
1262 <div
1263 style="display:flex;flex-wrap:wrap;gap:6px;padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)"
1264 >
1265 {summaryParts.map((p) => (
1266 <span
1267 class="badge"
1268 style="font-size:12px;padding:3px 9px;background:rgba(140,109,255,0.08);border-color:rgba(140,109,255,0.22);color:var(--text)"
1269 >
1270 {p}
1271 </span>
1272 ))}
1273 </div>
1274
1275 {/* Item list */}
1276 <ul style="list-style:none;margin:0;padding:0">
1277 {shownItems.map((item) => {
1278 // Build a link to the relevant resource if we have a number
1279 let href: string | undefined;
1280 if (item.refNumber !== null) {
1281 const base = `/${item.repoOwner}/${item.repoName}`;
1282 if (item.kind === "pr_auto_merged") {
1283 href = `${base}/pulls/${item.refNumber}`;
1284 } else if (item.kind === "spec_shipped") {
1285 href = `${base}/issues/${item.refNumber}`;
1286 }
1287 }
1288 const color = AI_ACTIVITY_COLOR[item.kind];
1289 const icon = AI_ACTIVITY_ICON[item.kind];
1290 const kindLabel = AI_ACTIVITY_LABEL[item.kind];
1291 return (
1292 <li
1293 style="display:flex;align-items:center;gap:10px;padding:8px var(--space-4);border-bottom:1px solid var(--border);font-size:13px"
1294 >
1295 <span
1296 style={`font-size:15px;width:20px;text-align:center;flex-shrink:0;color:${color}`}
1297 aria-label={kindLabel}
1298 >
1299 {icon}
1300 </span>
1301 <span
1302 style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
1303 title={item.label}
1304 >
1305 <span style="color:var(--text-muted);font-size:11px;margin-right:4px">
1306 {kindLabel}
1307 </span>
1308 {href ? (
1309 <a
1310 href={href}
1311 style="font-weight:500;color:var(--text)"
1312 >
1313 {item.label}
1314 </a>
1315 ) : (
1316 <span style="font-weight:500">{item.label}</span>
1317 )}
1318 </span>
1319 <span
1320 style="font-size:11px;color:var(--text-muted);flex-shrink:0;white-space:nowrap"
1321 >
1322 <a
1323 href={`/${item.repoOwner}/${item.repoName}`}
1324 style="color:var(--text-muted)"
1325 >
1326 {item.repoName}
1327 </a>
1328 <span style="margin-left:4px">
1329 {formatRelative(item.occurredAt)}
1330 </span>
1331 </span>
1332 </li>
1333 );
1334 })}
1335 </ul>
1336
1337 {activity.items.length > 6 && (
1338 <div
1339 style="padding:8px var(--space-4);font-size:12px;color:var(--text-muted);border-top:1px solid var(--border)"
1340 >
1341 + {activity.items.length - 6} more action{activity.items.length - 6 === 1 ? "" : "s"} in the last hour
1342 </div>
1343 )}
1344 </>
1345 )}
1346 </div>
1347 );
1348};
1349
9271350/**
9281351 * Block L9 — pure formatter used by the dashboard widget AND tests.
9291352 * Turns the breakdown into the small stat-pill array shown under the
9301353