From 5b3a9b2d66a9a983103a70f7b2b89cd93020c898 Mon Sep 17 00:00:00 2001 From: Scott Clinton Date: Mon, 21 Sep 2026 12:16:21 -0700 Subject: [PATCH 1/2] Add live status bars to the upload and publish pages Both pages previously just told the user to go check the Actions tab after submitting -- a repository_dispatch call returns no run id, so there was nothing to poll. Adds docs/js/workflow-status.js: WorkflowStatusBar attributes the run a dispatch just created by snapshotting existing run ids immediately beforehand and watching for one that wasn't in that snapshot (the only way to identify it, since the runs API never exposes a dispatch's client_payload back), then polls that run's jobs/steps until it finishes, rendering a progress bar + step list + link to the run the whole way. upload.html: one status bar per bootstrap-asset.yml submission. publish.html: one per publish-direct.yml dispatch (it can fire several per submit, one per checked asset/locale row) -- each request's run is attributed *before* the next request's dispatch fires, so two requests started close together can't both claim the same freshly-appeared run; polling each to completion then runs concurrently in the background rather than serializing the whole submission. Also names publish-direct.yml's two checkout steps (translations vs. translations-templates) -- both were relying on actions/checkout's own default step name before, which is identical for both and would have shown as two indistinguishable "Checkout" rows in the new step list. Verified the status bar's own logic (attribution, step rendering, success/failure/timeout paths) against mocked API responses in a Node harness, since neither browser tool in this session could reach a local static server to exercise it live in-browser. Claude-Session: https://claude.ai/code/session_01BXPXYXKZCgxnTSNDGCCGYq --- .github/workflows/publish-direct.yml | 6 +- docs/README.md | 25 ++++ docs/css/style.css | 38 ++++++ docs/js/github-api.js | 19 +++ docs/js/publish.js | 43 +++++- docs/js/upload.js | 23 +++- docs/js/workflow-status.js | 191 +++++++++++++++++++++++++++ docs/publish.html | 2 + docs/upload.html | 2 + 9 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 docs/js/workflow-status.js diff --git a/.github/workflows/publish-direct.yml b/.github/workflows/publish-direct.yml index 11339ba..4fd58a0 100644 --- a/.github/workflows/publish-direct.yml +++ b/.github/workflows/publish-direct.yml @@ -26,11 +26,13 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Check out translations + uses: actions/checkout@v4 with: path: translations - - uses: actions/checkout@v4 + - name: Check out translations-templates + uses: actions/checkout@v4 with: repository: GenAI-Security-Project/translations-templates ssh-key: ${{ secrets.TEMPLATES_DEPLOY_KEY }} diff --git a/docs/README.md b/docs/README.md index c494c64..14ce5af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -124,6 +124,31 @@ the payload always leaves `asset` unset for a new upload, so `bootstrap_asset.py` derives the real, authoritative id server-side. Never trust the browser's copy as the source of truth. +## Live status bars after submitting + +Both `upload.html` (after `bootstrap-asset.yml` fires) and `publish.html` +(after each `publish-direct.yml` fire — one per checked asset/locale row) +show a live status bar tracking the actual Actions run, instead of just +pointing the user at the Actions tab: current step, a progress bar, and a +link to the run once it's found. `js/workflow-status.js` implements this; +`js/upload.js`/`js/publish.js` just create one `WorkflowStatusBar` per +dispatch and call `.track(workflowFile, excludeIds)`. + +The one real wrinkle: `POST /dispatches` returns no run id (that's just how +`repository_dispatch` works), so a bar can't be told "watch run #12345" — +it has to find its own run by elimination. `excludeIds` is a snapshot of +that workflow's existing run ids, taken *immediately before* the dispatch +call; the bar then polls the run list until an id shows up that wasn't in +that snapshot, and attributes that one to itself. This is unambiguous for +`upload.html` (one dispatch per submit), but `publish.html` can fire several +dispatches back to back — there, each request's bar must finish attributing +its run (and add that id to the shared exclude set) *before* the next +request's dispatch fires, or two requests started close together could both +claim the same freshly-appeared run. Polling a run to completion still +happens concurrently across requests after that — only the attribution step +is serialized. See `workflow-status.js`'s header comment and `publish.js`'s +`handlePublish` for exactly where that ordering matters. + ## Duplicate-asset detection `bootstrap_asset.py`'s own "derived id already exists" refusal only catches diff --git a/docs/css/style.css b/docs/css/style.css index 3f09c06..990289c 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -199,6 +199,44 @@ button.secondary { background: transparent; color: var(--accent); border: 1px so .status-pill-progress { background: rgba(138, 90, 0, 0.15); color: var(--warn); } .status-pill-done { background: rgba(27, 122, 61, 0.15); color: var(--ok); } +/* Live workflow-run status bars -- see js/workflow-status.js. */ +.status-bar-row { margin-top: 14px; } +.status-bar { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 14px; + margin-top: 10px; + background: var(--field-bg); +} +.status-bar-label { font-weight: 600; margin-bottom: 6px; } +.status-bar-message { margin: 0 0 8px; font-size: 0.92rem; } +.status-bar-message-error { color: var(--error); } +.status-bar-track { + height: 8px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} +.status-bar-track > div { height: 100%; transition: width 0.3s ease; } +.status-bar-fill-progress { background: var(--accent); } +.status-bar-fill-success { background: var(--ok); } +.status-bar-fill-failure { background: var(--error); } +.status-bar-steps { + list-style: none; + padding: 0; + margin: 10px 0 0; + font-size: 0.85rem; + color: var(--muted); +} +.status-bar-step { padding: 2px 0; } +.status-bar-step-icon { display: inline-block; width: 1.4em; text-align: center; } +.status-bar-step-success .status-bar-step-icon { color: var(--ok); } +.status-bar-step-failure .status-bar-step-icon, .status-bar-step-cancelled .status-bar-step-icon { color: var(--error); } +.status-bar-step-in_progress { color: var(--fg); font-weight: 600; } +.status-bar-step-in_progress .status-bar-step-icon { color: var(--accent); } +.status-bar-link { display: inline-block; margin-top: 10px; font-size: 0.85rem; color: var(--accent); text-decoration: none; } +.status-bar-link:hover { text-decoration: underline; } + .duplicate-warning-soft, .duplicate-block { border-radius: 8px; padding: 10px 14px; diff --git a/docs/js/github-api.js b/docs/js/github-api.js index 304e579..644d248 100644 --- a/docs/js/github-api.js +++ b/docs/js/github-api.js @@ -143,3 +143,22 @@ async function dispatchPublishDirect(clientPayload) { body: JSON.stringify({ event_type: "publish-direct", client_payload: clientPayload }), }); } + +// Most recent runs of one workflow file, newest first -- used by +// workflow-status.js to find the run a dispatch just created (the +// dispatches API itself returns no run id, see its call sites above) and +// then to poll that run's own progress. Never cached: the whole point is +// watching it change. +async function listWorkflowRuns(workflowFile, perPage = 10) { + const data = await ghFetch(`/repos/${ORG}/${CONTENT_REPO}/actions/workflows/${workflowFile}/runs?per_page=${perPage}`); + return data.workflow_runs || []; +} + +// One run's jobs, each with its own steps[] (name/status/conclusion) -- +// the real, step-by-step progress a status bar renders. A run usually has +// exactly one job here (every workflow this pipeline fires does), but the +// API always returns a list. +async function getWorkflowRunJobs(runId) { + const data = await ghFetch(`/repos/${ORG}/${CONTENT_REPO}/actions/runs/${runId}/jobs`); + return data.jobs || []; +} diff --git a/docs/js/publish.js b/docs/js/publish.js index 40ab62a..75e7180 100644 --- a/docs/js/publish.js +++ b/docs/js/publish.js @@ -139,27 +139,55 @@ async function handlePublish(event) { const includeWatermark = document.getElementById("include-watermark-checkbox").checked; const publishButton = document.getElementById("publish-button"); + const barsContainer = document.getElementById("publish-status-bars"); publishButton.disabled = true; resultEl.textContent = `Submitting ${checked.length} publish request(s)…`; resultEl.className = ""; + barsContainer.innerHTML = ""; + + // Snapshotted once, before the first dispatch -- each request's status + // bar adds its own attributed run id as it's found, so the next + // request's attribution can't mistake an already-claimed run for its + // own. See workflow-status.js's header comment for why this can't just + // be done per-request after the fact. + let excludeIds; + try { + excludeIds = await snapshotRunIds("publish-direct.yml"); + } catch (_) { + excludeIds = new Set(); // best effort -- attribution may then take a moment longer, not fail outright + } const successes = []; const failures = []; + const pending = []; for (const checkbox of checked) { const { asset, locale } = checkbox.dataset; + const label = `${asset} / ${locale}`; + const barEl = document.createElement("div"); + barEl.className = "status-bar-row"; + barsContainer.appendChild(barEl); + const bar = new WorkflowStatusBar(barEl, label); + try { await dispatchPublishDirect({ asset, locale, watermark: includeWatermark, totp_code: totpCode }); - successes.push(`${asset} / ${locale}`); + successes.push(label); } catch (err) { - failures.push(`${asset} / ${locale}: ${err.message}`); + failures.push(`${label}: ${err.message}`); + bar.renderError(err.message); + continue; } + + // Attribute this request's run *before* the next dispatch fires (see + // above), then let it poll to completion in the background so waiting + // for one slow run doesn't hold up submitting the rest. + const runId = await bar.attributeRun("publish-direct.yml", excludeIds); + if (runId != null) excludeIds.add(runId); + pending.push(bar.pollUntilDone(runId)); } let summary = ""; if (successes.length) { - summary += `✓ Submitted: ${successes.join(", ")}. ` + - `Check the Actions tab for progress, ` + - `and each asset/locale's release/ folder or the repo's Releases once done.`; + summary += `✓ Submitted: ${successes.join(", ")}. Progress for each is shown below.`; } if (failures.length) { summary += `${summary ? "
" : ""}✗ Failed to submit: ${failures.join("; ")}`; @@ -167,6 +195,11 @@ async function handlePublish(event) { resultEl.innerHTML = summary; resultEl.className = failures.length ? "result-error" : "result-ok"; publishButton.disabled = false; + + // Not awaited -- the form handler is done; each bar renders its own + // outcome as its run progresses. Kept only so nothing here is treated as + // an unhandled/orphaned promise. + Promise.allSettled(pending); } document.addEventListener("DOMContentLoaded", () => { diff --git a/docs/js/upload.js b/docs/js/upload.js index 45542ec..c9b23ed 100644 --- a/docs/js/upload.js +++ b/docs/js/upload.js @@ -344,9 +344,11 @@ async function handleSubmit(event) { } const submitButton = document.getElementById("submit-button"); + const statusBarEl = document.getElementById("submit-status-bar"); submitButton.disabled = true; - resultEl.textContent = "Uploading…"; + resultEl.textContent = ""; resultEl.className = ""; + statusBarEl.innerHTML = ""; try { if (mode === "new") { @@ -360,16 +362,25 @@ async function handleSubmit(event) { payload.uploaded_path = incomingPath; } + // Snapshotted immediately before dispatching -- see workflow-status.js's + // header comment on why this has to happen before, not after. + const beforeRunIds = await snapshotRunIds("bootstrap-asset.yml"); await dispatchBootstrapAsset(payload); - resultEl.innerHTML = - "✓ Submitted. bootstrap-asset is now running — check the " + - `Actions tab ` + - "for progress, and the repo's Pull Requests once it opens the onboarding PR."; + resultEl.innerHTML = "✓ Submitted."; resultEl.className = "result-ok"; + submitButton.disabled = false; // re-enabled once dispatched -- tracking below can run for minutes and shouldn't block a second submission + + const bar = new WorkflowStatusBar(statusBarEl, null); + const run = await bar.track("bootstrap-asset.yml", beforeRunIds); + if (run && run.conclusion === "success") { + resultEl.innerHTML = + "✓ Onboarding finished — check the repo's " + + `Pull Requests ` + + "for the onboarding PR it opened. Merging that PR starts drafting the translation(s) for any newly added locale."; + } } catch (err) { resultEl.textContent = `✗ ${err.message}`; resultEl.className = "result-error"; - } finally { submitButton.disabled = false; } } diff --git a/docs/js/workflow-status.js b/docs/js/workflow-status.js new file mode 100644 index 0000000..89187da --- /dev/null +++ b/docs/js/workflow-status.js @@ -0,0 +1,191 @@ +// A live status bar for one GitHub Actions run, built on the polling +// helpers in github-api.js (listWorkflowRuns, getWorkflowRunJobs). Used by +// upload.js (bootstrap-asset.yml) and publish.js (publish-direct.yml) -- +// both fire a repository_dispatch, which returns no run id, so the only +// way to show real progress is: snapshot existing run ids before +// dispatching, then poll the workflow's run list until a run id shows up +// that wasn't in that snapshot (see attributeRun), then poll that run's +// own jobs/steps until it finishes (see pollUntilDone). +// +// Two dispatches of the same workflow fired close together can't be told +// apart any other way -- the runs API doesn't expose a dispatch's +// client_payload -- so callers that fire more than one dispatch (publish.js) +// must attribute each one (wait for its run to appear) *before* firing the +// next, even though each bar then polls its own run to completion +// independently/concurrently after that. See publish.js. + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const RUN_URL = (runId) => `https://github.com/GenAI-Security-Project/translations/actions/runs/${runId}`; + +const STEP_ICON = { + pending: "○", + in_progress: "◐", + success: "✓", + failure: "✗", + skipped: "–", + cancelled: "✗", +}; + +function stepDisplayStatus(step) { + if (step.status !== "completed") return step.status === "in_progress" ? "in_progress" : "pending"; + return step.conclusion || "success"; +} + +class WorkflowStatusBar { + // container: an element this renders into (its innerHTML is fully owned + // once tracking starts). label: short text identifying what this bar is + // tracking, e.g. an asset id or "asset / locale" pair, shown as a heading + // when a page is running more than one of these at once (publish.js). + constructor(container, label) { + this.container = container; + this.label = label; + this.runId = null; + this._renderWaiting("Submitted — waiting for the workflow to start…"); + } + + _shell(bodyHtml) { + const heading = this.label ? `
${this.label}
` : ""; + this.container.innerHTML = `
${heading}${bodyHtml}
`; + } + + _renderWaiting(message) { + this._shell(`

