Merge the run page fixes: Cancel run shows on every view of a run that is still going, including an earlier attempt's and job links, and pages refresh when a run is re-run from anywhere; a job's log follows to its true end, above anything covering the bottom of the window, and Jump to latest lands there.
8 files+303−280/8 viewed
| 1217 | 1217 | page asks for more every second while a step is running. | |
| 1218 | 1218 | ||
| 1219 | 1219 | - The running step opens by itself, and its timer counts up. Its log | |
| 1220 | − | follows the newest line while the end of it is on screen. Scroll up and | |
| 1221 | − | it stays where you are, with **Jump to latest** to go back to the end. | |
| 1220 | + | follows the newest line while the end of it is on screen, with the last | |
| 1221 | + | line wholly in view (above the tab bar on a phone). Scroll up and it | |
| 1222 | + | stays where you are, with **Jump to latest** to go back to the end and | |
| 1223 | + | follow it again. | |
| 1222 | 1224 | - A step that prints nothing for a while says so under its last line: | |
| 1223 | 1225 | **Running · no output for 2m 10s**, or **no output yet** before its | |
| 1224 | 1226 | first line. The job's header names the step it is on. | |
| ⋯ | |||
| 1374 | 1376 | ||
| 1375 | 1377 | ### Cancelling | |
| 1376 | 1378 | ||
| 1377 | − | **Cancel run** cancels jobs that have not started at once. A job that is | |
| 1378 | − | running is stopped the way GitHub stops one: | |
| 1379 | + | **Cancel run** is at the top of the run's page while any job of its latest | |
| 1380 | + | attempt has not finished: waiting for the jobs it needs, held for an | |
| 1381 | + | environment's approval, queued for a runner (a self-hosted job waits | |
| 1382 | + | until a runner with its labels takes it), or running. It is there on an | |
| 1383 | + | earlier attempt's page too, and on a job's, and the re-run buttons appear | |
| 1384 | + | only once the run has finished. A page left open on a finished run hears | |
| 1385 | + | when it is re-run, from anywhere, and offers Cancel run. | |
| 1386 | + | ||
| 1387 | + | It cancels jobs that have not started at once. A job that is running is | |
| 1388 | + | stopped the way GitHub stops one: | |
| 1379 | 1389 | ||
| 1380 | 1390 | 1. The step it is on gets `SIGINT`, then `SIGTERM` 7.5 seconds later, and | |
| 1381 | 1391 | is killed 2.5 seconds after that. Signals reach the processes the step | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { FOLLOW_SLACK, endPlace } from "./log-follow.ts"; | |
| 5 | + | ||
| 6 | + | const end = (bottom: number) => ({ top: bottom - 1, bottom, width: 600, height: 1 }); | |
| 7 | + | ||
| 8 | + | test("the end above the window's bottom edge is in view", () => { | |
| 9 | + | assert.equal(endPlace(end(700), { height: 800, covered: 12 }), "here"); | |
| 10 | + | assert.equal(endPlace(end(788), { height: 800, covered: 12 }), "here"); | |
| 11 | + | // Scrolled past it, the latest is still on screen. | |
| 12 | + | assert.equal(endPlace(end(-40), { height: 800, covered: 12 }), "here"); | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | test("an end one row below the edge is not reached: following stops there", () => { | |
| 16 | + | // Before, anything within 160 px below the window counted, so a log | |
| 17 | + | // could stop with its last lines cut off and no Jump to latest. | |
| 18 | + | assert.equal(endPlace(end(800 - 12 + 20), { height: 800, covered: 12 }), "below"); | |
| 19 | + | assert.equal(endPlace(end(800 + 20), { height: 800, covered: 0 }), "below"); | |
| 20 | + | assert.equal(endPlace(end(800 + FOLLOW_SLACK), { height: 800, covered: 0 }), "here"); | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | test("what covers the bottom of the window hides the end: a phone's tab bar", () => { | |
| 24 | + | // 56 px of tab bar and 12 px of room: an end at 760 is under the bar. | |
| 25 | + | assert.equal(endPlace(end(760), { height: 812, covered: 68 }), "below"); | |
| 26 | + | assert.equal(endPlace(end(744), { height: 812, covered: 68 }), "here"); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("a folded step has no end to follow", () => { | |
| 30 | + | assert.equal(endPlace(null, { height: 800, covered: 0 }), "folded"); | |
| 31 | + | assert.equal(endPlace({ top: 0, bottom: 0, width: 0, height: 0 }, { height: 800, covered: 0 }), "folded"); | |
| 32 | + | }); |
| 1 | + | /** | |
| 2 | + | * Following a running step's log: where the end of the log is against what | |
| 3 | + | * the viewer can see. Pure; the hook that uses it is `useFollow` in | |
| 4 | + | * routes/repo/actions-run.tsx. | |
| 5 | + | * | |
| 6 | + | * The end of the log is a marker after its last line. What covers the | |
| 7 | + | * bottom of the window (a phone's tab bar, and room to breathe under the | |
| 8 | + | * last line) is the marker's `scroll-margin-bottom`, so scrolling it into | |
| 9 | + | * view and judging whether it is in view read the same number. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | /** How far past what can be seen the end may be and still count as reached: rounding, not a row. */ | |
| 13 | + | export const FOLLOW_SLACK = 2; | |
| 14 | + | ||
| 15 | + | export type EndBox = { top: number; bottom: number; width: number; height: number }; | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * Where the end of the log is: folded away (a closed step draws nothing), | |
| 19 | + | * in view above whatever covers the window's bottom (the viewer is | |
| 20 | + | * reading the latest), or below it. | |
| 21 | + | */ | |
| 22 | + | export function endPlace(end: EndBox | null, view: { height: number; covered: number }): "folded" | "here" | "below" { | |
| 23 | + | if (!end || (end.width === 0 && end.height === 0)) return "folded"; | |
| 24 | + | return end.bottom <= view.height - view.covered + FOLLOW_SLACK ? "here" : "below"; | |
| 25 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts"; | |
| 5 | + | import { runControls } from "./run-controls.ts"; | |
| 6 | + | ||
| 7 | + | type Run = Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">; | |
| 8 | + | type Attempt = Pick<RunAttempt, "attempt" | "status">; | |
| 9 | + | ||
| 10 | + | const run = (more: Partial<Run> = {}): Run => ({ attempt: 1, status: "completed", conclusion: "success", error: null, ...more }); | |
| 11 | + | const jobs = (...statuses: Job["status"][]) => statuses.map((status) => ({ status })); | |
| 12 | + | /** Attempts 1 to `latest`, the earlier ones finished, the latest as `status`. */ | |
| 13 | + | function attempts(latest: number, status: Attempt["status"]): Attempt[] { | |
| 14 | + | return Array.from({ length: latest }, (_, i) => ({ attempt: i + 1, status: i + 1 === latest ? status : "completed" })); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | test("a re-run of failed jobs whose job waits for a self-hosted runner can be cancelled, not re-run", () => { | |
| 18 | + | // Attempt 6 of 6: its first job succeeded, the one that needs it is queued. | |
| 19 | + | const controls = runControls({ | |
| 20 | + | run: run({ attempt: 6, status: "in_progress", conclusion: null }), | |
| 21 | + | jobs: jobs("completed", "queued"), | |
| 22 | + | attempts: attempts(6, "in_progress"), | |
| 23 | + | member: true, | |
| 24 | + | }); | |
| 25 | + | assert.deepEqual(controls, { latest: true, going: true, cancelling: false, canCancel: true, canRerun: false }); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("an earlier attempt's page offers Cancel run while the latest attempt runs", () => { | |
| 29 | + | // `?attempt=5`: attempt 5 as it ended, every job finished. | |
| 30 | + | const controls = runControls({ | |
| 31 | + | run: run({ attempt: 5, status: "completed", conclusion: "failure" }), | |
| 32 | + | jobs: jobs("completed", "completed"), | |
| 33 | + | attempts: attempts(6, "in_progress"), | |
| 34 | + | member: true, | |
| 35 | + | }); | |
| 36 | + | assert.equal(controls.latest, false); | |
| 37 | + | assert.equal(controls.going, true); | |
| 38 | + | assert.equal(controls.canCancel, true); | |
| 39 | + | assert.equal(controls.canRerun, false); | |
| 40 | + | // How attempt 5 ended says nothing of attempt 6 being cancelled. | |
| 41 | + | assert.equal(runControls({ run: run({ attempt: 5, conclusion: "cancelled" }), jobs: [], attempts: attempts(6, "queued"), member: true }).cancelling, false); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("a job not finished keeps the latest attempt going, whatever the run's own status says", () => { | |
| 45 | + | for (const status of ["waiting", "pending", "queued", "in_progress", "calling"] as const) { | |
| 46 | + | const controls = runControls({ run: run({ attempt: 2, status: "completed" }), jobs: jobs("completed", status), attempts: attempts(2, "completed"), member: true }); | |
| 47 | + | assert.equal(controls.canCancel, true, status); | |
| 48 | + | assert.equal(controls.canRerun, false, status); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | test("every status a run waits or runs in can be cancelled", () => { | |
| 53 | + | for (const status of ["pending", "action_required", "queued", "in_progress", "waiting"] as const) { | |
| 54 | + | const controls = runControls({ run: run({ status, conclusion: null }), jobs: [], attempts: attempts(1, status), member: true }); | |
| 55 | + | assert.equal(controls.canCancel, true, status); | |
| 56 | + | assert.equal(controls.canRerun, false, status); | |
| 57 | + | } | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("a finished run is re-run from its latest attempt only", () => { | |
| 61 | + | const latest = runControls({ run: run({ attempt: 2, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true }); | |
| 62 | + | assert.deepEqual(latest, { latest: true, going: false, cancelling: false, canCancel: false, canRerun: true }); | |
| 63 | + | const earlier = runControls({ run: run({ attempt: 1, conclusion: "failure" }), jobs: jobs("completed"), attempts: attempts(2, "completed"), member: true }); | |
| 64 | + | assert.equal(earlier.canRerun, false); | |
| 65 | + | assert.equal(earlier.canCancel, false); | |
| 66 | + | // A run of one attempt has no list to look through. | |
| 67 | + | assert.equal(runControls({ run: run(), jobs: jobs("completed"), attempts: [], member: true }).canRerun, true); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("a run being cancelled shows Force cancel", () => { | |
| 71 | + | const controls = runControls({ run: run({ status: "in_progress", conclusion: "cancelled" }), jobs: jobs("in_progress"), attempts: attempts(1, "in_progress"), member: true }); | |
| 72 | + | assert.equal(controls.cancelling, true); | |
| 73 | + | assert.equal(controls.canCancel, true); | |
| 74 | + | }); | |
| 75 | + | ||
| 76 | + | test("neither without Write, nor for a run whose workflow file could not be used", () => { | |
| 77 | + | const going = { run: run({ status: "in_progress", conclusion: null }), jobs: jobs("queued"), attempts: attempts(1, "in_progress") }; | |
| 78 | + | assert.equal(runControls({ ...going, member: false }).canCancel, false); | |
| 79 | + | const broken = runControls({ run: run({ conclusion: "failure", error: "Its file is not YAML." }), jobs: [], attempts: [], member: true }); | |
| 80 | + | assert.equal(broken.canCancel, false); | |
| 81 | + | assert.equal(broken.canRerun, false); | |
| 82 | + | }); |
| 1 | + | /** | |
| 2 | + | * Which of a run's buttons its page shows: Cancel run (or Force cancel) | |
| 3 | + | * while any of it is still going, the re-runs once it has finished. Pure; | |
| 4 | + | * the page is routes/repo/actions-run.tsx. | |
| 5 | + | * | |
| 6 | + | * A run is going while its latest attempt is: the attempt list's last | |
| 7 | + | * entry says how it stands now, whichever attempt the page shows, so an | |
| 8 | + | * earlier attempt's page (`?attempt=2`, and its `?job=` links) offers | |
| 9 | + | * Cancel run while a later one runs. On the latest attempt a job that has | |
| 10 | + | * not finished (waiting, pending approval, queued for a runner, running) | |
| 11 | + | * keeps the run going too, whatever the run's own status says. | |
| 12 | + | */ | |
| 13 | + | import type { Job, RunAttempt, WorkflowRun } from "@g1t/contracts"; | |
| 14 | + | ||
| 15 | + | export type RunControls = { | |
| 16 | + | /** The attempt shown is the run's latest. */ | |
| 17 | + | latest: boolean; | |
| 18 | + | /** The run, at its latest attempt, has not finished. */ | |
| 19 | + | going: boolean; | |
| 20 | + | /** Cancelled, while its jobs run their cleanup steps: Force cancel in place of Cancel run. */ | |
| 21 | + | cancelling: boolean; | |
| 22 | + | canCancel: boolean; | |
| 23 | + | canRerun: boolean; | |
| 24 | + | }; | |
| 25 | + | ||
| 26 | + | export function runControls(input: { | |
| 27 | + | run: Pick<WorkflowRun, "attempt" | "status" | "conclusion" | "error">; | |
| 28 | + | jobs: Pick<Job, "status">[]; | |
| 29 | + | attempts: Pick<RunAttempt, "attempt" | "status">[]; | |
| 30 | + | /** The viewer may cancel and re-run (Write). */ | |
| 31 | + | member: boolean; | |
| 32 | + | }): RunControls { | |
| 33 | + | const { run, jobs, attempts, member } = input; | |
| 34 | + | const newest = attempts[attempts.length - 1]; | |
| 35 | + | const latest = newest == null || run.attempt === newest.attempt; | |
| 36 | + | const going = latest ? run.status !== "completed" || jobs.some((job) => job.status !== "completed") : newest.status !== "completed"; | |
| 37 | + | // Only the latest attempt's own status says it is being cancelled; an | |
| 38 | + | // earlier attempt's conclusion is how that attempt ended. | |
| 39 | + | const cancelling = going && latest && run.conclusion === "cancelled"; | |
| 40 | + | const allowed = member && !run.error; | |
| 41 | + | return { latest, going, cancelling, canCancel: allowed && going, canRerun: allowed && !going && latest }; | |
| 42 | + | } |
| 57 | 57 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 58 | 58 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 59 | 59 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 60 | + | import { runControls } from "../../lib/run-controls"; | |
| 61 | + | import { endPlace } from "../../lib/log-follow"; | |
| 60 | 62 | import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph"; | |
| 61 | 63 | import { repoPages } from "../../lib/repo-live"; | |
| 62 | 64 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| ⋯ | |||
| 197 | 199 | {quiet} | |
| 198 | 200 | </p> | |
| 199 | 201 | )} | |
| 200 | − | {running && <div ref={end} aria-hidden className="h-px" />} | |
| 202 | + | {/* The end of the log: kept clear of what covers the window's bottom (a phone's tab bar), with room under the last line. */} | |
| 203 | + | {running && <div ref={end} aria-hidden className="h-px scroll-mb-[calc(var(--tabbar-h)+0.75rem)]" />} | |
| 201 | 204 | </div> | |
| 202 | 205 | </details> | |
| 203 | 206 | ); | |
| ⋯ | |||
| 253 | 256 | /** | |
| 254 | 257 | * Following the running step's log as it grows, as GitHub does: while the | |
| 255 | 258 | * end of it is in view, new lines keep it in view; once the viewer | |
| 256 | − | * scrolls up, it stays put and `behind` says there is more below. | |
| 259 | + | * scrolls up, it stays put and `behind` says there is more below. The end | |
| 260 | + | * is in view only with the last line wholly above whatever covers the | |
| 261 | + | * window's bottom (lib/log-follow.ts), and following pins it there again | |
| 262 | + | * once the new lines have been laid out. | |
| 257 | 263 | */ | |
| 258 | 264 | function useFollow(running: number | null, grew: unknown): { end: RefObject<HTMLDivElement | null>; behind: boolean; jump: () => void } { | |
| 259 | 265 | const end = useRef<HTMLDivElement | null>(null); | |
| 260 | 266 | const following = useRef(false); | |
| 267 | + | // Jump to latest's smooth scroll is on its way: the scrolls it makes are | |
| 268 | + | // not the viewer scrolling up. | |
| 269 | + | const jumping = useRef(false); | |
| 261 | 270 | const [behind, setBehind] = useState(false); | |
| 262 | − | // Where the end of the log is: folded away (a closed step draws nothing), | |
| 263 | − | // on screen or nearly (the viewer is reading the latest), or below. | |
| 264 | − | const where = (): "folded" | "here" | "below" => { | |
| 265 | − | const box = end.current?.getBoundingClientRect(); | |
| 266 | − | if (!box || (box.width === 0 && box.height === 0)) return "folded"; | |
| 267 | − | return box.top <= window.innerHeight + 160 ? "here" : "below"; | |
| 271 | + | const where = () => { | |
| 272 | + | const marker = end.current; | |
| 273 | + | const covered = marker ? parseFloat(getComputedStyle(marker).scrollMarginBottom) || 0 : 0; | |
| 274 | + | return endPlace(marker?.getBoundingClientRect() ?? null, { height: window.innerHeight, covered }); | |
| 275 | + | }; | |
| 276 | + | // The end of the log at the bottom of what can be seen, now and once | |
| 277 | + | // anything still settling (a long log's rows being measured, the Jump to | |
| 278 | + | // latest bar going away) has been laid out. | |
| 279 | + | const pin = () => { | |
| 280 | + | end.current?.scrollIntoView({ block: "end" }); | |
| 281 | + | requestAnimationFrame(() => { | |
| 282 | + | if (following.current && where() !== "folded") end.current?.scrollIntoView({ block: "end" }); | |
| 283 | + | }); | |
| 268 | 284 | }; | |
| 269 | 285 | useEffect(() => { | |
| 270 | 286 | if (running === null) { | |
| ⋯ | |||
| 274 | 290 | } | |
| 275 | 291 | // The viewer moving decides: at the end, follow; scrolled up, stay. | |
| 276 | 292 | const moved = () => { | |
| 293 | + | if (jumping.current) return; | |
| 277 | 294 | const at = where(); | |
| 278 | 295 | following.current = at === "here"; | |
| 279 | 296 | setBehind(at === "below"); | |
| 280 | 297 | }; | |
| 281 | − | // A step folded or opened moves nothing the viewer chose; it only | |
| 282 | − | // changes whether there is anything below to jump to. | |
| 298 | + | // A step folded or opened, or the window resized (a phone's address | |
| 299 | + | // bar or keyboard), moves nothing the viewer chose: a log followed | |
| 300 | + | // stays at its end, and whether there is anything below to jump to may | |
| 301 | + | // change. | |
| 283 | 302 | const toggled = () => { | |
| 284 | 303 | const at = where(); | |
| 285 | 304 | if (at === "folded") following.current = false; | |
| 305 | + | else if (following.current && at === "below" && !jumping.current) { | |
| 306 | + | pin(); | |
| 307 | + | return; | |
| 308 | + | } | |
| 286 | 309 | setBehind(at === "below"); | |
| 287 | 310 | }; | |
| 288 | 311 | window.addEventListener("scroll", moved, { passive: true }); | |
| ⋯ | |||
| 297 | 320 | useLayoutEffect(() => { | |
| 298 | 321 | if (running === null || !end.current) return; | |
| 299 | 322 | if (following.current && where() !== "folded") { | |
| 300 | − | end.current.scrollIntoView({ block: "end" }); | |
| 323 | + | pin(); | |
| 301 | 324 | return; | |
| 302 | 325 | } | |
| 303 | 326 | // Not following: once there is a log and its end is on screen, follow | |
| ⋯ | |||
| 307 | 330 | if (grew && at === "here") following.current = true; | |
| 308 | 331 | setBehind(at === "below"); | |
| 309 | 332 | }, [grew, running]); | |
| 333 | + | // Smoothly there, then pinned where the end is once the scroll is over: | |
| 334 | + | // lines that arrived on the way, and the bar this button sits in going | |
| 335 | + | // away, move it. | |
| 310 | 336 | const jump = () => { | |
| 311 | 337 | following.current = true; | |
| 312 | − | end.current?.scrollIntoView({ block: "end", behavior: "smooth" }); | |
| 338 | + | const marker = end.current; | |
| 339 | + | if (!marker) return; | |
| 340 | + | jumping.current = true; | |
| 341 | + | setBehind(false); | |
| 342 | + | let settled = false; | |
| 343 | + | const settle = () => { | |
| 344 | + | if (settled) return; | |
| 345 | + | settled = true; | |
| 346 | + | jumping.current = false; | |
| 347 | + | window.removeEventListener("scrollend", settle); | |
| 348 | + | if (following.current && where() !== "folded") pin(); | |
| 349 | + | }; | |
| 350 | + | window.addEventListener("scrollend", settle); | |
| 351 | + | // A browser without `scrollend`, or nothing left to scroll. | |
| 352 | + | window.setTimeout(settle, 800); | |
| 353 | + | marker.scrollIntoView({ block: "end", behavior: "smooth" }); | |
| 313 | 354 | }; | |
| 314 | 355 | return { end, behind, jump }; | |
| 315 | 356 | } | |
| ⋯ | |||
| 650 | 691 | ))} | |
| 651 | 692 | </Card> | |
| 652 | 693 | {behind && !searching && ( | |
| 653 | − | <div className="pointer-events-none sticky bottom-4 z-10 flex justify-center"> | |
| 694 | + | <div className="pointer-events-none sticky bottom-[calc(var(--tabbar-h)+1rem)] z-10 flex justify-center"> | |
| 654 | 695 | <Button variant="secondary" size="sm" onClick={jump} className="pointer-events-auto shadow-md ring-1 ring-line"> | |
| 655 | 696 | <ArrowDown size={14} /> | |
| 656 | 697 | Jump to latest | |
| ⋯ | |||
| 1222 | 1263 | const { run, jobs, notes } = detail; | |
| 1223 | 1264 | const deployments = detail.pendingDeployments ?? []; | |
| 1224 | 1265 | const attempts = detail.attempts ?? []; | |
| 1225 | − | const latest = attempts.length === 0 || run.attempt === attempts[attempts.length - 1]!.attempt; | |
| 1226 | 1266 | const base = `/${params.owner}/${params.repo}`; | |
| 1227 | 1267 | const [search] = useSearchParams(); | |
| 1228 | 1268 | // One of the run's buttons is working: the others wait for it. | |
| 1229 | 1269 | const busy = usePending(); | |
| 1270 | + | // The attempt shown is still going. | |
| 1230 | 1271 | const live = run.status !== "completed"; | |
| 1231 | − | // Cancelled, while its jobs run their cleanup steps. | |
| 1232 | − | const cancelling = live && run.conclusion === "cancelled"; | |
| 1233 | − | // Re-running is for the latest attempt of a finished run. | |
| 1234 | − | const canRerun = member && !run.error && !live && latest; | |
| 1272 | + | // Cancel run while any of the run is going, from whichever attempt's | |
| 1273 | + | // page; re-running is for the latest attempt of a finished run. | |
| 1274 | + | const { latest, going, cancelling, canCancel, canRerun } = runControls({ run, jobs, attempts, member }); | |
| 1235 | 1275 | // A mirror standing by runs nothing; in CI failover it runs them again. | |
| 1236 | 1276 | const rerunBlocked = useWorkflowReason(); | |
| 1237 | 1277 | const logsUrl = `${base}/actions/runs/${run.id}/logs.zip${latest ? "" : `?attempt=${run.attempt}`}`; | |
| ⋯ | |||
| 1240 | 1280 | // streams on its own), every few seconds without it. Waiting on a person | |
| 1241 | 1281 | // needs no quick refresh. | |
| 1242 | 1282 | const { live: heard } = useRepoLive({ match: repoPages.run(run.id, run.sha) }); | |
| 1243 | − | useRefreshWhile(live, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500); | |
| 1283 | + | useRefreshWhile(going, run.status === "action_required" || run.status === "waiting" ? 8000 : heard ? 10_000 : 2500); | |
| 1244 | 1284 | ||
| 1245 | 1285 | // The job asked for; none is the run's summary. | |
| 1246 | 1286 | const selected = jobs.find((job) => job.id === search.get("job")) ?? null; | |
| ⋯ | |||
| 1275 | 1315 | </Button> | |
| 1276 | 1316 | </Hint> | |
| 1277 | 1317 | )} | |
| 1278 | − | {member && !run.error && live && ( | |
| 1318 | + | {canCancel && ( | |
| 1279 | 1319 | <Form method="post" className="flex gap-2"> | |
| 1280 | 1320 | {cancelling ? ( | |
| 1281 | 1321 | <Hint label="Stop its jobs now, without waiting for their cleanup steps"> | |
| 2227 | 2227 | self.report_pending(&run).await?; | |
| 2228 | 2228 | } | |
| 2229 | 2229 | self.advance(&run.id).await?; | |
| 2230 | + | // A page left open on the attempt that ended, which asks for nothing | |
| 2231 | + | // while its run is over, hears that it is going again: re-run from | |
| 2232 | + | // the API, the MCP tool, a pull request or another tab, as well as | |
| 2233 | + | // from that page, it offers Cancel run and not the re-runs. | |
| 2234 | + | self.tell_live(&run.repo_id, &run.id, Some(&run.sha), run.pull).await; | |
| 2230 | 2235 | self.run_summary(&run.id).await | |
| 2231 | 2236 | } | |
| 2232 | 2237 |
| 322 | 322 | })); | |
| 323 | 323 | } | |
| 324 | 324 | let pending_deployments = self.pending_for(&run, &a.viewer).await?; | |
| 325 | − | // Jobs held at an environment's rules: the run waits, as on GitHub. | |
| 326 | − | if matches!(summary.status.as_str(), "queued" | "in_progress") && jobs.iter().any(|job| job.status == "pending") { | |
| 327 | − | summary.status = "waiting".to_owned(); | |
| 328 | − | } | |
| 325 | + | summary.status = shown_status(&summary.status, jobs.iter().map(|job| job.status.as_str())); | |
| 329 | 326 | Ok(Outcome::Ok(RunDetail { | |
| 330 | 327 | notes: notes(&run.source), | |
| 331 | 328 | run: summary, | |
| ⋯ | |||
| 580 | 577 | ||
| 581 | 578 | /// The most runs `check_runs` reads for a set of commits. | |
| 582 | 579 | const CHECK_RUNS_LIMIT: u32 = 200; | |
| 580 | + | ||
| 581 | + | /// How the run's current attempt stands on its page: as its row says, and | |
| 582 | + | /// `waiting`, as on GitHub, while a job is held at an environment's rules. | |
| 583 | + | /// A job queued for a runner, or waiting for the jobs it needs, leaves a | |
| 584 | + | /// run that has started `in_progress`: it is still going. | |
| 585 | + | fn shown_status<'a>(status: &str, mut jobs: impl Iterator<Item = &'a str>) -> String { | |
| 586 | + | if matches!(status, "queued" | "in_progress") && jobs.any(|job| job == "pending") { | |
| 587 | + | "waiting".to_owned() | |
| 588 | + | } else { | |
| 589 | + | status.to_owned() | |
| 590 | + | } | |
| 591 | + | } | |
| 592 | + | ||
| 593 | + | #[cfg(test)] | |
| 594 | + | mod tests { | |
| 595 | + | use super::*; | |
| 596 | + | ||
| 597 | + | fn shown(status: &str, jobs: &[&str]) -> String { | |
| 598 | + | shown_status(status, jobs.iter().copied()) | |
| 599 | + | } | |
| 600 | + | ||
| 601 | + | #[test] | |
| 602 | + | fn a_job_queued_for_a_self_hosted_runner_keeps_its_run_going() { | |
| 603 | + | // A re-run of failed jobs: the job that failed now succeeded, and the | |
| 604 | + | // one that needs it waits for a runner with labels no runner has. | |
| 605 | + | assert_eq!(shown("in_progress", &["completed", "queued"]), "in_progress"); | |
| 606 | + | assert_eq!(shown("queued", &["waiting", "waiting"]), "queued"); | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | #[test] | |
| 610 | + | fn a_job_held_at_an_environment_makes_its_run_wait() { | |
| 611 | + | assert_eq!(shown("in_progress", &["completed", "pending"]), "waiting"); | |
| 612 | + | assert_eq!(shown("queued", &["pending"]), "waiting"); | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | #[test] | |
| 616 | + | fn a_finished_or_held_run_reads_as_its_row_says() { | |
| 617 | + | assert_eq!(shown("completed", &["completed"]), "completed"); | |
| 618 | + | assert_eq!(shown("action_required", &["waiting"]), "action_required"); | |
| 619 | + | assert_eq!(shown("pending", &["pending"]), "pending"); | |
| 620 | + | } | |
| 621 | + | } | |