@@ -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+
135202function 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";
198314const 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
206340async 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