diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte index 3be5de5c..de90b9fd 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte @@ -1,7 +1,12 @@ + + @@ -26,3 +58,8 @@ + + + + diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte index 4f11fd6a..47becc80 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte @@ -33,6 +33,15 @@ line: 22, gap: 8 }; + + // slack for a fractional scrollTop. it has to stay under one line, or a single + // appended line still reads as "at the end" and the tail creeps along after the + // reader took over + const END_SLACK = 8; + + // a scroll event lands a frame or two after the virtualizer sets scrollTop, so + // anything inside this window is ours and never counts as the reader scrolling + const COMMAND_WINDOW = 120; + + {#if steps.length === 0}
{#if status === "connecting"} @@ -175,94 +245,108 @@ {/if}
{:else} -
- {#if stickyInfo} - {@const slots = classes({ - collapsed: collapsed.has(stickyInfo.step.id), - first: rows[stickyInfo.index - 1]?.kind === "step", - last: isLastRow(rows, stickyInfo.index) - })} -
- -
- {/if} -
- {#each $virtualizer.getVirtualItems() as item (item.key)} - {@const row = rows[item.index]} - {#if row} - {@const slots = classes({ - collapsed: row.kind === "step" && collapsed.has(row.step.id), - first: rows[item.index - 1]?.kind === "step", - last: isLastRow(rows, item.index) - })} -
toggle(stickyInfo.step.id)} > - {#if row.kind === "step"} - - {:else if row.kind === "command"} -
-
{row.command}
-
- {:else if row.kind === "line"} -
- -
{@html row.html}
-
- {:else if row.kind === "gap"} -
+ {#if collapsed.has(stickyInfo.step.id)} +
- {/if} - {/each} + {stickyInfo.step.name} + + +
+ {/if} +
+ {#each $virtualizer.getVirtualItems() as item (item.key)} + {@const row = rows[item.index]} + {#if row} + {@const slots = classes({ + collapsed: row.kind === "step" && collapsed.has(row.step.id), + first: rows[item.index - 1]?.kind === "step", + last: isLastRow(rows, item.index) + })} +
+ {#if row.kind === "step"} + + {:else if row.kind === "command"} +
+
{row.command}
+
+ {:else if row.kind === "line"} +
+ +
{@html row.html}
+
+ {:else if row.kind === "gap"} +
+ {/if} +
+ {/if} + {/each} +
+ {#if live && detached} +
+ +
+ {/if} {/if} diff --git a/web/src/lib/components/repo/pipelines/WorkflowView.svelte b/web/src/lib/components/repo/pipelines/WorkflowView.svelte index 6a6fbb32..52cf8a6d 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowView.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowView.svelte @@ -3,7 +3,7 @@ import type { PipelineSummary, WorkflowSummary } from "$lib/components/repo/types"; import WorkflowLogs from "./WorkflowLogs.svelte"; import WorkflowSidebar from "./WorkflowSidebar.svelte"; - import { errorDetails, errorMessage } from "./pipeline"; + import { errorDetails, errorMessage, isLive } from "./pipeline"; import type { LogStep } from "./logs"; import type { LogStreamStatus } from "./logStream.svelte"; @@ -28,6 +28,8 @@ status, error = null }: Props = $props(); + + const live = $derived(isLive(workflow.status));
@@ -60,6 +62,6 @@
{/if} - + diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts index d5fe9890..a290ecac 100644 --- a/web/src/lib/components/repo/pipelines/mock.ts +++ b/web/src/lib/components/repo/pipelines/mock.ts @@ -36,7 +36,12 @@ export const pipelines: PipelineSummary[] = [ }, workflows: [ { name: "build", status: "success", duration: minutes(2) + 4_000 }, - { name: "test", status: "failed", duration: minutes(6) + 31_000, error: "3 tests failed" }, + { + name: "test", + status: "failed", + duration: minutes(6) + 31_000, + error: "3 tests failed" + }, { name: "lint", status: "success", duration: 41_000 } ] }, @@ -130,7 +135,10 @@ const drain = (frames: LogFrame[]): LogStep[] => { export const workflowLog: LogStep[] = drain([ control(-1, "microVM setup", { kind: "system" }), output(0, "starting microVM image alpine\n"), - output(0, "agent connected; serial log: /tmp/spindle-microvm-knot1.tangled.sh-3msfzd67fzh22-deploy-web-dev.yml-95471715/serial.log\n"), + output( + 0, + "agent connected; serial log: /tmp/spindle-microvm-knot1.tangled.sh-3msfzd67fzh22-deploy-web-dev.yml-95471715/serial.log\n" + ), finish(-1, 3), control(0, "Clone repository into workspace", { kind: "system", @@ -170,6 +178,57 @@ export const runningWorkflowLog: LogStep[] = drain([ output(1, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n") ]); +const tailFrames: LogFrame[] = [ + control(-1, "microVM setup", { kind: "system" }), + output(-1, "starting microVM image alpine\n"), + finish(-1, 3), + control(0, "install dependencies", { command: "pnpm install --frozen-lockfile" }), + ...Array.from({ length: 30 }, (_, index) => + output(0, `\u001b[90m+\u001b[0m package-${index} \u001b[32m1.${index}.0\u001b[0m\n`) + ), + finish(0, 41), + control(1, "run tests", { command: "pnpm test" }), + output(1, "\u001b[1mRUN\u001b[0m v4.1.10\n"), + ...Array.from({ length: 40 }, (_, index) => + output(1, `\u001b[32m✓\u001b[0m src/lib/suite-${index}.test.ts (${index + 2})\n`) + ), + finish(1, 96), + control(2, "build the site", { command: "pnpm build" }), + ...Array.from({ length: 40 }, (_, index) => + output(2, `[${index}] \u001b[36mbundle\u001b[0m src/module-${index}.ts\n`) + ) +]; + +// the tail is replayed live, so its stamps have to be too, or every step reads +// as an hour long +const restamp = (frame: LogFrame): LogFrame => { + const time = new Date().toISOString(); + if (frame.kind === "control") return { kind: "control", control: { ...frame.control, time } }; + if (frame.kind === "data") return { kind: "data", data: { ...frame.data, time } }; + return frame; +}; + +/** + * hands out a growing log, one frame per call, through the real accumulator. a + * story drives it on a timer, so the scroll behavior can be watched against a + * moving tail instead of a fixed fixture. + */ +export const createLogTail = (): { advance: () => LogStep[]; exhausted: () => boolean } => { + const accumulator = createLogAccumulator(); + let index = 0; + return { + advance() { + const frame = tailFrames[index]; + if (frame) { + index += 1; + accumulator.push(restamp(frame)); + } + return [...accumulator.steps]; + }, + exhausted: () => index >= tailFrames.length + }; +}; + /** a step big enough that the viewport can only hold a slice of it */ export const longWorkflowLog: LogStep[] = drain([ control(1, "build everything", { command: "make -j8" }),