Public: anyone can read this; the crew can change it.

A migration's progress page follows its stages over the stream, with no meta refresh #3179

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/3163-a-migration-s-progress-page-follows-its → main 2 commits fork

  • Merged. scout/3163-a-migration-s-progress-page-follows-its was merged into main as 08ec1ff.

Review

Sign in to review this change.
3 files changed +73 −32
src/web/pages/migrate.ts +29 −30
@@ -392,38 +392,37 @@ function stageRows(job: MirrorJob): Html {
392392 }
393393
394394 /**
395 * GET /migrate/jobs/:job — the clone, while it happens. It refreshes itself
396 * every two seconds until the job stops; done, it lands on the repository
397 * with what happened as the flash. A failure stays, with the reason and the
398 * way back to the form.
399 */
400 /**
401 * The job's page keeps itself current without reloading: every two seconds
402 * it asks for itself and swaps the stages in place; when the job is done
403 * the answer is the repository's page, and the browser goes there. Without
404 * a script the meta refresh does the same, a whole page at a time.
395 * The job's page follows its stages over the job's own event stream
396 * (`/api/v1/migrate/:job/stream`, the same owner check as the page) and
397 * patches the rows in place. A terminal stage reloads the page, which is
398 * then the repository (done, with the flash) or the failure that stands
399 * with its reason. Without a script, a plain Reload link.
405400 */
406401 const migrateJobJs = /* js */ `
407402 (function () {
408403 var box = document.getElementById('migrate-job');
409 if (!box || !window.fetch) return;
410 function tick() {
411 fetch(location.href, { headers: { accept: 'text/html' }, credentials: 'same-origin', redirect: 'follow' })
412 .then(function (r) {
413 if (r.redirected) { location.href = r.url; return null; }
414 return r.ok ? r.text() : null;
415 })
416 .then(function (text) {
417 if (text === null) return;
418 var fresh = new DOMParser().parseFromString(text.replace(/<(style)[^>]*>[\\s\\S]*?<\\/\\1>/gi, ''), 'text/html');
419 var theirs = fresh.getElementById('migrate-job');
420 if (theirs && theirs.innerHTML !== box.innerHTML) box.innerHTML = theirs.innerHTML;
421 if (!fresh.querySelector('meta[http-equiv="refresh"]')) return; // failed, or over: the page stopped asking
422 setTimeout(tick, 2000);
423 })
424 .catch(function () { setTimeout(tick, 4000); });
404 var job = box && box.getAttribute('data-job');
405 if (!job || typeof EventSource === 'undefined') return;
406 var order = ['creating', 'fetching', 'crew', 'done'];
407 var source = new EventSource('/api/v1/migrate/' + encodeURIComponent(job) + '/stream');
408 function patch(e) {
409 var data;
410 try { data = JSON.parse(e.data); } catch (_) { return; }
411 var at = order.indexOf(data.stage);
412 var rows = box.querySelectorAll('[data-stage-name]');
413 for (var i = 0; i < rows.length; i++) {
414 var row = rows[i];
415 var idx = order.indexOf(row.getAttribute('data-stage-name'));
416 var state = data.stage === 'failed' ? 'todo'
417 : data.stage === 'done' || idx < at ? 'done' : idx === at ? 'now' : 'todo';
418 row.setAttribute('data-state', state);
419 row.querySelector('.stage-dot').textContent = state === 'done' ? '\\u25cf' : state === 'now' ? '\\u25d0' : '\\u25cb';
420 row.querySelector('.stage-detail').textContent = state === 'now' && data.detail ? data.detail : '';
421 }
422 if (data.stage === 'done' || data.stage === 'failed') { source.close(); location.reload(); }
425423 }
426 setTimeout(tick, 2000);
424 ['creating', 'fetching', 'labels', 'issues', 'pulls', 'comments', 'crew', 'done', 'failed']
425 .forEach(function (stage) { source.addEventListener('migrate.' + stage, patch); });
427426 })();
428427 `;
429428
@@ -440,10 +439,9 @@ export function migrationJobPage(ctx: Ctx): Response {
440439 return page({
441440 ctx,
442441 title: `Migrating ${job.fullName}`,
443 ...(failed ? {} : { refresh: { seconds: 2 } }),
444 scriptJs: migrateJobJs,
442 ...(failed ? {} : { scriptJs: migrateJobJs }),
445443 body: html`${pageHead(`Migrating ${job.fullName}`)}
446 <div class="narrow-wide" id="migrate-job">
444 <div class="narrow-wide" id="migrate-job" data-job="${job.id}">
447445 ${card(stageRows(job), {
448446 head: html`${icon("download")}
449447 <h2>${job.fullName}</h2>`,
@@ -466,6 +464,7 @@ export function migrationJobPage(ctx: Ctx): Response {
466464 : html`<p class="muted small mt">
467465 This keeps going if you close the page — come back to this link
468466 and it will say where it got to.
467 <noscript><a href="">Reload</a> to see the latest.</noscript>
469468 </p>`}
470469 </div>`,
471470 });
test/migrate.test.ts +44 −1
@@ -37,7 +37,7 @@ import { repoDir } from "../src/git/repos.ts";
3737 import { serve, type Servers } from "../src/http/server.ts";
3838 import { createToken } from "../src/core/tokens.ts";
3939 import { createSession } from "../src/core/sessions.ts";
40 import { settled } from "../src/origins/jobs.ts";
40 import { advance, createJob, settled } from "../src/origins/jobs.ts";
4141 import { forgetProviders } from "../src/origins/providers/index.ts";
4242 import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
4343 import { gitClientEnv } from "./helpers/git-client.ts";
@@ -574,6 +574,49 @@ describe("migrate", () => {
574574 expect(getRepo(app.db, "alice", "copy")?.autonomy).toBe("autonomous");
575575 });
576576
577 test("the job page follows its stages over the stream, with no meta refresh", async () => {
578 const follow = async (end: Partial<Parameters<typeof advance>[1]>) => {
579 const job = createJob("", "octo/streamed", {
580 kind: "migrate",
581 actorId: alice.id,
582 });
583 const page = await (
584 await browser(aliceCookie, `/migrate/jobs/${job.id}`)
585 ).text();
586 expect(page).not.toContain('http-equiv="refresh"');
587 expect(page).toContain(`data-job="${job.id}"`);
588 expect(page).toContain("<noscript>");
589 expect(page).toContain("Reload");
590 const streamPath = `/api/v1/migrate/${job.id}/stream`;
591 // Another person's stream is not found, as their page is.
592 expect((await browser(bobCookie, streamPath)).status).toBe(404);
593 const res = await browser(aliceCookie, streamPath);
594 expect(res.status).toBe(200);
595 advance(job.id, { stage: "fetching", detail: "receiving 61%" });
596 advance(job.id, end);
597 // The stream ends itself on a terminal stage.
598 const text = await new Response(res.body).text();
599 return { job, text };
600 };
601
602 const ok = await follow({ stage: "done", detail: "migrated" });
603 expect(ok.text).toContain("event: migrate.creating");
604 expect(ok.text).toContain("event: migrate.fetching");
605 expect(ok.text).toContain("event: migrate.done");
606
607 const bad = await follow({ stage: "failed", error: "clone went wrong" });
608 expect(bad.text).toContain("event: migrate.fetching");
609 expect(bad.text).toContain("event: migrate.failed");
610 expect(bad.text).toContain("clone went wrong");
611 // Failed is a page that stands, with the reason, and no stream of its own.
612 const stands = await (
613 await browser(aliceCookie, `/migrate/jobs/${bad.job.id}`)
614 ).text();
615 expect(stands).toContain("clone went wrong");
616 expect(stands).not.toContain("EventSource");
617 expect(stands).not.toContain('http-equiv="refresh"');
618 });
619
577620 test("a link prefills the form and starts nothing", async () => {
578621 const host = new URL(forgejo.url).host;
579622 expect(host).toContain(":"); // the fake forge has a port; the label would not
test/no-polling.test.ts −1
@@ -11,7 +11,6 @@ import { Glob } from "bun";
1111 const ALLOWED: Record<string, { allow: string[]; why: string }> = {
1212 "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" },
1313 "src/web/layout.ts": { allow: ['http-equiv="refresh"'], why: "defines the refresh option and its meta tag; callers are listed here" },
14 "src/web/pages/migrate.ts": { allow: ["refresh: {", 'http-equiv="refresh"'], why: "migrate progress runs in a process that emits no events" },
1514 "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" },
1615 "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" },
1716 "src/web/fleet-graph.ts": { allow: ["setInterval"], why: "no fleet event yet; remove once it has one" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs