Public: anyone can read this; the crew can change it.
A migration's progress stages draw §5's row and badge, on the server and in the live update #4637
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4595-a-migration-s-progress-stages-draw-5-s-r → main 11 commits fork 7 threads · 7 unresolved
docs/DESIGN.md +2 −2
| @@ -182,7 +182,7 @@ specification. | ||
| 182 | 182 | | **where strip** `whereStrip()` (`scope.ts`) | `.where > .where-place` | `data-state=reading\|present\|absent` — accent dot, success dot, faint dot — the dot is the place's `aria-hidden` glyph span, its aside `.muted` | three places, always in this order and these words; a place is a link where one exists, and the third is the pin toggle | `● codeberg.org · ● workbench · ◆ this machine` | |
| 183 | 183 | | **live-browse banner** `liveBanner()` | `.live-note` | — | only on a live browse, above the content, one sentence | one accent line under the tab row | |
| 184 | 184 | | **where-it-lives block** | `.where-block` | — | origin, last fetch, Sync now and where pushes land, as one block in the About rail | the same four lines under a rule | |
| 185 | | **mirror stages** `stageList()` | `.stages > li` | `data-state=done\|now\|failed\|todo` | the stage order is the markup order; a script updates it in place and goes to the mirror on done; without JavaScript the same markup renders and a `<noscript>` Reload link re-reads it. Nothing here refreshes itself: no `<meta refresh>`, no timer | a checklist with `●`/`◐`/`○` | | |
| 185 | | **mirror stages** `stageList()` | `ol.list.stages[aria-live=polite] > li > .row` | `data-state=done\|now\|todo\|failed` (failed: the stage the job stopped on, a danger "Failed" badge) | the stage order is the markup order; a script updates it in place and goes to the mirror on done; without JavaScript the same markup renders and a `<noscript>` Reload link re-reads it. Nothing here refreshes itself: no `<meta refresh>`, no timer | a checklist with `●`/`◐`/`○` | | |
| 186 | 186 | | **origin chip** (`chrome.ts`) | `.repo-origin > .origin-chip` | the forge's mark: `github` / `forgejo` / `gitea` | the older one-line form, still used off the repo header | one `mirrored from owner/name @ host` fact line | |
| 187 | 187 | | **origin state badge** `badge()` | `.badge` | `success` healthy · `danger` needs re-auth · `attn` error | the error message sits under the row, never inside the badge | the same three words beside the row | |
| 188 | 188 | | **imported author** `originTag()` | `.byline.origin-by` | the origin's mark instead of an avatar | never a link to `/name` — that would be a different person | `▲ login` | |
| @@ -395,7 +395,7 @@ gone, so this list only shrinks. | ||
| 395 | 395 | A `.field` directly inside a `.bar` drops its bottom margin and the bar aligns to the end, so a labelled input and its button share a baseline |
| 396 | 396 | - **`style/components.ts`** — parts of the §5 components that the table does not spell out — the palette's list, the menu's items, a dialog's head and foot, comments, the theme picker — and a few small helpers beside them: `.cat` `.clone` `.clone-text` `.comment` `.comment-body` `.comment-form` `.dash` `.dialog-body` `.dialog-foot` `.dialog-head` `.empty-title` `.error-note` `.field-row` `.form-actions` `.hex` `.ico` `.ico-loader` `.list` `.menu-item` `.menu-panel` `.menu-sep` `.menu-title` `.palette-empty` `.palette-foot` `.palette-group` `.palette-heading` `.palette-input` `.palette-item` `.palette-list` `.passkey` `.passkey-off` `.passkey-on` `.preview` `.sep` `.split` `.sprite` `.swatch` `.table-scroll` `.tell-here` `.theme-apply` `.theme-option` `.tick` `.timeline` `.tl-dot` `.toast-body` `.topbar-search` |
| 397 | 397 | - **`style/components.ts`, settings rows** — a list whose rows are settings (limits, capacity): the `<ul class="list setting-list">` lets title and meta wrap instead of ellipsizing, and a row's controls take their own line under 640px. `.row-fields` is the form in that row's trail (input, Save, Default): always visible, never hover-gated, wrapping under the text before it would clip. Use both for any page whose rows show a name, a current value, and a control to change it; use `.row-actions` only for secondary actions (delete, wake, pin, revoke). Flags, telemetry, cloud, people, crew roles and subscription headroom/budget use `.row-fields`. Classes: `.row-fields` `.setting-list` |
| 398 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: `.bform` `.bgrid` `.bmanage` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.commit-body` `.commit-head` `.domain-records` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.frozen` `.frozen-form` `.frozen-sentence` `.headroom` `.issue-head` `.issue-meta` `.mirror-form` `.number` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.stage-detail` `.stage-dot` `.steps` `.sub` `.sys-pair` `.table-wrap` `.tok` `.where-sep` | |
| 398 | - **`style/content.ts`, page furniture** — what frames a page's content: page and repo heads, the front page and its tiles, explore, backups, boards, the fleet chart's series colours, lineage: `.bform` `.bgrid` `.bmanage` `.bp-h2` `.bp-h3` `.bp-w2` `.bp-w3` `.break` `.bt-bar` `.bt-grid` `.bt-group` `.bt-hist` `.bt-name` `.bt-procs` `.bt-row` `.bt-val` `.commit-body` `.commit-head` `.domain-records` `.fg-s0` `.fg-s1` `.fg-s2` `.fg-s3` `.fg-s4` `.fg-s5` `.fg-s6` `.fg-s7` `.filters` `.flag-form` `.front-admin` `.front-controls` `.front-crew` `.front-featured` `.front-featured-body` `.front-featured-look` `.front-issues` `.front-live` `.front-more` `.front-pick` `.frozen` `.frozen-form` `.frozen-sentence` `.headroom` `.issue-head` `.issue-meta` `.mirror-form` `.number` `.origin-mark` `.origin-name` `.origin-row` `.owner` `.page-head` `.path-head` `.policy-line` `.qf` `.repo-actions` `.repo-audience` `.repo-desc` `.repo-layout` `.repo-lineage` `.repo-title` `.search` `.slash` `.steps` `.sub` `.sys-pair` `.table-wrap` `.tok` `.where-sep` | |
| 399 | 399 | - **`style/content.ts`, code view** — the source view and the syntax-highlight token classes `highlight-html.ts` builds as `tok-${kind}`: `.blob-view` `.ln` `.tok-attribute` `.tok-comment` `.tok-constant` `.tok-decorator` `.tok-deleted` `.tok-emphasis` `.tok-function` `.tok-heading` `.tok-inserted` `.tok-keyword` `.tok-link` `.tok-meta` `.tok-number` `.tok-operator` `.tok-plain` `.tok-property` `.tok-punctuation` `.tok-regex` `.tok-string` `.tok-tag` `.tok-type` `.tok-variable` |
| 400 | 400 | - **`style/content.ts`, prose (markdown)** — table cell alignment, built by `markdown.ts` as `ta-${align}`: `.ta-c` `.ta-r` |
| 401 | 401 | - **`style/content.ts`, diff viewer** — the diff table: its gutters, line kinds, file head and inline review comments: `.add` `.del` `.dh` `.diff` `.diff-body` `.diff-head` `.diff-note` `.diff-path` `.diff-stat` `.diff-stub` `.diff-summary` `.diff-table` `.dl` `.dl-review` `.dn` `.dn-comment` `.nonl` `.rc-hidden` `.rc-resolve` `.rc-resolved` `.wd` |
src/origins/jobs.ts +3
| @@ -69,6 +69,8 @@ export interface MirrorJob { | ||
| 69 | 69 | readonly repo?: Repo; |
| 70 | 70 | /** Set on `failed`: a scrubbed reason, safe to show and to log. */ |
| 71 | 71 | readonly error?: string; |
| 72 | /** Set on `failed`: the stage the job was on when it stopped. */ | |
| 73 | readonly failedAt?: MirrorStage; | |
| 72 | 74 | } |
| 73 | 75 | |
| 74 | 76 | export type JobListener = (job: MirrorJob) => void; |
| @@ -158,6 +160,7 @@ export function advance( | ||
| 158 | 160 | ...current, |
| 159 | 161 | ...patch, |
| 160 | 162 | stage, |
| 163 | ...(stage === "failed" ? { failedAt: current.stage } : {}), | |
| 161 | 164 | ...(terminal(stage) ? { finishedAt: new Date().toISOString() } : {}), |
| 162 | 165 | }; |
| 163 | 166 | jobs.set(jobId, next); |
src/web/client.ts +8 −4
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * behaviour that a page depends on to be usable. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { STAGE_BADGE_JS } from "./stage-row.ts"; | |
| 13 | 14 | import { paletteHelpersJs } from "./palette.ts"; |
| 14 | 15 | |
| 15 | 16 | /** |
| @@ -105,7 +106,7 @@ export const mirrorJs = /* js */ ` | ||
| 105 | 106 | |
| 106 | 107 | var rows = Array.prototype.slice.call(list.querySelectorAll('li[data-stage-name]')); |
| 107 | 108 | var order = rows.map(function (li) { return li.getAttribute('data-stage-name'); }); |
| 108 | var glyph = { done: '\\u25CF', now: '\\u25D0', failed: '\\u2715', todo: '\\u25CB' }; | |
| 109 | var badges = ${STAGE_BADGE_JS}; | |
| 109 | 110 | |
| 110 | 111 | function paint(stage, detail) { |
| 111 | 112 | list.setAttribute('data-stage', stage); |
| @@ -116,9 +117,12 @@ export const mirrorJs = /* js */ ` | ||
| 116 | 117 | : i === there ? (stage === 'done' ? 'done' : 'now') |
| 117 | 118 | : 'todo'; |
| 118 | 119 | li.setAttribute('data-state', state); |
| 119 | var dot = li.querySelector('.stage-dot'); | |
| 120 | if (dot) dot.textContent = glyph[state]; | |
| 121 | var note = li.querySelector('.stage-detail'); | |
| 120 | var badge = li.querySelector('.badge'); | |
| 121 | if (badge) { | |
| 122 | badge.textContent = badges[state][0]; | |
| 123 | badge.setAttribute('data-variant', badges[state][1]); | |
| 124 | } | |
| 125 | var note = li.querySelector('.row-meta'); | |
| 122 | 126 | if (note) note.textContent = state === 'now' ? (detail || '') : ''; |
| 123 | 127 | }); |
| 124 | 128 | } |
src/web/pages/browse.ts +10 −25
| @@ -86,6 +86,7 @@ import { | ||
| 86 | 86 | } from "./explorer.ts"; |
| 87 | 87 | import { accountOnly, hostLabel, originIcon } from "./origins.ts"; |
| 88 | 88 | import { refSetFrom } from "./refs.ts"; |
| 89 | import { stageRow, stageStateFor } from "../stage-row.ts"; | |
| 89 | 90 | import { |
| 90 | 91 | blobView, |
| 91 | 92 | codePage, |
| @@ -1006,28 +1007,11 @@ const STAGES: { name: MirrorStage; label: string }[] = [ | ||
| 1006 | 1007 | |
| 1007 | 1008 | const ORDER = STAGES.map((s) => s.name); |
| 1008 | 1009 | |
| 1009 | /** Where one row stands relative to where the job has got to. */ | |
| 1010 | function stageState( | |
| 1011 | stage: MirrorStage, | |
| 1012 | at: MirrorStage, | |
| 1013 | ): "done" | "now" | "todo" { | |
| 1014 | // A failed job no longer remembers which stage it was on — `advance` | |
| 1015 | // overwrites it — so nothing is claimed as done. The scrubbed error under | |
| 1016 | // the list is the honest account, and a checklist that guessed would not be. | |
| 1017 | if (at === "failed") return "todo"; | |
| 1018 | const here = ORDER.indexOf(stage); | |
| 1019 | const there = ORDER.indexOf(at); | |
| 1020 | if (here < there) return "done"; | |
| 1021 | if (here === there) return at === "done" ? "done" : "now"; | |
| 1022 | return "todo"; | |
| 1023 | } | |
| 1024 | ||
| 1025 | const GLYPH = { done: "●", now: "◐", todo: "○" } as const; | |
| 1026 | ||
| 1027 | 1010 | /** The checklist. Its shape is what `mirrorJs` updates in place. */ |
| 1028 | 1011 | export function stageList(job: MirrorJob, streamUrl: string): Html { |
| 1029 | 1012 | return html`<ol |
| 1030 | class="stages" | |
| 1013 | class="list stages" | |
| 1014 | aria-live="polite" | |
| 1031 | 1015 | id="stages" |
| 1032 | 1016 | data-stream="${streamUrl}" |
| 1033 | 1017 | data-stage="${job.stage}" |
| @@ -1036,12 +1020,13 @@ export function stageList(job: MirrorJob, streamUrl: string): Html { | ||
| 1036 | 1020 | : `/${job.repo.ownerName}/${job.repo.name}`}" |
| 1037 | 1021 | > |
| 1038 | 1022 | ${STAGES.map((stage) => { |
| 1039 | const state = stageState(stage.name, job.stage); | |
| 1040 | return html`<li data-stage-name="${stage.name}" data-state="${state}"> | |
| 1041 | <span class="stage-dot" aria-hidden="true">${GLYPH[state]}</span> | |
| 1042 | <span class="stage-label">${stage.label}</span> | |
| 1043 | <span class="stage-detail">${state === "now" ? job.detail : ""}</span> | |
| 1044 | </li>`; | |
| 1023 | const state = stageStateFor(ORDER, stage.name, job.stage, job.failedAt); | |
| 1024 | return stageRow({ | |
| 1025 | name: stage.name, | |
| 1026 | label: stage.label, | |
| 1027 | state, | |
| 1028 | detail: job.detail, | |
| 1029 | }); | |
| 1045 | 1030 | })} |
| 1046 | 1031 | </ol>`; |
| 1047 | 1032 | } |
src/web/pages/migrate.ts +21 −19
| @@ -57,6 +57,7 @@ import { | ||
| 57 | 57 | selectField, |
| 58 | 58 | field as formField, |
| 59 | 59 | } from "../ui.ts"; |
| 60 | import { STAGE_BADGE_JS, stageRow, stageStateFor } from "../stage-row.ts"; | |
| 60 | 61 | import { prefill, originHost } from "./migrate-prefill.ts"; |
| 61 | 62 | import { githubConnectable, hostLabel } from "./origins.ts"; |
| 62 | 63 | |
| @@ -707,23 +708,20 @@ const stagesFor = (job: MirrorJob): typeof STAGES => | ||
| 707 | 708 | |
| 708 | 709 | function stageRows(job: MirrorJob): Html { |
| 709 | 710 | const stages = stagesFor(job); |
| 710 | const at = stages.findIndex((s) => s.name === job.stage); | |
| 711 | return html`<ol class="stages"> | |
| 712 | ${stages.map((stage, i) => { | |
| 713 | const state = | |
| 714 | job.stage === "failed" | |
| 715 | ? "todo" | |
| 716 | : i < at || job.stage === "done" | |
| 717 | ? "done" | |
| 718 | : i === at | |
| 719 | ? "now" | |
| 720 | : "todo"; | |
| 721 | const glyph = state === "done" ? "●" : state === "now" ? "◐" : "○"; | |
| 722 | return html`<li data-stage-name="${stage.name}" data-state="${state}"> | |
| 723 | <span class="stage-dot" aria-hidden="true">${glyph}</span> | |
| 724 | <span class="stage-label">${stage.label}</span> | |
| 725 | <span class="stage-detail">${state === "now" ? job.detail : ""}</span> | |
| 726 | </li>`; | |
| 711 | return html`<ol class="list stages" aria-live="polite"> | |
| 712 | ${stages.map((stage) => { | |
| 713 | const state = stageStateFor( | |
| 714 | stages.map((s) => s.name), | |
| 715 | stage.name, | |
| 716 | job.stage, | |
| 717 | job.failedAt, | |
| 718 | ); | |
| 719 | return stageRow({ | |
| 720 | name: stage.name, | |
| 721 | label: stage.label, | |
| 722 | state, | |
| 723 | detail: job.detail, | |
| 724 | }); | |
| 727 | 725 | })} |
| 728 | 726 | </ol>`; |
| 729 | 727 | } |
| @@ -741,6 +739,7 @@ const migrateJobJs = /* js */ ` | ||
| 741 | 739 | var job = box && box.getAttribute('data-job'); |
| 742 | 740 | if (!job || typeof EventSource === 'undefined') return; |
| 743 | 741 | var order = ['creating', 'fetching', 'issues', 'pulls', 'crew', 'done']; |
| 742 | var badges = ${STAGE_BADGE_JS}; | |
| 744 | 743 | var source = new EventSource('/api/v1/migrate/' + encodeURIComponent(job) + '/stream'); |
| 745 | 744 | function patch(e) { |
| 746 | 745 | var data; |
| @@ -753,8 +752,11 @@ const migrateJobJs = /* js */ ` | ||
| 753 | 752 | var state = data.stage === 'failed' ? 'todo' |
| 754 | 753 | : data.stage === 'done' || idx < at ? 'done' : idx === at ? 'now' : 'todo'; |
| 755 | 754 | row.setAttribute('data-state', state); |
| 756 | row.querySelector('.stage-dot').textContent = state === 'done' ? '\\u25cf' : state === 'now' ? '\\u25d0' : '\\u25cb'; | |
| 757 | row.querySelector('.stage-detail').textContent = state === 'now' && data.detail ? data.detail : ''; | |
| 755 | var badge = row.querySelector('.badge'); | |
| 756 | badge.textContent = badges[state][0]; | |
| 757 | badge.setAttribute('data-variant', badges[state][1]); | |
| 758 | var note = row.querySelector('.row-meta'); | |
| 759 | if (note) note.textContent = state === 'now' && data.detail ? data.detail : ''; | |
| 758 | 760 | } |
| 759 | 761 | if (data.stage === 'done' || data.stage === 'failed') { source.close(); location.reload(); } |
outdated
|
||
| 760 | 762 | } |
src/web/stage-row.ts added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | /** | |
| 2 | * One stage of a job in flight, drawn as §5's list row with a badge for its | |
| 3 | * state. The migrate page, the mirror page and the live update's script all | |
| 4 | * write this one shape: `[data-stage-name][data-state]` on the `<li>`, the | |
| 5 | * state's `.badge` in `.row-trail`, the job's own words in `.row-meta`. | |
| 6 | */ | |
| 7 | import { html, type Html } from "./html.ts"; | |
| 8 | import { badge, row, type Tone } from "./ui.ts"; | |
| 9 | ||
| 10 | export type StageState = "done" | "now" | "todo" | "failed"; | |
| 11 | ||
| 12 | /** What the badge says and how it is toned, by state. The live-update | |
| 13 | * scripts are built from this one table (`STAGE_BADGE_JS`). */ | |
| 14 | export const STAGE_BADGE: Record<StageState, { text: string; tone: Tone; variant: string }> = { | |
| 15 | done: { text: "Done", tone: "success", variant: "success" }, | |
| 16 | now: { text: "Running", tone: "attn", variant: "attn" }, | |
No failed state: a stopped job paints its stages as Waiting. Add |
||
| 17 | failed: { text: "Failed", tone: "danger", variant: "danger" }, | |
| 18 | todo: { text: "Waiting", tone: "default", variant: "outline" }, | |
| 19 | }; | |
| 20 | ||
| 21 | /** Where one stage stands, given the job's stage (and, once it failed, the | |
| 22 | * stage it failed on). `order` is the stages as this job lists them. */ | |
| 23 | export function stageStateFor( | |
| 24 | order: readonly string[], | |
| 25 | name: string, | |
| 26 | stage: string, | |
| 27 | failedAt?: string, | |
| 28 | ): StageState { | |
| 29 | const here = order.indexOf(name); | |
outdated
|
||
| 30 | const at = order.indexOf(stage === "failed" ? (failedAt ?? "") : stage); | |
| 31 | if (at < 0) return "todo"; | |
| 32 | if (here < at) return "done"; | |
| 33 | if (here > at) return "todo"; | |
| 34 | return stage === "failed" ? "failed" : stage === "done" ? "done" : "now"; | |
outdated
The ternary duplicates what badge() does; derive variant from tone once and drop the |
||
| 35 | } | |
| 36 | ||
| 37 | export function stageRow(o: { | |
| 38 | name: string; | |
| 39 | label: string; | |
| 40 | state: StageState; | |
| 41 | detail: string; | |
| 42 | }): Html { | |
| 43 | const b = STAGE_BADGE[o.state]; | |
| 44 | return row({ | |
| 45 | title: o.label, | |
| 46 | meta: html`${o.state === "now" ? o.detail : ""}`, | |
| 47 | metaSlot: true, | |
| 48 | trail: badge(b.text, { tone: b.tone, variant: "outline" }), | |
Minor: |
||
| 49 | data: { "stage-name": o.name, state: o.state }, | |
| 50 | }); | |
| 51 | } | |
| 52 | ||
| 53 | /** The table as a JS literal for the scripts, `{done:["Done","success"],…}`. */ | |
| 54 | export const STAGE_BADGE_JS = JSON.stringify( | |
| 55 | Object.fromEntries( | |
| 56 | Object.entries(STAGE_BADGE).map(([k, b]) => [k, [b.text, b.tone === "default" ? b.variant : b.tone]]), | |
| 57 | ), | |
| 58 | ); |
src/web/style/content.ts +2 −14
| @@ -113,21 +113,9 @@ a.where-place:hover, button.where-place:hover { | ||
| 113 | 113 | .live-note strong { color: var(--fg); } |
| 114 | 114 | |
| 115 | 115 | /* A mirror in flight. One row per stage, in the order they happen; the row |
| 116 | says which of the three states it is in and nothing else moves. */ | |
| 116 | is a §5 row whose badge says which of the three states it is in. */ | |
| 117 | 117 | .stages { list-style: none; margin: 0; padding: 0; } |
The divider rule was removed above and the ol has no |
||
| 118 | .stages li { | |
| 119 | display: flex; align-items: center; gap: 10px; | |
| 120 | padding: 8px 0; font-size: 13px; color: var(--faint); | |
| 121 | } | |
| 122 | .stages li + li { border-top: 1px solid var(--border); } | |
| 123 | .stages li[data-state="done"] { color: var(--muted-fg); } | |
| 124 | .stages li[data-state="done"] .stage-dot { color: var(--success); } | |
| 125 | .stages li[data-state="now"] { color: var(--fg); font-weight: 560; } | |
| 126 | .stages li[data-state="now"] .stage-dot { color: var(--accent); } | |
| 127 | .stages li[data-state="failed"] { color: var(--danger); } | |
| 128 | .stages li[data-state="failed"] .stage-dot { color: var(--danger); } | |
| 129 | .stage-dot { flex: none; width: 16px; text-align: center; } | |
| 130 | .stage-detail { margin-left: auto; color: var(--faint); font-size: 12.5px; font-weight: 400; } | |
| 118 | .stages li[data-state="todo"] .row-title { color: var(--faint); } | |
| 131 | 119 | .origin-chip { |
| 132 | 120 | display: inline-flex; align-items: center; gap: 6px; |
| 133 | 121 | max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
src/web/ui.ts +3 −1
| @@ -888,6 +888,8 @@ export interface RowOptions { | ||
| 888 | 888 | href?: string; |
| 889 | 889 | title: Html | string; |
| 890 | 890 | meta?: Html; |
| 891 | /** Draw `.row-meta` even when `meta` is empty, for a script to write into. */ | |
| 892 | metaSlot?: boolean; | |
| 891 | 893 | lead?: Html; |
| 892 | 894 | trail?: Html; |
| 893 | 895 | state?: "open" | "closed" | "merged" | "draft"; |
| @@ -920,7 +922,7 @@ export function row(o: RowOptions): Html { | ||
| 920 | 922 | ${o.titleAttr ? html`title="${o.titleAttr}"` : null} |
| 921 | 923 | >${o.title}</span |
| 922 | 924 | > |
| 923 | ${o.meta ? html`<span class="row-meta">${o.meta}</span>` : null} | |
| 925 | ${o.meta || o.metaSlot ? html`<span class="row-meta">${o.meta}</span>` : null} | |
| 924 | 926 | </span> |
| 925 | 927 | ${o.trail && o.trail.text.trim() !== "" |
| 926 | 928 | ? html`<span class="row-trail">${o.trail}</span>` |
test/migrate.test.ts +29
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | type FakeForge, |
| 63 | 63 | } from "./fake-origin.ts"; |
| 64 | 64 | import { gitClientEnv } from "./helpers/git-client.ts"; |
| 65 | import { STAGE_BADGE_JS } from "../src/web/stage-row.ts"; | |
| 65 | 66 | import { prefill } from "../src/web/pages/migrate-prefill.ts"; |
| 66 | 67 | import { quietLogs } from "./helpers/globals.ts"; |
| 67 | 68 | import { promote } from "./helpers/members.ts"; |
| @@ -1405,6 +1406,34 @@ describe("migrate", () => { | ||
| 1405 | 1406 | target.stop(true); |
| 1406 | 1407 | } |
| 1407 | 1408 | }); |
| 1409 | ||
| 1410 | test("the job page draws each stage as a row with a state badge, and no stage-dot", async () => { | |
| 1411 | const job = createJob("", "octo/rows", { kind: "migrate", actorId: alice.id }); | |
| 1412 | advance(job.id, { stage: "fetching", detail: "receiving 61%" }); | |
| 1413 | const page = await (await browser(aliceCookie, `/migrate/jobs/${job.id}`)).text(); | |
| 1414 | expect(page).not.toContain("stage-dot"); | |
| 1415 | expect(page).not.toContain("stage-detail"); | |
| 1416 | // The page's script is built from the table the rows are drawn from. | |
| 1417 | expect(page).toContain(`var badges = ${STAGE_BADGE_JS};`); | |
| 1418 | expect(page).toMatch(/<li data-stage-name="creating" data-state="done">[\s\S]*?class="row"[\s\S]*?>Done</); | |
| 1419 | expect(page).toMatch(/data-stage-name="fetching" data-state="now">[\s\S]*?receiving 61%[\s\S]*?>Running</); | |
| 1420 | expect(page).toMatch(/data-stage-name="crew" data-state="todo">[\s\S]*?>Waiting</); | |
| 1421 | expect(page).toMatch(/<ol class="list stages" aria-live="polite">/); | |
| 1422 | // Every stage has a `.row-meta` for the live update to write into. | |
| 1423 | const items = page.match(/<li data-stage-name=[\s\S]*?<\/li>/g) ?? []; | |
| 1424 | expect(items.length).toBeGreaterThan(0); | |
| 1425 | for (const li of items) expect(li).toContain('class="row-meta"'); | |
| 1426 | }); | |
| 1427 | ||
| 1428 | test("mark the stage it failed on, and keep the earlier ones done", async () => { | |
| 1429 | const job = createJob("", "octo/broke", { kind: "migrate", actorId: alice.id }); | |
| 1430 | advance(job.id, { stage: "fetching", detail: "receiving 3%" }); | |
| 1431 | advance(job.id, { stage: "failed", error: "clone went wrong" }); | |
| 1432 | const page = await (await browser(aliceCookie, `/migrate/jobs/${job.id}`)).text(); | |
| 1433 | expect(page).toMatch(/data-stage-name="creating" data-state="done"/); | |
| 1434 | expect(page).toMatch(/data-stage-name="fetching" data-state="failed">[\s\S]*?data-variant="danger"\s*>Failed</); | |
| 1435 | expect(page).toMatch(/data-stage-name="crew" data-state="todo"/); | |
| 1436 | }); | |
| 1408 | 1437 | }); |
| 1409 | 1438 | |
| 1410 | 1439 | describe("migrate prefill", () => { |
test/web-browse.test.ts +12 −1
| @@ -30,7 +30,8 @@ import { forgetProviders } from "../src/origins/providers/index.ts"; | ||
| 30 | 30 | import { clearViewCache } from "../src/view/cache.ts"; |
| 31 | 31 | import { quietLogs } from "./helpers/globals.ts"; |
| 32 | 32 | import { newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts"; |
| 33 | import { codeJs } from "../src/web/client.ts"; | |
| 33 | import { codeJs, mirrorJs } from "../src/web/client.ts"; | |
| 34 | import { STAGE_BADGE, STAGE_BADGE_JS } from "../src/web/stage-row.ts"; | |
| 34 | 35 | import { adminCookie, seedAdmin } from "./helpers/admin.ts"; |
| 35 | 36 | |
| 36 | 37 | quietLogs(); |
| @@ -547,6 +548,16 @@ describe("browsing an origin repository on the web", () => { | ||
| 547 | 548 | `data-stream="/api/v1/user/origins/${originId}/mirror/`, |
| 548 | 549 | ); |
| 549 | 550 | expect(running).toContain("new EventSource(url)"); |
| 551 | // Each stage is a §5 row with a state badge, never a bare glyph. | |
| 552 | expect(running).not.toContain("stage-dot"); | |
| 553 | expect(running).not.toContain("stage-detail"); | |
| 554 | expect(running).toMatch(/<ol\s+class="list stages"\s+aria-live="polite"/); | |
| 555 | expect(count(running, "<li data-stage-name=")).toBe(7); | |
| 556 | expect(running).toMatch(/<li data-stage-name="creating" data-state="(?:done|now)">\s*<div class="row"/); | |
| 557 | expect(running).toMatch(/class="badge"\s+data-variant="(?:success|attn|outline)"\s*>(?:Done|Running|Waiting)</); | |
| 558 | // The live update is built from the same table the markup is. | |
| 559 | expect(mirrorJs).toContain(STAGE_BADGE_JS); | |
| 560 | for (const b of Object.values(STAGE_BADGE)) expect(STAGE_BADGE_JS).toContain(b.text); | |
| 550 | 561 | expect(running).toMatch(/<script nonce="[^"]+">/); |
| 551 | 562 | expect(running).not.toMatch(/<script[^>]*src="(?!\/assets\/(?:shell|beacon)-)/); |
| 552 | 563 |
The
if (note)guard silently skips the detail for rows with no.row-meta, so a stage that becomes running after load never shows its detail. Fixing the markup makes this work; add a test.