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

demo.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.

demo.tsxBlame409 lines · 1 contributor
52ad8b1Claude1/**
2 * Block L3 — public `/demo` landing page + companion JSON endpoints.
3 *
4 * Anonymous-friendly. Demonstrates Gluecron's AI features in real time by
5 * surfacing live counts off the audit log + `pr_comments` table for the
6 * seeded demo repos.
7 *
8 * Routes mounted from `src/app.tsx`. Must come BEFORE `routes/admin.tsx`
9 * in mount order so this handler wins over the legacy `/demo` redirect.
10 *
11 * GET /demo → SSR landing page (graceful no-JS,
12 * polls JSON endpoints every 30s
13 * when JS is available)
14 * GET /api/v2/demo/activity → combined activity feed JSON
15 * GET /api/v2/demo/queued → queued ai:build issues JSON
16 * GET /api/v2/demo/merges → recent auto-merges JSON
17 * GET /api/v2/demo/reviews → recent AI reviews JSON
18 *
19 * All JSON endpoints serve `Cache-Control: public, max-age=30` so the demo
20 * page is cheap to refresh and external dashboards can embed safely.
21 */
22
23import { Hono } from "hono";
24import { Layout } from "../views/layout";
25import { softAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { DEMO_USERNAME } from "../lib/demo-seed";
28import {
29 countAiReviewsSince,
30 listDemoActivityFeed,
31 listQueuedAiBuildIssues,
32 listRecentAiReviews,
33 listRecentAutoMerges,
34 type DemoActivityEntry,
35} from "../lib/demo-activity";
36
37const app = new Hono<AuthEnv>();
38
39const POLL_INTERVAL_MS = 30_000;
40
41function jsonCacheHeaders(): Record<string, string> {
42 return {
43 "Content-Type": "application/json; charset=utf-8",
44 "Cache-Control": "public, max-age=30",
45 };
46}
47
48// ───────────────────────────────────────────────────────────────────
49// JSON endpoints — cheap, public, cacheable.
50// ───────────────────────────────────────────────────────────────────
51
52app.get("/api/v2/demo/activity", async (c) => {
53 const feed = await listDemoActivityFeed(20);
54 return new Response(
55 JSON.stringify({
56 entries: feed.map((e) => ({
57 kind: e.kind,
58 repo: e.repo,
59 ref: e.ref,
60 at: e.at.toISOString(),
61 })),
62 }),
63 { status: 200, headers: jsonCacheHeaders() }
64 );
65});
66
67app.get("/api/v2/demo/queued", async (c) => {
68 const items = await listQueuedAiBuildIssues(5);
69 return new Response(
70 JSON.stringify({
71 items: items.map((i) => ({
72 repo: i.repo,
73 number: i.number,
74 title: i.title,
75 createdAt: i.createdAt.toISOString(),
76 })),
77 }),
78 { status: 200, headers: jsonCacheHeaders() }
79 );
80});
81
82app.get("/api/v2/demo/merges", async (c) => {
83 const items = await listRecentAutoMerges(5, 24);
84 return new Response(
85 JSON.stringify({
86 items: items.map((m) => ({
87 repo: m.repo,
88 number: m.number,
89 title: m.title,
90 mergedAt: m.mergedAt.toISOString(),
91 })),
92 }),
93 { status: 200, headers: jsonCacheHeaders() }
94 );
95});
96
97app.get("/api/v2/demo/reviews", async (c) => {
98 const [items, count] = await Promise.all([
99 listRecentAiReviews(5, 24),
100 countAiReviewsSince(24),
101 ]);
102 return new Response(
103 JSON.stringify({
104 count,
105 items: items.map((r) => ({
106 repo: r.repo,
107 prNumber: r.prNumber,
108 commentSnippet: r.commentSnippet,
109 createdAt: r.createdAt.toISOString(),
110 })),
111 }),
112 { status: 200, headers: jsonCacheHeaders() }
113 );
114});
115
116// ───────────────────────────────────────────────────────────────────
117// HTML landing page.
118// ───────────────────────────────────────────────────────────────────
119
120function escapeHtml(s: string): string {
121 return String(s).replace(/[&<>"']/g, (c) =>
122 c === "&"
123 ? "&amp;"
124 : c === "<"
125 ? "&lt;"
126 : c === ">"
127 ? "&gt;"
128 : c === '"'
129 ? "&quot;"
130 : "&#39;"
131 );
132}
133
134function relativeTime(d: Date): string {
135 const ms = Date.now() - d.getTime();
136 const s = Math.max(0, Math.floor(ms / 1000));
137 if (s < 60) return `${s}s ago`;
138 const m = Math.floor(s / 60);
139 if (m < 60) return `${m}m ago`;
140 const h = Math.floor(m / 60);
141 if (h < 48) return `${h}h ago`;
142 const days = Math.floor(h / 24);
143 return `${days}d ago`;
144}
145
146function activityLabel(kind: DemoActivityEntry["kind"]): string {
147 switch (kind) {
148 case "auto_merge.merged":
149 return "auto-merged";
150 case "ai_build.dispatched":
151 return "AI-build dispatched";
152 case "ai_review.posted":
153 return "AI review posted";
154 }
155}
156
157app.get("/demo", softAuth, async (c) => {
158 const user = c.get("user") ?? null;
159
160 // Server-side render the initial snapshot. The JS poller refreshes it
161 // every 30s but the no-JS experience still works.
162 const [queued, merges, reviewsCountAndList, feed] = await Promise.all([
163 listQueuedAiBuildIssues(5),
164 listRecentAutoMerges(5, 24),
165 Promise.all([listRecentAiReviews(5, 24), countAiReviewsSince(24)]),
166 listDemoActivityFeed(20),
167 ]);
168 const [reviews, reviewCount] = reviewsCountAndList;
169
170 const demoUserUrl = `/${DEMO_USERNAME}`;
171 const demoRepos: { name: string; tagline: string }[] = [
172 { name: "hello-python", tagline: "Tiny Python app — labels, issues, gates." },
173 { name: "todo-api", tagline: "Hono todo API — PRs, AI review, auto-merge." },
174 { name: "design-docs", tagline: "ADRs + architecture docs." },
175 ];
176
177 // Poller — refreshes the four tiles every 30s. Plain vanilla JS, no
178 // framework. Mirrors the same pattern as `src/lib/sse-client.ts`.
179 const pollerScript = `
180(function(){try{
181 var INTERVAL=${POLL_INTERVAL_MS};
182 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
183 function rel(iso){try{var ms=Date.now()-new Date(iso).getTime();var s=Math.max(0,Math.floor(ms/1000));if(s<60)return s+'s ago';var m=Math.floor(s/60);if(m<60)return m+'m ago';var h=Math.floor(m/60);if(h<48)return h+'h ago';return Math.floor(h/24)+'d ago';}catch(e){return '';}}
184 function pollQueued(){fetch('/api/v2/demo/queued').then(function(r){return r.json();}).then(function(d){
185 var el=document.getElementById('tile-queued-list');if(!el)return;
186 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No queued AI builds — quiet right now.</li>';return;}
187 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/issues/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join('');
188 }).catch(function(){});}
189 function pollMerges(){fetch('/api/v2/demo/merges').then(function(r){return r.json();}).then(function(d){
190 var el=document.getElementById('tile-merges-list');if(!el)return;
191 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No auto-merges in the last 24h.</li>';return;}
192 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.number+'">#'+i.number+' '+esc(i.title)+'</a> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.mergedAt)+'</span></li>';}).join('');
193 }).catch(function(){});}
194 function pollReviews(){fetch('/api/v2/demo/reviews').then(function(r){return r.json();}).then(function(d){
195 var c=document.getElementById('tile-reviews-count');if(c)c.textContent=String(d.count||0);
196 var el=document.getElementById('tile-reviews-list');if(!el)return;
197 if(!d.items||d.items.length===0){el.innerHTML='<li class="demo-empty">No AI reviews in the last 24h.</li>';return;}
198 el.innerHTML=d.items.map(function(i){return '<li><a href="/${DEMO_USERNAME}/'+esc(i.repo)+'/pulls/'+i.prNumber+'">#'+i.prNumber+'</a> <span class="demo-snippet">'+esc(i.commentSnippet)+'</span> <span class="demo-meta">'+esc(i.repo)+' · '+rel(i.createdAt)+'</span></li>';}).join('');
199 }).catch(function(){});}
200 function pollFeed(){fetch('/api/v2/demo/activity').then(function(r){return r.json();}).then(function(d){
201 var el=document.getElementById('demo-feed-list');if(!el)return;
202 if(!d.entries||d.entries.length===0){el.innerHTML='<li class="demo-empty">Quiet right now — push a commit to a demo repo to see live updates.</li>';return;}
203 el.innerHTML=d.entries.map(function(e){var label=e.kind==='auto_merge.merged'?'auto-merged':(e.kind==='ai_build.dispatched'?'AI-build dispatched':'AI review posted');var path=e.ref.type==='pr'?'pulls':'issues';return '<li><span class="demo-kind demo-kind-'+esc(e.kind.replace(/\\./g,'-'))+'">'+esc(label)+'</span> <a href="/${DEMO_USERNAME}/'+esc(e.repo)+'/'+path+'/'+e.ref.number+'">'+esc(e.repo)+' #'+e.ref.number+'</a> <span class="demo-meta">'+rel(e.at)+'</span></li>';}).join('');
204 }).catch(function(){});}
205 function tick(){pollQueued();pollMerges();pollReviews();pollFeed();}
206 // Initial refresh after a short delay so the SSR snapshot stays visible
207 // a beat — keeps the page from "flashing" identical content on load.
208 setInterval(tick,INTERVAL);
209}catch(e){}})();
210`.trim();
211
212 return c.html(
213 <Layout title="Live demo" user={user}>
214 <style dangerouslySetInnerHTML={{ __html: DEMO_CSS }} />
215 <div class="demo-page">
216 <div class="demo-hero">
217 <div class="demo-hero-inner">
218 <div class="eyebrow">
219 <span class="demo-pulse" />
220 Live · pulled from production · refreshes every 30s
221 </div>
222 <h1 class="demo-title">
223 Watch Claude <span class="gradient-text">build software, live.</span>
224 </h1>
225 <p class="demo-sub">
226 Every tile below is real audit data from the seeded demo repos.
227 File an issue tagged <code>ai:build</code> and the gluecron autopilot
228 opens a PR. Land an AI review. Merge it automatically when gates
229 go green. No human in the loop for the routine cases.
230 </p>
231 </div>
232 <div class="demo-hero-cta">
233 <a href="/register" class="btn btn-primary btn-lg">
234 Sign up free <span aria-hidden="true">{"→"}</span>
235 </a>
236 </div>
237 </div>
238
239 <div class="demo-tiles">
240 <section class="demo-tile" aria-labelledby="tile-queued-h">
241 <h2 id="tile-queued-h" class="demo-tile-title">
242 Issues queued for AI build
243 </h2>
244 <ul id="tile-queued-list" class="demo-list">
245 {queued.length === 0 ? (
246 <li class="demo-empty">No queued AI builds — quiet right now.</li>
247 ) : (
248 queued.map((i) => (
249 <li>
250 <a href={`/${DEMO_USERNAME}/${i.repo}/issues/${i.number}`}>
251 #{i.number} {i.title}
252 </a>{" "}
253 <span class="demo-meta">
254 {i.repo} · {relativeTime(i.createdAt)}
255 </span>
256 </li>
257 ))
258 )}
259 </ul>
260 </section>
261
262 <section class="demo-tile" aria-labelledby="tile-merges-h">
263 <h2 id="tile-merges-h" class="demo-tile-title">
264 PRs auto-merged in the last 24h
265 </h2>
266 <ul id="tile-merges-list" class="demo-list">
267 {merges.length === 0 ? (
268 <li class="demo-empty">
269 No auto-merges in the last 24h.
270 </li>
271 ) : (
272 merges.map((m) => (
273 <li>
274 <a href={`/${DEMO_USERNAME}/${m.repo}/pulls/${m.number}`}>
275 #{m.number} {m.title}
276 </a>{" "}
277 <span class="demo-meta">
278 {m.repo} · {relativeTime(m.mergedAt)}
279 </span>
280 </li>
281 ))
282 )}
283 </ul>
284 </section>
285
286 <section class="demo-tile" aria-labelledby="tile-reviews-h">
287 <h2 id="tile-reviews-h" class="demo-tile-title">
288 AI reviews posted today
289 </h2>
290 <div class="demo-bigcount">
291 <span id="tile-reviews-count">{reviewCount}</span>
292 <span class="demo-bigcount-label">in the last 24h</span>
293 </div>
294 <ul id="tile-reviews-list" class="demo-list demo-list-small">
295 {reviews.length === 0 ? (
296 <li class="demo-empty">
297 No AI reviews in the last 24h.
298 </li>
299 ) : (
300 reviews.map((r) => (
301 <li>
302 <a href={`/${DEMO_USERNAME}/${r.repo}/pulls/${r.prNumber}`}>
303 #{r.prNumber}
304 </a>{" "}
305 <span class="demo-snippet">{r.commentSnippet}</span>{" "}
306 <span class="demo-meta">
307 {r.repo} · {relativeTime(r.createdAt)}
308 </span>
309 </li>
310 ))
311 )}
312 </ul>
313 </section>
314 </div>
315
316 <section class="demo-repos" aria-labelledby="demo-repos-h">
317 <h2 id="demo-repos-h" class="demo-section-title">
318 Dig into the demo repos
319 </h2>
320 <div class="demo-repos-grid">
321 {demoRepos.map((r) => (
322 <a class="demo-repo-card" href={`${demoUserUrl}/${r.name}`}>
323 <div class="demo-repo-name">
324 {DEMO_USERNAME}/{r.name}
325 </div>
326 <div class="demo-repo-tag">{r.tagline}</div>
327 </a>
328 ))}
329 </div>
330 </section>
331
332 <section class="demo-feed" aria-labelledby="demo-feed-h">
333 <h2 id="demo-feed-h" class="demo-section-title">
334 Live activity
335 </h2>
336 <ul id="demo-feed-list" class="demo-feed-list">
337 {feed.length === 0 ? (
338 <li class="demo-empty">
339 Quiet right now — push a commit to a demo repo to see live updates.
340 </li>
341 ) : (
342 feed.map((e) => {
343 const path = e.ref.type === "pr" ? "pulls" : "issues";
344 return (
345 <li>
346 <span
347 class={`demo-kind demo-kind-${e.kind.replace(/\./g, "-")}`}
348 >
349 {activityLabel(e.kind)}
350 </span>{" "}
351 <a href={`/${DEMO_USERNAME}/${e.repo}/${path}/${e.ref.number}`}>
352 {e.repo} #{e.ref.number}
353 </a>{" "}
354 <span class="demo-meta">{relativeTime(e.at)}</span>
355 </li>
356 );
357 })
358 )}
359 </ul>
360 </section>
361 </div>
362 <script dangerouslySetInnerHTML={{ __html: pollerScript }} />
363 </Layout>
364 );
365});
366
367// Minimal CSS, scoped under .demo-page so it doesn't bleed into other views.
368const DEMO_CSS = `
369.demo-page { max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; }
370.demo-hero { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; justify-content: space-between; margin-bottom: 32px; }
371.demo-hero-inner { flex: 1 1 480px; }
372.demo-pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #34d399; box-shadow: 0 0 8px rgba(52,211,153,0.7); margin-right: 8px; vertical-align: middle; animation: demo-pulse 1.6s infinite; }
373@keyframes demo-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
374.demo-title { font-size: 36px; line-height: 1.1; margin: 12px 0 16px; }
375.demo-sub { color: var(--text-muted); max-width: 640px; font-size: 15px; line-height: 1.55; }
376.demo-sub code { background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px; font-size: 13px; }
377.demo-hero-cta { flex: 0 0 auto; }
378.demo-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 32px; }
379.demo-tile { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
380.demo-tile-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin: 0 0 12px; }
381.demo-list { list-style: none; margin: 0; padding: 0; font-size: 14px; line-height: 1.5; }
382.demo-list li { padding: 6px 0; border-bottom: 1px solid var(--border); }
383.demo-list li:last-child { border-bottom: 0; }
384.demo-list-small li { font-size: 13px; }
385.demo-list a { color: var(--text-strong); text-decoration: none; }
386.demo-list a:hover { text-decoration: underline; }
387.demo-meta { color: var(--text-muted); font-size: 12px; }
388.demo-snippet { color: var(--text-muted); font-style: italic; font-size: 12px; }
389.demo-empty { color: var(--text-muted); font-size: 13px; padding: 6px 0; }
390.demo-bigcount { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
391.demo-bigcount span:first-child { font-size: 32px; font-weight: 700; color: var(--text-strong); }
392.demo-bigcount-label { color: var(--text-muted); font-size: 12px; }
393.demo-section-title { font-size: 18px; margin: 16px 0 12px; }
394.demo-repos { margin-bottom: 32px; }
395.demo-repos-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
396.demo-repo-card { display: block; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; text-decoration: none; color: inherit; transition: border-color 0.15s; }
397.demo-repo-card:hover { border-color: var(--accent, #8c6dff); }
398.demo-repo-name { font-weight: 600; font-size: 14px; color: var(--text-strong); margin-bottom: 4px; }
399.demo-repo-tag { font-size: 12px; color: var(--text-muted); }
400.demo-feed { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; }
401.demo-feed-list { list-style: none; margin: 0; padding: 0; font-size: 13px; line-height: 1.6; }
402.demo-feed-list li { padding: 4px 0; }
403.demo-kind { display: inline-block; padding: 1px 7px; border-radius: 9999px; font-size: 11px; font-weight: 500; }
404.demo-kind-auto_merge-merged, .demo-kind-auto-merge-merged { background: rgba(52,211,153,0.12); color: #34d399; }
405.demo-kind-ai_build-dispatched, .demo-kind-ai-build-dispatched { background: rgba(140,109,255,0.15); color: #8c6dff; }
406.demo-kind-ai_review-posted, .demo-kind-ai-review-posted { background: rgba(54,197,214,0.15); color: #36c5d6; }
407`;
408
409export default app;