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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3163-a-migration-s-progress-page-follows-its → main 2 commits fork
3 files
changed +73
−32
src/web/pages/migrate.ts +29 −30
| @@ -392,38 +392,37 @@ function stageRows(job: MirrorJob): Html { | ||
| 392 | 392 | } |
| 393 | 393 | |
| 394 | 394 | /** |
| 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. | |
| 405 | 400 | */ |
| 406 | 401 | const migrateJobJs = /* js */ ` |
| 407 | 402 | (function () { |
| 408 | 403 | 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(); } | |
| 425 | 423 | } |
| 426 | setTimeout(tick, 2000); | |
| 424 | ['creating', 'fetching', 'labels', 'issues', 'pulls', 'comments', 'crew', 'done', 'failed'] | |
| 425 | .forEach(function (stage) { source.addEventListener('migrate.' + stage, patch); }); | |
| 427 | 426 | })(); |
| 428 | 427 | `; |
| 429 | 428 | |
| @@ -440,10 +439,9 @@ export function migrationJobPage(ctx: Ctx): Response { | ||
| 440 | 439 | return page({ |
| 441 | 440 | ctx, |
| 442 | 441 | title: `Migrating ${job.fullName}`, |
| 443 | ...(failed ? {} : { refresh: { seconds: 2 } }), | |
| 444 | scriptJs: migrateJobJs, | |
| 442 | ...(failed ? {} : { scriptJs: migrateJobJs }), | |
| 445 | 443 | 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}"> | |
| 447 | 445 | ${card(stageRows(job), { |
| 448 | 446 | head: html`${icon("download")} |
| 449 | 447 | <h2>${job.fullName}</h2>`, |
| @@ -466,6 +464,7 @@ export function migrationJobPage(ctx: Ctx): Response { | ||
| 466 | 464 | : html`<p class="muted small mt"> |
| 467 | 465 | This keeps going if you close the page — come back to this link |
| 468 | 466 | and it will say where it got to. |
| 467 | <noscript><a href="">Reload</a> to see the latest.</noscript> | |
| 469 | 468 | </p>`} |
| 470 | 469 | </div>`, |
| 471 | 470 | }); |
test/migrate.test.ts +44 −1
| @@ -37,7 +37,7 @@ import { repoDir } from "../src/git/repos.ts"; | ||
| 37 | 37 | import { serve, type Servers } from "../src/http/server.ts"; |
| 38 | 38 | import { createToken } from "../src/core/tokens.ts"; |
| 39 | 39 | 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"; | |
| 41 | 41 | import { forgetProviders } from "../src/origins/providers/index.ts"; |
| 42 | 42 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 43 | 43 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| @@ -574,6 +574,49 @@ describe("migrate", () => { | ||
| 574 | 574 | expect(getRepo(app.db, "alice", "copy")?.autonomy).toBe("autonomous"); |
| 575 | 575 | }); |
| 576 | 576 | |
| 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 | ||
| 577 | 620 | test("a link prefills the form and starts nothing", async () => { |
| 578 | 621 | const host = new URL(forgejo.url).host; |
| 579 | 622 | 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"; | ||
| 11 | 11 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| 12 | 12 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 13 | 13 | "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" }, | |
| 15 | 14 | "src/web/pages/browse.ts": { allow: ["refresh: {"], why: "mirror progress runs in a process that emits no events" }, |
| 16 | 15 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 17 | 16 | "src/web/fleet-graph.ts": { allow: ["setInterval"], why: "no fleet event yet; remove once it has one" }, |
new above
offline