${message}

`); + } + + _renderError(message) { + this._shell(`

✗ ${message}

`); + } + + // Public entry point for a caller-side failure this bar never got to + // observe itself (e.g. the dispatch call that was supposed to create the + // run it would track failed outright) -- everything else here is + // internal to attributeRun/pollUntilDone/track. + renderError(message) { + this._renderError(message); + } + + _renderSteps(run, jobs) { + const steps = jobs.flatMap((j) => j.steps || []); + const total = steps.length; + const done = steps.filter((s) => s.status === "completed").length; + const pct = total > 0 ? Math.round((done / total) * 100) : run.status === "completed" ? 100 : 0; + + const failed = steps.find((s) => s.status === "completed" && s.conclusion === "failure"); + const running = steps.find((s) => s.status === "in_progress"); + + let summary; + let barClass = "status-bar-fill-progress"; + if (run.status === "completed") { + if (run.conclusion === "success") { + summary = "✓ Completed successfully"; + barClass = "status-bar-fill-success"; + } else if (failed) { + summary = `✗ Failed at "${failed.name}"`; + barClass = "status-bar-fill-failure"; + } else { + summary = `✗ ${run.conclusion || "did not complete"}`; + barClass = "status-bar-fill-failure"; + } + } else if (running) { + summary = `Running: ${running.name}`; + } else { + summary = "Queued…"; + } + + const stepRows = steps + .map((s) => { + const status = stepDisplayStatus(s); + return `
  • ${STEP_ICON[status] || "○"} ${s.name}
  • `; + }) + .join(""); + + this._shell(` +

    ${summary}

    +
    + + View this run on GitHub → + `); + } + + // Polls workflowFile's run list until a run id appears that isn't in + // excludeIds (a Set, snapshotted by the caller immediately before its + // dispatch call). Resolves with that run's id once found (does not wait + // for it to finish -- see pollUntilDone for that), or null on timeout. + async attributeRun(workflowFile, excludeIds, { timeoutMs = 30000, intervalMs = 2500 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let runs; + try { + runs = await listWorkflowRuns(workflowFile, 15); + } catch (err) { + this._renderError(err.message); + return null; + } + const fresh = runs.find((r) => !excludeIds.has(r.id)); + if (fresh) { + this.runId = fresh.id; + return fresh.id; + } + await sleep(intervalMs); + } + this._renderError( + "Timed out waiting for the workflow to start. It may still be queued -- " + + `check the Actions tab.` + ); + return null; + } + + // Polls one run's jobs/steps, updating the status bar each time, until + // the run reports status "completed". Resolves with the final run object + // (its .conclusion is the thing to check), or null if runId is null + // (attributeRun already timed out and rendered its own error). + async pollUntilDone(runId, { intervalMs = 4000, timeoutMs = 10 * 60 * 1000 } = {}) { + if (runId == null) return null; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + let jobs; + try { + jobs = await getWorkflowRunJobs(runId); + } catch (err) { + this._renderError(err.message); + return null; + } + // The run's own status/conclusion isn't on the jobs response -- a job + // reliably mirrors it (this pipeline's workflows are always one job), + // and completed-with-no-jobs-yet only happens in the instant a run is + // first created, before its job list populates. + const job = jobs[0]; + const run = job + ? { id: runId, status: job.status, conclusion: job.conclusion } + : { id: runId, status: "queued", conclusion: null }; + this._renderSteps(run, jobs); + if (run.status === "completed") return run; + await sleep(intervalMs); + } + this._renderError( + "Timed out watching this run. It may still be in progress -- " + + `check it directly: ${RUN_URL(runId)}.` + ); + return null; + } + + // Convenience wrapper: attribute, then poll to completion. excludeIds + // must be snapshotted (listWorkflowRuns) by the caller *before* firing + // the dispatch this bar is meant to track. + async track(workflowFile, excludeIds) { + const runId = await this.attributeRun(workflowFile, excludeIds); + return this.pollUntilDone(runId); + } +} + +// Snapshot helper for the excludeIds a caller needs before dispatching -- +// just the current run ids for a workflow, as a Set. Kept here (rather than +// inlined at each call site) so "how far back to look" (perPage) stays in +// one place. +async function snapshotRunIds(workflowFile) { + const runs = await listWorkflowRuns(workflowFile, 15); + return new Set(runs.map((r) => r.id)); +} diff --git a/docs/publish.html b/docs/publish.html index 946d1b5..3d216cb 100644 --- a/docs/publish.html +++ b/docs/publish.html @@ -99,12 +99,14 @@

    4. Confirm

    +
    + diff --git a/docs/upload.html b/docs/upload.html index 69b8eec..9df9b56 100644 --- a/docs/upload.html +++ b/docs/upload.html @@ -120,6 +120,7 @@

    5. Confirm

    +
    @@ -127,6 +128,7 @@

    5. Confirm

    + From 1c38ab33814e1f5b8f2b591688b887dfeeadd46b Mon Sep 17 00:00:00 2001 From: Scott Clinton Date: Mon, 21 Sep 2026 13:39:43 -0700 Subject: [PATCH 2/2] Status bar: label with the asset id above, "Actions:" heading below The upload page's status bar previously had no label at all (upload.js passed null); the publish page's already showed " / " per row. Both now consistently show the asset id right above the bar -- for upload.html, existing-asset mode uses the real selected id, new-asset mode uses deriveAssetIdPreview() (a preview only: the real id is always derived server-side by bootstrap_asset.py and could in principle differ, same caveat as the on-page id preview elsewhere in this file). Also adds a small "Actions:" heading directly under the progress bar, right above the existing step-by-step list (previously just a bare list with no heading) -- applies to both pages, since they share the same _renderSteps in workflow-status.js. Claude-Session: https://claude.ai/code/session_01BXPXYXKZCgxnTSNDGCCGYq --- docs/css/style.css | 8 ++++++++ docs/js/upload.js | 11 ++++++++++- docs/js/workflow-status.js | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/css/style.css b/docs/css/style.css index 990289c..20b04fb 100644 --- a/docs/css/style.css +++ b/docs/css/style.css @@ -221,6 +221,14 @@ button.secondary { background: transparent; color: var(--accent); border: 1px so .status-bar-fill-progress { background: var(--accent); } .status-bar-fill-success { background: var(--ok); } .status-bar-fill-failure { background: var(--error); } +.status-bar-actions-heading { + margin: 12px 0 2px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--muted); +} .status-bar-steps { list-style: none; padding: 0; diff --git a/docs/js/upload.js b/docs/js/upload.js index c9b23ed..22fa611 100644 --- a/docs/js/upload.js +++ b/docs/js/upload.js @@ -343,6 +343,15 @@ async function handleSubmit(event) { // asset is left unset — bootstrap_asset.py derives the real id server-side } + // Shown above the status bar once submitted, so it's clear which asset a + // given run belongs to (matters most once more than one upload has been + // submitted from this tab). For a new asset this is only the *preview* id + // (see deriveAssetIdPreview's own caveats) -- the real one is always + // derived server-side and could in principle differ. + const statusBarLabel = mode === "existing" + ? payload.asset + : deriveAssetIdPreview(document.getElementById("file-input").files[0].name); + const submitButton = document.getElementById("submit-button"); const statusBarEl = document.getElementById("submit-status-bar"); submitButton.disabled = true; @@ -370,7 +379,7 @@ async function handleSubmit(event) { resultEl.className = "result-ok"; submitButton.disabled = false; // re-enabled once dispatched -- tracking below can run for minutes and shouldn't block a second submission - const bar = new WorkflowStatusBar(statusBarEl, null); + const bar = new WorkflowStatusBar(statusBarEl, statusBarLabel); const run = await bar.track("bootstrap-asset.yml", beforeRunIds); if (run && run.conclusion === "success") { resultEl.innerHTML = diff --git a/docs/js/workflow-status.js b/docs/js/workflow-status.js index 89187da..6d908d4 100644 --- a/docs/js/workflow-status.js +++ b/docs/js/workflow-status.js @@ -105,6 +105,7 @@ class WorkflowStatusBar { this._shell(`

    ${summary}

    +

    Actions:

      ${stepRows}
    View this run on GitHub → `);