diff --git a/web/src/lib/api/commitStatuses.ts b/web/src/lib/api/commitStatuses.ts index 03554e55e..67ac8e1d4 100644 --- a/web/src/lib/api/commitStatuses.ts +++ b/web/src/lib/api/commitStatuses.ts @@ -1,25 +1,17 @@ -import { browser } from "$app/environment"; import type { PipelineSummary } from "$lib/components/repo/types"; export type CommitStatuses = Record; -// the server has no spindle session, resolve empty so the boundary doesn't hang -const none: Promise = Promise.resolve({}); - -export const commitStatuses = ( - spindle: string | undefined, +export const fetchCommitStatuses = async ( ownerHandle: string, repoName: string, - shas: string[] -): Promise | undefined => { - // only repos with a spindle run CI - if (!spindle || shas.length === 0) return undefined; - if (!browser) return none; - + shas: string[], + signal?: AbortSignal +): Promise => { const params = new URLSearchParams(); for (const sha of new Set(shas)) params.append("sha", sha); const path = `/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(repoName)}/commit-statuses?${params}`; - return fetch(path) - .then((res) => (res.ok ? (res.json() as Promise) : ({} as CommitStatuses))) - .catch(() => ({})); + const response = await fetch(path, { signal }); + if (!response.ok) throw new Error(`commit statuses returned ${response.status}`); + return response.json() as Promise; }; diff --git a/web/src/lib/api/liveCommitStatuses.svelte.ts b/web/src/lib/api/liveCommitStatuses.svelte.ts new file mode 100644 index 000000000..20dbc399e --- /dev/null +++ b/web/src/lib/api/liveCommitStatuses.svelte.ts @@ -0,0 +1,68 @@ +import { browser } from "$app/environment"; +import { untrack } from "svelte"; +import { fetchCommitStatuses, type CommitStatuses } from "$lib/api/commitStatuses"; +import { pollPipelineCollection } from "$lib/components/repo/pipelines/livePipelines"; +import type { RepoInfo } from "$lib/components/repo/types"; + +interface CommitStatusTarget { + repo: RepoInfo; + shas: string[]; +} + +const replaceStatuses = (current: CommitStatuses, updated: CommitStatuses) => { + for (const sha of Object.keys(current)) { + if (!(sha in updated)) delete current[sha]; + } + Object.assign(current, updated); +}; + +export const liveCommitStatuses = ( + target: () => Promise +): Promise => { + if (!browser) return Promise.resolve({}); + + const current = $state({}); + $effect(() => { + const pendingTarget = target(); + let stopped = false; + let stopPolling = () => {}; + untrack(() => replaceStatuses(current, {})); + + void pendingTarget + .then(async (resolved) => { + if (!resolved.repo.spindle || resolved.shas.length === 0) return; + const initial = await fetchCommitStatuses( + resolved.repo.ownerHandle, + resolved.repo.name, + resolved.shas + ); + if (stopped) return; + replaceStatuses(current, initial); + stopPolling = pollPipelineCollection( + Object.values(initial), + async (_pipelines, signal) => + Object.values( + await fetchCommitStatuses( + resolved.repo.ownerHandle, + resolved.repo.name, + resolved.shas, + signal + ) + ), + (updated) => + replaceStatuses( + current, + Object.fromEntries(updated.map((pipeline) => [pipeline.sha, pipeline])) + ) + ); + }) + .catch(() => {}); + + return () => { + stopped = true; + stopPolling(); + }; + }); + + return Promise.resolve(current); +}; diff --git a/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte index 6336376fb..93d78da30 100644 --- a/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte @@ -1,17 +1,65 @@ -{#if pipelines.length > 0} +{#if current.length > 0}
- {#each pipelines as pipeline (pipeline.id)} + {#each current as pipeline (pipeline.id)} {/each}
diff --git a/web/src/lib/components/repo/pipelines/WorkflowRunView.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowRunView.stories.svelte index 23e13d39e..8399b4061 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowRunView.stories.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowRunView.stories.svelte @@ -12,6 +12,7 @@ ownerHandle: "tangled.org", repoName: "core", spindle: "https://spindle.example.test", + repoDid: "did:plc:repo", pipeline, workflow } diff --git a/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte index f6c22f956..dfbbff48f 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte @@ -5,17 +5,40 @@ import { isLive } from "$lib/components/repo/pipelines/pipeline"; import { LogStream } from "$lib/components/repo/pipelines/logStream.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; + import { createSpindleClient, getPipeline, toPipelineSummary } from "$lib/api/spindle"; + import { pollPipelineCollection } from "./livePipelines"; + import { untrack } from "svelte"; // the log stream's effects need a sync context, so the page awaits before rendering this interface Props { ownerHandle: string; repoName: string; spindle: string; + repoDid: string; pipeline: PipelineSummary; workflow: WorkflowSummary; } - let { ownerHandle, repoName, spindle, pipeline, workflow }: Props = $props(); + let { ownerHandle, repoName, spindle, repoDid, pipeline, workflow }: Props = $props(); + + let currentPipeline = $state.raw(untrack(() => pipeline)); + const currentWorkflow = $derived( + currentPipeline.workflows.find((candidate) => candidate.name === workflow.name) ?? workflow + ); + + $effect(() => { + currentPipeline = pipeline; + const ctx = createSpindleClient(spindle, fetch); + return pollPipelineCollection( + [pipeline], + async (_pipelines, signal) => { + const result = await getPipeline(ctx, { pipeline: pipeline.id }, { signal }); + if (result.repo !== repoDid) throw new Error("pipeline belongs to another repo"); + return [{ ...pipeline, workflows: toPipelineSummary(result, repoDid).workflows }]; + }, + (updated) => ([currentPipeline] = updated) + ); + }); const stream = $derived( new LogStream({ @@ -34,15 +57,15 @@ - +
({ + id: "3lz", + sha: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b", + createdAt: "2026-08-24T12:00:00Z", + trigger: { kind: "manual" }, + workflows: [{ name: "build", status, duration: 0 }] +}); + +const visible = { + isHidden: () => false, + subscribe: () => () => {} +}; + +afterEach(() => vi.useRealTimers()); + +describe("pollPipelineCollection", () => { + it("loads the whole collection every five seconds until it settles", async () => { + vi.useFakeTimers(); + const updates: PipelineSummary[][] = []; + const load = vi.fn().mockResolvedValue([pipeline("success")]); + const stop = pollPipelineCollection( + [pipeline("running"), pipeline("pending")], + load, + (updated) => updates.push(updated), + { visibility: visible } + ); + + await vi.advanceTimersByTimeAsync(4_999); + expect(load).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(load).toHaveBeenCalledOnce(); + expect(load.mock.calls[0][0]).toHaveLength(2); + expect(updates[0][0].workflows[0].status).toBe("success"); + expect(vi.getTimerCount()).toBe(0); + stop(); + }); + + it("retains a live snapshot and retries after a transient failure", async () => { + vi.useFakeTimers(); + const load = vi.fn().mockRejectedValue(new Error("offline")); + const stop = pollPipelineCollection([pipeline("running")], load, () => {}, { + visibility: visible + }); + + await vi.advanceTimersByTimeAsync(5_000); + expect(load).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(5_000); + expect(load).toHaveBeenCalledTimes(2); + stop(); + }); + + it("waits while hidden and refreshes immediately when visible", async () => { + vi.useFakeTimers(); + let hidden = true; + let visibilityListener = () => {}; + const load = vi.fn().mockResolvedValue([pipeline("running")]); + const stop = pollPipelineCollection([pipeline("pending")], load, () => {}, { + visibility: { + isHidden: () => hidden, + subscribe: (listener) => { + visibilityListener = listener; + return () => {}; + } + } + }); + + expect(vi.getTimerCount()).toBe(0); + hidden = false; + visibilityListener(); + await vi.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(1); + stop(); + }); + + it("does nothing when every pipeline is already terminal", () => { + vi.useFakeTimers(); + const load = vi.fn(); + + pollPipelineCollection([pipeline("failed")], load, () => {}, { visibility: visible }); + + expect(load).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/web/src/lib/components/repo/pipelines/livePipelines.ts b/web/src/lib/components/repo/pipelines/livePipelines.ts new file mode 100644 index 000000000..5793939eb --- /dev/null +++ b/web/src/lib/components/repo/pipelines/livePipelines.ts @@ -0,0 +1,86 @@ +import type { PipelineSummary } from "$lib/components/repo/types"; +import { aggregateStatus, isLive } from "./pipeline"; + +const POLL_INTERVAL = 5_000; + +interface Visibility { + isHidden: () => boolean; + subscribe: (listener: () => void) => () => void; +} + +interface PollPipelineOptions { + visibility?: Visibility; +} + +const pageVisibility: Visibility = { + isHidden: () => document.hidden, + subscribe: (listener) => { + document.addEventListener("visibilitychange", listener); + return () => document.removeEventListener("visibilitychange", listener); + } +}; + +const hasLivePipelines = (pipelines: PipelineSummary[]): boolean => + pipelines.some((pipeline) => isLive(aggregateStatus(pipeline))); + +export const pollPipelineCollection = ( + initial: PipelineSummary[], + load: (current: PipelineSummary[], signal: AbortSignal) => Promise, + onUpdate: (pipelines: PipelineSummary[]) => void, + options: PollPipelineOptions = {} +): (() => void) => { + if (!hasLivePipelines(initial)) return () => {}; + + const visibility = options.visibility ?? pageVisibility; + let current = initial; + let controller: AbortController | undefined; + let timer: number | NodeJS.Timeout | undefined; + let stopped = false; + + const stopTimer = () => { + clearInterval(timer); + timer = undefined; + }; + + const refresh = async () => { + if (stopped || controller || visibility.isHidden()) return; + controller = new AbortController(); + try { + const updated = await load(current, controller.signal); + if (!stopped) { + current = updated; + onUpdate(updated); + } + } catch { + // keep the last good snapshot and try again while it still says live + } finally { + controller = undefined; + if (!hasLivePipelines(current)) stopTimer(); + } + }; + + const startTimer = () => { + if (timer || visibility.isHidden() || !hasLivePipelines(current)) return; + timer = setInterval(() => void refresh(), POLL_INTERVAL); + }; + + const onVisibilityChange = () => { + if (visibility.isHidden()) { + stopTimer(); + controller?.abort(); + return; + } + void refresh(); + startTimer(); + }; + + const unsubscribeVisibility = visibility.subscribe(onVisibilityChange); + startTimer(); + + return () => { + stopped = true; + stopTimer(); + controller?.abort(); + unsubscribeVisibility(); + }; +}; diff --git a/web/src/routes/[handle]/[repo]/+page.svelte b/web/src/routes/[handle]/[repo]/+page.svelte index 93171b4e9..bc6ec51fc 100644 --- a/web/src/routes/[handle]/[repo]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/+page.svelte @@ -1,20 +1,16 @@ diff --git a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte index 576bd4941..dda674982 100644 --- a/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/commit/[ref]/+page.svelte @@ -1,6 +1,6 @@ @@ -34,11 +41,6 @@ stat={commitDiff.stat} {diffStyle} prerendered={await data.prerendered} - pipelineStatuses={commitStatuses( - repo.spindle, - repo.ownerHandle, - repo.name, - commit.hash ? [commit.hash] : [] - )} + {pipelineStatuses} /> diff --git a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte index 86dcb2810..514725787 100644 --- a/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/commits/[ref]/+page.svelte @@ -1,7 +1,7 @@ @@ -32,12 +39,7 @@ ref={data.ref} {commits} tagsByCommit={data.tagsByCommit} - pipelineStatuses={commitStatuses( - repo.spindle, - repo.ownerHandle, - repo.name, - commits.map((commit) => commit.hash) - )} + {pipelineStatuses} page={data.page} pageCount={await data.pageCount} onPageChange={changePage} diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte index 0a5b1b28b..c5c5fe1de 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte @@ -49,6 +49,9 @@ ownerHandle={repo.ownerHandle} ownerDid={repo.ownerDid} repoName={repo.name} + spindle={repo.spindle} + repoDid={repo.repoDid} + filter={data.filter} pipelines={await data.pipelines} filtered={await data.filtered} forkLabels={data.forkLabels} diff --git a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte index 35c3608d5..20970e0a5 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte @@ -24,6 +24,7 @@ ownerHandle={repo.ownerHandle} repoName={repo.name} {spindle} + repoDid={repo.repoDid!} {pipeline} {workflow} /> diff --git a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte index 7db64d9d9..85a44d1d6 100644 --- a/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/tree/[ref]/+page.svelte @@ -1,5 +1,5 @@