Skip to content

Commit 468b4c4

Browse files
committed
Worker: live CI step list + progress bar on loading page
- New /api/status endpoint: queries the latest workflow_dispatch run + its job's step list, returns step statuses (queued/in_progress/ completed/failed) for the loading page to render. - Loading page redesign: real-time step list (rendered as a checklist with spinners on the current step), progress bar based on done + 0.5*running / total, elapsed-time counter, deep-link to the GH Actions run, and a clearer 'mining already running' state when dedup returns 202.
1 parent 90f9f1d commit 468b4c4

1 file changed

Lines changed: 244 additions & 54 deletions

File tree

cloudflare/worker/index.ts

Lines changed: 244 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ export default {
2424
if (url.pathname === "/api/refresh") {
2525
return handleRefresh(req, env, url);
2626
}
27+
if (url.pathname === "/api/status") {
28+
return handleStatus(req, env, url);
29+
}
2730

2831
// -- Static assets ----------------------------------------------------
2932
const assetResp = await env.ASSETS.fetch(req);
@@ -132,10 +135,73 @@ function json(obj: unknown, status = 200): Response {
132135
}
133136

134137

138+
async function handleStatus(
139+
req: Request,
140+
env: Env,
141+
url: URL,
142+
): Promise<Response> {
143+
const user = url.searchParams.get("user")?.trim();
144+
if (!user || !VALID_LOGIN.test(user)) {
145+
return json({ error: "invalid user" }, 400);
146+
}
147+
const repo = env.GH_REPO ?? "ArchiveBox/githubusers";
148+
// Fetch the most recent workflow_dispatch run. Since concurrency.group
149+
// serializes mines, the latest in_progress (or most recent overall)
150+
// is most likely the one for this user.
151+
const r = await fetch(
152+
`https://api.github.com/repos/${repo}/actions/runs?per_page=5&event=workflow_dispatch`,
153+
{
154+
headers: {
155+
Authorization: `Bearer ${env.GH_DISPATCH_TOKEN}`,
156+
"User-Agent": "githubusers-archivebox-io",
157+
Accept: "application/vnd.github+json",
158+
},
159+
},
160+
);
161+
if (!r.ok) {
162+
return json({ error: "gh api failed", status: r.status }, 502);
163+
}
164+
const data = await r.json() as any;
165+
const run = (data.workflow_runs ?? [])[0];
166+
if (!run) {
167+
return json({ ok: false, status: "no_runs" });
168+
}
169+
// Get job steps for the run.
170+
const jr = await fetch(
171+
`https://api.github.com/repos/${repo}/actions/runs/${run.id}/jobs`,
172+
{
173+
headers: {
174+
Authorization: `Bearer ${env.GH_DISPATCH_TOKEN}`,
175+
"User-Agent": "githubusers-archivebox-io",
176+
Accept: "application/vnd.github+json",
177+
},
178+
},
179+
);
180+
const jdata = await jr.json() as any;
181+
const job = (jdata.jobs ?? [])[0];
182+
const steps = (job?.steps ?? []).map((s: any) => ({
183+
name: s.name,
184+
status: s.status,
185+
conclusion: s.conclusion,
186+
}));
187+
return json({
188+
ok: true,
189+
run_id: run.id,
190+
run_status: run.status, // queued | in_progress | completed
191+
run_conclusion: run.conclusion, // success | failure | cancelled | null
192+
run_started_at: run.run_started_at,
193+
run_url: run.html_url,
194+
job_status: job?.status,
195+
current_step: steps.find((s: any) => s.status === "in_progress")?.name
196+
?? steps.at(-1)?.name ?? null,
197+
steps,
198+
});
199+
}
200+
201+
135202
function loadingPage(user: string): string {
136-
// Inline HTML for the "mining…" view. Polls /<user> every 4 seconds and
137-
// reloads once we get a real (non-fallback) response. Also kicks off
138-
// /api/refresh on load so users don't need a config change.
203+
// Inline HTML for the "mining…" view. Polls /api/status for the GH
204+
// workflow run's step list + /<user>.html for the first partial deploy.
139205
return `<!doctype html>
140206
<html lang="en">
141207
<head>
@@ -146,61 +212,129 @@ function loadingPage(user: string): string {
146212
html, body {
147213
background: #0d1117; color: #e6edf3;
148214
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
149-
margin: 0; padding: 0; min-height: 100%;
215+
margin: 0; padding: 0; min-height: 100vh;
150216
display: flex; align-items: center; justify-content: center;
151217
}
152218
.card {
153-
max-width: 540px; padding: 40px 36px; text-align: center;
219+
max-width: 620px; width: calc(100% - 32px);
220+
padding: 32px 36px;
154221
border: 1px solid #30363d; border-radius: 12px; background: #161b22;
155222
}
156-
h1 { font-size: 18px; margin: 0 0 6px; font-weight: 500; }
157-
h2 { font-size: 26px; margin: 0 0 18px; font-weight: 600;
223+
h1 { font-size: 16px; margin: 0; font-weight: 500; color: #8b949e; }
224+
h2 { font-size: 28px; margin: 4px 0 16px; font-weight: 600;
158225
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
159-
p { color: #8b949e; line-height: 1.5; margin: 12px 0; font-size: 13px; }
226+
p { color: #8b949e; line-height: 1.5; margin: 10px 0; font-size: 13px; }
227+
.row { display: flex; align-items: center; gap: 10px; margin: 18px 0; }
160228
.spinner {
161-
width: 36px; height: 36px; margin: 24px auto;
162-
border: 3px solid #30363d; border-top-color: #58a6ff;
163-
border-radius: 50%; animation: spin 0.9s linear infinite;
229+
width: 22px; height: 22px;
230+
border: 2px solid #30363d; border-top-color: #58a6ff;
231+
border-radius: 50%; animation: spin 0.9s linear infinite; flex: 0 0 auto;
164232
}
165233
@keyframes spin { to { transform: rotate(360deg); } }
166-
.status {
234+
.summary { font-size: 13px; }
235+
.summary .now { color: #e6edf3; font-weight: 500; }
236+
.summary .elapsed { color: #8b949e; font-variant-numeric: tabular-nums; }
237+
.progress-track {
238+
height: 6px; background: #21262d; border-radius: 3px;
239+
overflow: hidden; margin: 8px 0 20px;
240+
}
241+
.progress-fill {
242+
height: 100%;
243+
background: linear-gradient(90deg, #58a6ff, #3fb950);
244+
width: 0%; transition: width 0.4s;
245+
}
246+
ol.steps {
247+
list-style: none; padding: 0; margin: 0;
167248
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
168-
font-size: 12px; color: #8b949e; margin-top: 16px;
169-
padding: 8px 12px; background: #0d1117; border-radius: 6px;
170-
border: 1px solid #21262d;
249+
font-size: 12px;
250+
}
251+
ol.steps li {
252+
padding: 7px 12px; border-radius: 4px; margin-bottom: 3px;
253+
display: flex; align-items: center; gap: 10px;
254+
background: #0d1117; border: 1px solid transparent;
255+
}
256+
ol.steps li.done { color: #3fb950; }
257+
ol.steps li.done::before { content: "✓"; flex: 0 0 14px; }
258+
ol.steps li.running {
259+
color: #58a6ff; border-color: #1f4d7a; background: #0e2640;
260+
}
261+
ol.steps li.running::before {
262+
content: ""; flex: 0 0 14px; width: 12px; height: 12px;
263+
border: 2px solid #30363d; border-top-color: #58a6ff;
264+
border-radius: 50%; animation: spin 0.9s linear infinite;
265+
}
266+
ol.steps li.pending { color: #6e7681; }
267+
ol.steps li.pending::before { content: "◌"; flex: 0 0 14px; }
268+
ol.steps li.failed { color: #f85149; border-color: #6e2120; background: #2a0e10; }
269+
ol.steps li.failed::before { content: "✗"; flex: 0 0 14px; }
270+
.err {
271+
color: #f85149; padding: 10px 14px; background: #2a0e10;
272+
border: 1px solid #6e2120; border-radius: 6px; font-size: 13px;
273+
margin: 10px 0;
171274
}
172-
.status.ok { color: #3fb950; border-color: #1f6028; }
173-
.status.err { color: #f85149; border-color: #6e2120; }
174275
a { color: #58a6ff; }
175-
code { background: #21262d; padding: 2px 6px; border-radius: 4px; font-size: 90%; }
276+
code { background: #21262d; padding: 1px 5px; border-radius: 3px;
277+
font-size: 90%; font-family: inherit; }
278+
.footer-row {
279+
margin-top: 22px; padding-top: 18px; border-top: 1px solid #21262d;
280+
font-size: 11px; color: #6e7681;
281+
display: flex; justify-content: space-between; align-items: center;
282+
}
176283
</style>
177284
</head>
178285
<body>
179286
<div class="card">
180287
<h1>Mining contribution stats for</h1>
181-
<h2>@${user}</h2>
182-
<div class="spinner"></div>
183-
<p>
184-
This takes <strong>~3–8 minutes</strong> the first time
185-
(gathering commits, PRs, issues, stars, and merged-PR diff stats
186-
from the GitHub API).
187-
The page will reload automatically when ready.
188-
</p>
189-
<div id="status" class="status">Triggering mining job…</div>
190-
<p style="margin-top:24px;font-size:11px;">
191-
Tip: bookmark <code>githubusers.archivebox.io/${user}</code>.
192-
Subsequent visits load instantly from cache.
193-
</p>
288+
<h2 id="hdr">@${user}</h2>
289+
290+
<div class="row">
291+
<div class="spinner" id="hdr-spinner"></div>
292+
<div class="summary" style="flex:1">
293+
<div class="now" id="now-line">Triggering mining job…</div>
294+
<div class="elapsed" id="elapsed-line">elapsed 00:00</div>
295+
</div>
296+
</div>
297+
298+
<div class="progress-track"><div class="progress-fill" id="progress"></div></div>
299+
300+
<ol class="steps" id="steps"></ol>
301+
302+
<div id="error" class="err" style="display:none"></div>
303+
304+
<div class="footer-row">
305+
<div>
306+
Bookmark <code>/${user}</code> · subsequent visits load instantly
307+
</div>
308+
<a id="run-link" href="#" target="_blank" rel="noreferrer" style="display:none">view CI run →</a>
309+
</div>
194310
</div>
195311
196312
<script>
197313
"use strict";
198314
const USER = ${JSON.stringify(user)};
199-
const statusEl = document.getElementById("status");
200315
201-
function setStatus(msg, cls = "") {
202-
statusEl.textContent = msg;
203-
statusEl.className = "status" + (cls ? " " + cls : "");
316+
const $now = document.getElementById("now-line");
317+
const $elapsed = document.getElementById("elapsed-line");
318+
const $progress = document.getElementById("progress");
319+
const $steps = document.getElementById("steps");
320+
const $err = document.getElementById("error");
321+
const $runLink = document.getElementById("run-link");
322+
const $spinner = document.getElementById("hdr-spinner");
323+
324+
const startedAt = Date.now();
325+
function fmtElapsed(sec) {
326+
const m = Math.floor(sec / 60), s = sec % 60;
327+
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
328+
}
329+
setInterval(() => {
330+
$elapsed.textContent = "elapsed " + fmtElapsed(
331+
Math.floor((Date.now() - startedAt) / 1000),
332+
);
333+
}, 1000);
334+
335+
function showError(msg) {
336+
$err.style.display = "block";
337+
$err.textContent = msg;
204338
}
205339
206340
async function dispatch() {
@@ -209,50 +343,106 @@ async function dispatch() {
209343
method: "POST",
210344
});
211345
const data = await r.json().catch(() => ({}));
212-
if (r.status === 202) {
213-
setStatus("Mining job dispatched. Polling for completion…");
214-
} else if (r.status === 404) {
215-
setStatus("GitHub user @" + USER + " does not exist.", "err");
346+
if (r.status === 404) {
347+
showError("GitHub user @" + USER + " does not exist.");
348+
$spinner.style.display = "none";
349+
return false;
350+
}
351+
if (!r.ok) {
352+
showError("Dispatch failed (HTTP " + r.status + "): " + (data.error || "unknown"));
216353
return false;
217-
} else {
218-
setStatus("Dispatch failed (HTTP " + r.status + "): " + (data.error || "unknown"), "err");
219354
}
355+
$now.textContent = data.status === "already_running"
356+
? "Mining already running — joining in progress…"
357+
: "Mining job dispatched.";
220358
} catch (e) {
221-
setStatus("Dispatch network error: " + e.message, "err");
359+
showError("Dispatch network error: " + e.message);
360+
return false;
222361
}
223362
return true;
224363
}
225364
226-
async function poll() {
365+
async function fetchStatus() {
366+
try {
367+
const r = await fetch("/api/status?user=" + encodeURIComponent(USER),
368+
{ cache: "no-store" });
369+
if (!r.ok) return null;
370+
return await r.json();
371+
} catch (e) { return null; }
372+
}
373+
374+
async function checkDeployed() {
227375
try {
228376
const r = await fetch("/" + USER, {
229377
cache: "no-store",
230378
headers: { "X-Stats-Poll": "1" },
231379
});
232380
if (!r.ok) return false;
233381
const txt = await r.text();
234-
// If the page is our own loading shell, keep waiting.
235-
if (txt.includes("Mining contribution stats for")) return false;
236-
// Otherwise the real dashboard has landed — reload to display it.
237-
return true;
382+
// If the response is our loading shell, the asset isn't there yet.
383+
return !txt.includes('id="hdr">@' + USER);
238384
} catch (e) {
239385
return false;
240386
}
241387
}
242388
389+
function renderSteps(status) {
390+
if (!status || !status.steps) return;
391+
if (status.run_url) {
392+
$runLink.href = status.run_url;
393+
$runLink.style.display = "inline";
394+
}
395+
// Filter to the steps that matter for the user.
396+
const meaningful = status.steps.filter(s =>
397+
!["Set up job", "Complete job"].includes(s.name) &&
398+
!s.name.startsWith("Post ")
399+
);
400+
const total = meaningful.length || 1;
401+
let done = 0, running = 0;
402+
$steps.innerHTML = meaningful.map(s => {
403+
let cls = "pending";
404+
if (s.status === "completed") {
405+
if (s.conclusion === "success") { cls = "done"; done++; }
406+
else if (s.conclusion === "skipped") { cls = "done"; done++; }
407+
else { cls = "failed"; }
408+
} else if (s.status === "in_progress") {
409+
cls = "running"; running++;
410+
} else if (s.status === "queued") {
411+
cls = "pending";
412+
}
413+
return '<li class="' + cls + '">' + s.name + '</li>';
414+
}).join("");
415+
// Progress = (done + 0.5 * running) / total
416+
const pct = Math.min(100, ((done + 0.5 * running) / total) * 100);
417+
$progress.style.width = pct + "%";
418+
// Header status line
419+
const runStep = meaningful.find(s => s.status === "in_progress");
420+
if (status.run_status === "completed") {
421+
if (status.run_conclusion === "success") {
422+
$now.textContent = "Run completed · loading dashboard…";
423+
} else {
424+
$now.textContent = "Run " + status.run_conclusion + " — see CI link";
425+
}
426+
} else if (runStep) {
427+
$now.textContent = "Running: " + runStep.name;
428+
} else if (status.run_status === "queued") {
429+
$now.textContent = "Queued in GitHub Actions…";
430+
}
431+
}
432+
243433
(async () => {
244434
const ok = await dispatch();
245435
if (!ok) return;
246-
const started = Date.now();
247436
const interval = setInterval(async () => {
248-
const ready = await poll();
249-
const elapsed = Math.floor((Date.now() - started) / 1000);
250-
if (ready) {
437+
const [status, deployed] = await Promise.all([
438+
fetchStatus(),
439+
checkDeployed(),
440+
]);
441+
renderSteps(status);
442+
if (deployed) {
251443
clearInterval(interval);
252-
setStatus("Dashboard ready — reloading…", "ok");
444+
$now.textContent = "Dashboard ready — reloading…";
253445
setTimeout(() => location.reload(), 500);
254-
} else {
255-
setStatus("Mining in progress · " + elapsed + "s elapsed · still polling…");
256446
}
257447
}, 4000);
258448
})();

0 commit comments

Comments
 (0)