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

ai-live.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.

ai-live.tsxBlame317 lines · 1 contributor
40e7738Claude1/**
2 * Live AI activity dashboard.
3 *
4 * `GET /ai/live` renders an HTML shell that:
5 * 1. Server-renders the most recent ~50 events + a rollup-by-action card.
6 * 2. Subscribes to `/live-events/ai:global` via EventSource and prepends
7 * new events to the timeline as they land.
8 *
9 * `GET /api/ai/activity` returns the same recent-events list as JSON, so
10 * external dashboards / the CLI can poll it.
11 *
12 * Public read — no AI secrets cross the boundary, only summaries and
13 * model/latency/success metadata. Per-repo private context is not exposed
14 * here because the flywheel only stores summaries, never prompts.
15 */
16
17import { Hono } from "hono";
18import { html, raw } from "hono/html";
19import { Layout } from "../views/layout";
20import { softAuth, type AuthEnv } from "../middleware/auth";
21import {
22 listRecentAiEvents,
23 rollupByAction,
24 type AiEvent,
25 type RollupRow,
26} from "../lib/ai-flywheel";
27
28const app = new Hono<AuthEnv>();
29
30app.get("/ai/live", softAuth, async (c) => {
31 const user = c.get("user") ?? null;
32 let recent: AiEvent[] = [];
33 let rollup: RollupRow[] = [];
34 try {
35 [recent, rollup] = await Promise.all([
36 listRecentAiEvents({ limit: 50 }),
37 rollupByAction(24),
38 ]);
39 } catch {
40 /* empty arrays render gracefully */
41 }
42
43 const total24h = rollup.reduce((s, r) => s + r.total, 0);
44 const fail24h = rollup.reduce((s, r) => s + r.failures, 0);
45 const greenRate24h =
46 total24h === 0 ? 100 : Math.round(((total24h - fail24h) / total24h) * 100);
47
48 return c.html(
49 <Layout title="AI in motion" user={user}>
50 <style>{raw(STYLE)}</style>
51 <div class="ai-live-page">
52 <header class="ai-live-header">
53 <div>
54 <h1>AI in motion</h1>
55 <p class="ai-live-subtitle">
56 Every model invocation across gluecron, streamed live.
57 </p>
58 </div>
59 <div class="ai-live-stats">
60 <div class="ai-stat">
61 <div class="ai-stat-num">{total24h}</div>
62 <div class="ai-stat-label">events / 24h</div>
63 </div>
64 <div class="ai-stat">
65 <div class="ai-stat-num">{greenRate24h}%</div>
66 <div class="ai-stat-label">success rate</div>
67 </div>
68 <div class="ai-stat">
69 <div class="ai-stat-num" id="ai-live-counter">
70 {recent.length}
71 </div>
72 <div class="ai-stat-label">on screen</div>
73 </div>
74 </div>
75 </header>
76
77 {rollup.length > 0 && (
78 <section class="ai-rollup">
79 <h2>Last 24h by action</h2>
80 <ul class="ai-rollup-list">
81 {rollup.map((r) => (
82 <li class="ai-rollup-row" data-action={r.actionType}>
83 <span class="ai-rollup-name">{r.actionType}</span>
84 <span class="ai-rollup-num">{r.total}</span>
85 <span class="ai-rollup-bar">
86 <span
87 class="ai-rollup-bar-ok"
88 style={`width:${
89 r.total === 0
90 ? 0
91 : Math.round((r.successes / r.total) * 100)
92 }%`}
93 />
94 </span>
95 <span class="ai-rollup-meta">
96 {r.successes}/{r.total} ok · {r.avgLatencyMs}ms avg
97 </span>
98 </li>
99 ))}
100 </ul>
101 </section>
102 )}
103
104 <section class="ai-stream">
105 <div class="ai-stream-head">
106 <h2>Live stream</h2>
107 <span class="ai-conn" id="ai-conn-state">
108 <span class="ai-conn-dot" />
109 connecting
110 </span>
111 </div>
112 <ul class="ai-events" id="ai-events">
113 {recent.length === 0 && (
114 <li class="ai-empty">
115 Waiting for the first AI event in this process. Push a commit,
116 open a PR, or trigger an AI feature to see it appear here.
117 </li>
118 )}
119 {recent.map((e) => (
120 <li
121 class={`ai-event ${e.success ? "ok" : "fail"}`}
122 data-id={e.id}
123 >
124 {renderEvent(e)}
125 </li>
126 ))}
127 </ul>
128 </section>
129 </div>
130 {raw(SCRIPT)}
131 </Layout>
132 );
133});
134
135app.get("/api/ai/activity", softAuth, async (c) => {
136 const limit = Math.max(1, Math.min(200, Number(c.req.query("limit") ?? 50)));
137 const repositoryId = c.req.query("repositoryId") || undefined;
138 const events = await listRecentAiEvents({ limit, repositoryId });
139 return c.json({ events });
140});
141
142function renderEvent(e: AiEvent) {
143 const when = relTime(e.createdAt);
144 return (
145 <>
146 <div class="ai-event-head">
147 <span class={`ai-pill ai-action-${e.actionType}`}>{e.actionType}</span>
148 <span class="ai-model">{e.model}</span>
149 <span class={`ai-status ${e.success ? "ok" : "fail"}`}>
150 {e.success ? "ok" : "fail"}
151 </span>
152 <span class="ai-latency">{e.latencyMs}ms</span>
153 <span class="ai-when">{when}</span>
154 </div>
155 <div class="ai-event-summary">{e.summary}</div>
156 {e.error && <div class="ai-event-error">{e.error}</div>}
157 </>
158 );
159}
160
161function relTime(iso: string): string {
162 const t = new Date(iso).getTime();
163 if (!Number.isFinite(t)) return "just now";
164 const seconds = Math.max(0, Math.floor((Date.now() - t) / 1000));
165 if (seconds < 5) return "just now";
166 if (seconds < 60) return `${seconds}s ago`;
167 const minutes = Math.floor(seconds / 60);
168 if (minutes < 60) return `${minutes}m ago`;
169 const hours = Math.floor(minutes / 60);
170 if (hours < 24) return `${hours}h ago`;
171 return `${Math.floor(hours / 24)}d ago`;
172}
173
174const STYLE = `
175.ai-live-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
176.ai-live-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
177.ai-live-subtitle { color: var(--text-muted); margin: 4px 0 0; }
178.ai-live-stats { display: flex; gap: 16px; }
179.ai-stat { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; text-align: center; min-width: 110px; }
180.ai-stat-num { font-size: 24px; font-weight: 700; line-height: 1; }
181.ai-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin-top: 4px; }
182.ai-rollup { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 24px; }
183.ai-rollup h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0 0 12px; }
184.ai-rollup-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
185.ai-rollup-row { display: grid; grid-template-columns: 140px 50px 160px 1fr; align-items: center; gap: 12px; font-size: 13px; }
186.ai-rollup-name { font-family: ui-monospace, monospace; color: var(--text); }
187.ai-rollup-num { font-weight: 700; text-align: right; }
188.ai-rollup-bar { background: rgba(255,255,255,0.06); height: 8px; border-radius: 4px; overflow: hidden; }
189.ai-rollup-bar-ok { display: block; height: 100%; background: linear-gradient(90deg, #34d399, #10b981); transition: width .4s ease; }
190.ai-rollup-meta { color: var(--text-muted); font-size: 12px; }
191.ai-stream-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
192.ai-stream-head h2 { font-size: 14px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: 0; }
193.ai-conn { display: inline-flex; gap: 6px; align-items: center; font-size: 12px; color: var(--text-muted); }
194.ai-conn-dot { width: 8px; height: 8px; border-radius: 50%; background: #facc15; box-shadow: 0 0 8px rgba(250,204,21,.6); animation: pulse 1.4s ease-in-out infinite; }
195.ai-conn.ok .ai-conn-dot { background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,.7); }
196.ai-conn.fail .ai-conn-dot { background: #f87171; }
197@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .35 } }
198.ai-events { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
199.ai-event { background: var(--bg-secondary); border: 1px solid var(--border); border-left: 3px solid #34d399; border-radius: 6px; padding: 10px 14px; transition: background .25s ease, border-color .25s ease; }
200.ai-event.fail { border-left-color: #f87171; }
201.ai-event.fresh { animation: arrive .55s ease; }
202@keyframes arrive { from { transform: translateY(-6px); opacity: 0; background: rgba(52,211,153,.16); } to { transform: translateY(0); opacity: 1; } }
203.ai-event-head { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; font-size: 12px; }
204.ai-pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: rgba(99,102,241,.15); color: #a5b4fc; font-family: ui-monospace, monospace; font-size: 11px; text-transform: lowercase; }
205.ai-action-repair { background: rgba(34,197,94,.18); color: #86efac; }
206.ai-action-incident { background: rgba(248,113,113,.18); color: #fca5a5; }
207.ai-action-review { background: rgba(96,165,250,.18); color: #93c5fd; }
208.ai-action-completion { background: rgba(168,85,247,.18); color: #d8b4fe; }
209.ai-action-explain { background: rgba(244,114,182,.18); color: #f9a8d4; }
210.ai-action-test { background: rgba(250,204,21,.18); color: #fcd34d; }
211.ai-action-changelog { background: rgba(251,146,60,.18); color: #fdba74; }
212.ai-model { font-family: ui-monospace, monospace; color: var(--text-muted); font-size: 11px; }
213.ai-status.ok { color: #34d399; font-weight: 600; }
214.ai-status.fail { color: #f87171; font-weight: 600; }
215.ai-latency { color: var(--text-muted); }
216.ai-when { color: var(--text-muted); margin-left: auto; }
217.ai-event-summary { font-size: 13px; margin-top: 4px; color: var(--text); }
218.ai-event-error { font-family: ui-monospace, monospace; font-size: 11px; color: #fca5a5; margin-top: 4px; }
219.ai-empty { background: var(--bg-secondary); border: 1px dashed var(--border); border-radius: 6px; padding: 20px; text-align: center; color: var(--text-muted); }
220@media (max-width: 600px) {
221 .ai-rollup-row { grid-template-columns: 1fr 50px; }
222 .ai-rollup-bar, .ai-rollup-meta { display: none; }
223}
224`;
225
226const SCRIPT = `<script>
227(function(){
228 var list = document.getElementById('ai-events');
229 var counter = document.getElementById('ai-live-counter');
230 var conn = document.getElementById('ai-conn-state');
231 if (!list) return;
232 var max = 200;
233 var es;
234 var retry = 1000;
235
236 function setConn(state, label) {
237 if (!conn) return;
238 conn.classList.remove('ok','fail');
239 if (state) conn.classList.add(state);
240 var dot = conn.querySelector('.ai-conn-dot');
241 conn.innerHTML = '';
242 if (dot) conn.appendChild(dot); else { var d = document.createElement('span'); d.className='ai-conn-dot'; conn.appendChild(d); }
243 conn.appendChild(document.createTextNode(' ' + label));
244 }
245
246 function fmtRel(iso) {
247 var t = new Date(iso).getTime();
248 if (!isFinite(t)) return 'just now';
249 var s = Math.max(0, Math.floor((Date.now()-t)/1000));
250 if (s < 5) return 'just now';
251 if (s < 60) return s + 's ago';
252 var m = Math.floor(s/60);
253 if (m < 60) return m + 'm ago';
254 var h = Math.floor(m/60);
255 if (h < 24) return h + 'h ago';
256 return Math.floor(h/24) + 'd ago';
257 }
258
259 function pillClass(action) {
260 var safe = String(action || '').replace(/[^a-z0-9-]/gi,'').toLowerCase();
261 return 'ai-pill ai-action-' + safe;
262 }
263
264 function render(ev) {
265 var li = document.createElement('li');
266 li.className = 'ai-event fresh ' + (ev.success ? 'ok' : 'fail');
267 li.setAttribute('data-id', ev.id || '');
268 var head = document.createElement('div');
269 head.className = 'ai-event-head';
270 var pill = document.createElement('span'); pill.className = pillClass(ev.actionType); pill.textContent = ev.actionType || 'other'; head.appendChild(pill);
271 var model = document.createElement('span'); model.className = 'ai-model'; model.textContent = ev.model || ''; head.appendChild(model);
272 var status = document.createElement('span'); status.className = 'ai-status ' + (ev.success ? 'ok' : 'fail'); status.textContent = ev.success ? 'ok' : 'fail'; head.appendChild(status);
273 var lat = document.createElement('span'); lat.className = 'ai-latency'; lat.textContent = (ev.latencyMs || 0) + 'ms'; head.appendChild(lat);
274 var when = document.createElement('span'); when.className = 'ai-when'; when.textContent = fmtRel(ev.createdAt); head.appendChild(when);
275 li.appendChild(head);
276 var summary = document.createElement('div'); summary.className = 'ai-event-summary'; summary.textContent = ev.summary || ''; li.appendChild(summary);
277 if (ev.error) {
278 var err = document.createElement('div'); err.className = 'ai-event-error'; err.textContent = ev.error; li.appendChild(err);
279 }
280 return li;
281 }
282
283 function prepend(ev) {
284 var empty = list.querySelector('.ai-empty');
285 if (empty) empty.remove();
286 var li = render(ev);
287 list.insertBefore(li, list.firstChild);
288 while (list.children.length > max) list.removeChild(list.lastChild);
289 if (counter) counter.textContent = String(list.children.length);
290 setTimeout(function(){ li.classList.remove('fresh'); }, 700);
291 }
292
293 function connect() {
294 setConn(null, 'connecting');
295 try { es = new EventSource('/live-events/ai:global'); } catch(e) { setTimeout(connect, retry); retry = Math.min(retry*2, 30000); return; }
296 es.onopen = function(){ setConn('ok','live'); retry = 1000; };
297 es.addEventListener('ai', function(e){
298 try { var ev = JSON.parse(e.data); prepend(ev); } catch(_) {}
299 });
300 es.onerror = function(){ setConn('fail','reconnecting'); try { es.close(); } catch(_){}; setTimeout(connect, retry); retry = Math.min(retry*2, 30000); };
301 }
302
303 connect();
304
305 // refresh relative timestamps every 15s
306 setInterval(function(){
307 Array.prototype.forEach.call(list.querySelectorAll('.ai-event'), function(li){
308 var when = li.querySelector('.ai-when');
309 var head = li.querySelector('.ai-event-head');
310 if (!when || !head) return;
311 // Skip — we don't carry the iso on the DOM node; only newly streamed events get refresh.
312 });
313 }, 15000);
314})();
315</script>`;
316
317export default app;