From 6083fb733c567cf7342b40d4be0100e39b87602f Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 6 Aug 2026 14:46:08 +0300 Subject: [PATCH] web: add workflow logs streaming and detail page Signed-off-by: dawn --- web/package.json | 3 + web/pnpm-lock.yaml | 77 ++++-- web/src/app.css | 38 +++ web/src/lib/api/spindle.ts | 4 +- web/src/lib/api/spindleLogs.test.ts | 88 ++++++ web/src/lib/api/spindleLogs.ts | 94 +++++++ .../components/repo/pipelines/Elapsed.svelte | 30 ++ .../repo/pipelines/PipelineCardContent.svelte | 1 - .../repo/pipelines/PipelineWorkflows.svelte | 6 +- .../pipelines/WorkflowLogs.stories.svelte | 28 ++ .../repo/pipelines/WorkflowLogs.svelte | 259 ++++++++++++++++++ .../pipelines/WorkflowSidebar.stories.svelte | 28 ++ .../repo/pipelines/WorkflowSidebar.svelte | 81 ++++++ .../repo/pipelines/logStream.svelte.ts | 102 +++++++ .../components/repo/pipelines/logs.test.ts | 138 ++++++++++ web/src/lib/components/repo/pipelines/logs.ts | 122 +++++++++ web/src/lib/components/repo/pipelines/mock.ts | 83 ++++++ .../lib/components/repo/pipelines/pipeline.ts | 15 + web/src/lib/components/repo/types.ts | 4 + web/src/logs.css | 101 +++++++ .../workflow/[workflow]/+page.svelte | 69 +++++ .../[pipeline]/workflow/[workflow]/+page.ts | 26 ++ 22 files changed, 1372 insertions(+), 25 deletions(-) create mode 100644 web/src/lib/api/spindleLogs.test.ts create mode 100644 web/src/lib/api/spindleLogs.ts create mode 100644 web/src/lib/components/repo/pipelines/Elapsed.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowLogs.stories.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowLogs.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowSidebar.stories.svelte create mode 100644 web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte create mode 100644 web/src/lib/components/repo/pipelines/logStream.svelte.ts create mode 100644 web/src/lib/components/repo/pipelines/logs.test.ts create mode 100644 web/src/lib/components/repo/pipelines/logs.ts create mode 100644 web/src/logs.css create mode 100644 web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte create mode 100644 web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts diff --git a/web/package.json b/web/package.json index 6c9ddae8..b44226ef 100644 --- a/web/package.json +++ b/web/package.json @@ -74,6 +74,7 @@ }, "dependencies": { "@atcute/atproto": "^4.0.3", + "@atcute/cbor": "^2.3.6", "@atcute/client": "^5.1.1", "@atcute/identity-resolver": "^2.0.1", "@atcute/lexicons": "^2.0.2", @@ -83,6 +84,8 @@ "@mdit/plugin-footnote": "^1.0.1", "@mdit/plugin-tasklist": "^1.0.1", "@pierre/diffs": "^1.2.12", + "@tanstack/svelte-virtual": "^3.13.35", + "ansi_up": "^6.0.6", "markdown-it": "^14.3.0", "markdown-it-anchor": "^9.2.1", "markdown-it-emoji": "^3.1.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 63fcccab..209bf142 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@atcute/atproto': specifier: ^4.0.3 version: 4.0.3(@atcute/lexicons@2.0.2) + '@atcute/cbor': + specifier: ^2.3.6 + version: 2.3.6(@atcute/cid@2.4.2) '@atcute/client': specifier: ^5.1.1 version: 5.1.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) @@ -38,6 +41,12 @@ importers: '@pierre/diffs': specifier: ^1.2.12 version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/svelte-virtual': + specifier: ^3.13.35 + version: 3.13.35(svelte@5.56.5(@typescript-eslint/types@8.64.0)) + ansi_up: + specifier: ^6.0.6 + version: 6.0.6 markdown-it: specifier: ^14.3.0 version: 14.3.0 @@ -59,7 +68,7 @@ importers: devDependencies: '@atcute/lex-cli': specifier: 3.2.1 - version: 3.2.1(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.5)(typescript@6.0.3) + version: 3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.5)(typescript@6.0.3) '@chromatic-com/storybook': specifier: ^5.2.1 version: 5.2.1(storybook@10.5.0(@types/react@19.2.18)(prettier@3.9.5)(react@19.2.8)) @@ -215,8 +224,8 @@ packages: '@atcute/cbor': ^2.0.0 '@atcute/cid': ^2.0.0 - '@atcute/cbor@2.3.5': - resolution: {integrity: sha512-NvgeibMqtfeD6/4NMTnBwhjyB/F945UdnzeZMEsGb2eXSalOBzutgNmpnBPTj5FiN6Sl9VYhE3qDQfyox+P8aw==} + '@atcute/cbor@2.3.6': + resolution: {integrity: sha512-Ml/G8i6KK5UWSx1kxgm41eTYm1EQ6O2fNWSXMJ5DxoQv/lmZ0Hmk7dmHda2seRE+Cj2X3HJKAuD9cQRh3m2P7A==} peerDependencies: '@atcute/cid': ^2.0.0 @@ -276,6 +285,9 @@ packages: '@atcute/multibase@1.2.4': resolution: {integrity: sha512-WeX12hvFZEim6C+cyv7Eqd93w6DzubNWQGmTFBghjsEuXvMe4HbBCYvsti0OUnbA5qLBPlsTyssQUJeLlHCzIw==} + '@atcute/multibase@1.2.5': + resolution: {integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==} + '@atcute/oauth-browser-client@4.0.1': resolution: {integrity: sha512-k5GGDFWUqTifbi7bMk4LhwG12XWNJop9atZ3ocBpzHJvXboTqp7E2zaD/arhJWxHXp5+0A1R3EKXdHsVbWjHyA==} peerDependencies: @@ -307,6 +319,9 @@ packages: '@atcute/uint8array@1.1.4': resolution: {integrity: sha512-rSW5AFVCIN4ooH7vEZB+J60+uWjn5fRBQAQL58qHLiDm8+xDPmHfEU5GfYOJuuD+7UBj8KKiQugIzohFn8/xPw==} + '@atcute/uint8array@1.1.5': + resolution: {integrity: sha512-1SFCXOtjE3ismP92CqzbOfYSwJmPPPFbj798wLXSpLv2hSd5OIaUrTh6EqRVR1VZ3ZhR+vaBmd3kxAN0wkw+gQ==} + '@atcute/util-fetch@2.0.1': resolution: {integrity: sha512-ugWTOLemA8OxSOj7c8q6ncRmBGFDHSwwE1YinO+PCtaw6WLQFGBfHn+yikQ0e3wTK2t4IPjQ5PxZcRXm961ZVA==} @@ -1751,6 +1766,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/svelte-virtual@3.13.35': + resolution: {integrity: sha512-4ur3bPzSXnZZtQWwns/ziCR9pJ/4x/AlBIeDZu96MKrRMbZBL1TQkGiO+ZaX5zCgdpXUZwJnehKzBp32KOumCQ==} + peerDependencies: + svelte: ^3.48.0 || ^4.0.0 || ^5.0.0 + + '@tanstack/virtual-core@3.17.7': + resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2004,6 +2027,9 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi_up@6.0.6: + resolution: {integrity: sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3612,18 +3638,18 @@ snapshots: dependencies: '@atcute/lexicons': 2.0.2 - '@atcute/car@6.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': + '@atcute/car@6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': dependencies: - '@atcute/cbor': 2.3.5(@atcute/cid@2.4.2) + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) '@atcute/cid': 2.4.2 '@atcute/uint8array': 1.1.4 '@atcute/varint': 2.0.1 - '@atcute/cbor@2.3.5(@atcute/cid@2.4.2)': + '@atcute/cbor@2.3.6(@atcute/cid@2.4.2)': dependencies: '@atcute/cid': 2.4.2 - '@atcute/multibase': 1.2.4 - '@atcute/uint8array': 1.1.4 + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.1.5 '@atcute/cid@2.4.2': dependencies: @@ -3659,12 +3685,12 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/lex-cli@3.2.1(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.5)(typescript@6.0.3)': + '@atcute/lex-cli@3.2.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(prettier@3.9.5)(typescript@6.0.3)': dependencies: '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/lexicon-doc': 3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) - '@atcute/lexicon-resolver': 1.0.1(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) + '@atcute/lexicon-resolver': 1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/lexicons': 2.0.2 '@oomfware/kempt': 0.1.4 '@optique/core': 1.1.1 @@ -3688,14 +3714,14 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/lexicon-resolver@1.0.1(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3)': + '@atcute/lexicon-resolver@1.0.1(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicon-doc@3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3)': dependencies: '@atcute/crypto': 2.4.2 '@atcute/identity': 2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/identity-resolver': 2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/lexicon-doc': 3.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) '@atcute/lexicons': 2.0.2 - '@atcute/repo': 1.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.2) + '@atcute/repo': 1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.2) '@atcute/util-fetch': 2.0.1(typescript@6.0.3) valibot: 1.4.2(typescript@6.0.3) transitivePeerDependencies: @@ -3710,9 +3736,9 @@ snapshots: '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 - '@atcute/mst@1.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': + '@atcute/mst@1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)': dependencies: - '@atcute/cbor': 2.3.5(@atcute/cid@2.4.2) + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) '@atcute/cid': 2.4.2 '@atcute/uint8array': 1.1.4 @@ -3720,6 +3746,10 @@ snapshots: dependencies: '@atcute/uint8array': 1.1.4 + '@atcute/multibase@1.2.5': + dependencies: + '@atcute/uint8array': 1.1.5 + '@atcute/oauth-browser-client@4.0.1(@atcute/identity-resolver@2.0.1(@atcute/identity@2.0.1(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3))(@atcute/lexicons@2.0.2)(typescript@6.0.3)': dependencies: '@atcute/client': 5.1.1(@atcute/lexicons@2.0.2)(typescript@6.0.3) @@ -3756,14 +3786,14 @@ snapshots: transitivePeerDependencies: - typescript - '@atcute/repo@1.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.2)': + '@atcute/repo@1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2)(@atcute/lexicons@2.0.2)': dependencies: - '@atcute/car': 6.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) - '@atcute/cbor': 2.3.5(@atcute/cid@2.4.2) + '@atcute/car': 6.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) + '@atcute/cbor': 2.3.6(@atcute/cid@2.4.2) '@atcute/cid': 2.4.2 '@atcute/crypto': 2.4.2 '@atcute/lexicons': 2.0.2 - '@atcute/mst': 1.0.2(@atcute/cbor@2.3.5(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) + '@atcute/mst': 1.0.2(@atcute/cbor@2.3.6(@atcute/cid@2.4.2))(@atcute/cid@2.4.2) '@atcute/uint8array': 1.1.4 '@atcute/tid@1.1.4': @@ -3774,6 +3804,8 @@ snapshots: '@atcute/uint8array@1.1.4': {} + '@atcute/uint8array@1.1.5': {} + '@atcute/util-fetch@2.0.1(typescript@6.0.3)': dependencies: valibot: 1.4.2(typescript@6.0.3) @@ -4869,6 +4901,13 @@ snapshots: tailwindcss: 4.3.2 vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + '@tanstack/svelte-virtual@3.13.35(svelte@5.56.5(@typescript-eslint/types@8.64.0))': + dependencies: + '@tanstack/virtual-core': 3.17.7 + svelte: 5.56.5(@typescript-eslint/types@8.64.0) + + '@tanstack/virtual-core@3.17.7': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -5196,6 +5235,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi_up@6.0.6: {} + argparse@2.0.1: {} aria-query@5.3.0: diff --git a/web/src/app.css b/web/src/app.css index acaf90b3..e77fabe1 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "./markup.css"; @import "./pierre.css"; +@import "./logs.css"; @theme { --font-sans: "InterVariable", "system-ui", sans-serif, ui-sans-serif; @@ -146,6 +147,25 @@ --color-alpha-dark-90: color-mix(in srgb, var(--color-foreground-default) 90%, transparent); --color-alpha-dark-95: color-mix(in srgb, var(--color-foreground-default) 95%, transparent); + /* --- terminal (pipeline logs) --- catppuccin latte, same values the + appview's terminal-to-html palette uses. dark is macchiato, below. */ + --color-ansi-black: #6c6f85; + --color-ansi-red: #d20f39; + --color-ansi-green: #40a02b; + --color-ansi-yellow: #df8e1d; + --color-ansi-blue: #1e66f5; + --color-ansi-magenta: #8839ef; + --color-ansi-cyan: #179299; + --color-ansi-white: #4c4f69; + --color-ansi-bright-black: #8c8fa1; + --color-ansi-bright-red: #e64553; + --color-ansi-bright-green: #40a02b; + --color-ansi-bright-yellow: #e5c890; + --color-ansi-bright-blue: #209fb5; + --color-ansi-bright-magenta: #ea76cb; + --color-ansi-bright-cyan: #04a5e5; + --color-ansi-bright-white: #bcc0cc; + /* --- animations --- */ --animate-spin-step: spin-step 2.5s infinite; @@ -409,6 +429,24 @@ --color-shadow-inner-subtle: #111827; --color-shadow-ambient-strong: transparent; --color-shadow-ambient-subtle: transparent; + + /* --- terminal (Dark) --- catppuccin macchiato */ + --color-ansi-black: #a5adcb; + --color-ansi-red: #ed8796; + --color-ansi-green: #a6da95; + --color-ansi-yellow: #eed49f; + --color-ansi-blue: #8aadf4; + --color-ansi-magenta: #c6a0f6; + --color-ansi-cyan: #8bd5ca; + --color-ansi-white: #cad3f5; + --color-ansi-bright-black: #8087a2; + --color-ansi-bright-red: #f5bde6; + --color-ansi-bright-green: #a6da95; + --color-ansi-bright-yellow: #f5a97f; + --color-ansi-bright-blue: #7dc4e4; + --color-ansi-bright-magenta: #f4dbd6; + --color-ansi-bright-cyan: #91d7e3; + --color-ansi-bright-white: #f0c6c6; } } diff --git a/web/src/lib/api/spindle.ts b/web/src/lib/api/spindle.ts index 98ce2a37..135331fe 100644 --- a/web/src/lib/api/spindle.ts +++ b/web/src/lib/api/spindle.ts @@ -68,6 +68,8 @@ export const toPipelineSummary = (pipeline: Pipeline.Main, repoDid?: string): Pi name: workflow.name, status: workflow.status as PipelineStatus, duration: durationOf(workflow), - error: workflow.error + error: workflow.error, + startedAt: workflow.startedAt, + finishedAt: workflow.finishedAt })) }); diff --git a/web/src/lib/api/spindleLogs.test.ts b/web/src/lib/api/spindleLogs.test.ts new file mode 100644 index 00000000..cad9130a --- /dev/null +++ b/web/src/lib/api/spindleLogs.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { decodeLogFrame, pipelineLogsUrl } from "./spindleLogs"; + +// captured off the spindle's own serializer: CiSubscribePipelineLogs_Event +// .Serialize in api/tangled/cisubscribePipelineLogs_ext.go, hex-dumped +const frames = { + controlStart: + "a261746823636f6e74726f6c626f7001a7646b696e6464757365726473746570016474696d6574323032362d30382d30365431323a30303a30305a6673746174757365737461727467636f6d6d616e64676563686f20686967636f6e74656e746952756e20746573747368776f726b666c6f77656275696c64", + controlMinimal: + "a261746823636f6e74726f6c626f7001a46473746570016474696d6574323032362d30382d30365431323a30303a30395a67636f6e74656e746952756e20746573747368776f726b666c6f77656275696c64", + data: "a26174652364617461626f7001a56473746570016474696d6574323032362d30382d30365431323a30303a30315a6673747265616d6673746465727267636f6e74656e746e1b5b33316d626f6f6d1b5b306d0a68776f726b666c6f77656275696c64", + error: + "a1626f7020a2656572726f7270576f726b666c6f774e6f74466f756e64676d657373616765706e6f207375636820776f726b666c6f77" +} as const; + +const bytes = (hex: string): Uint8Array => + Uint8Array.from(hex.match(/../g)!.map((pair) => parseInt(pair, 16))); + +describe("decodeLogFrame", () => { + it("reads a step start with its command and kind", () => { + expect(decodeLogFrame(bytes(frames.controlStart))).toEqual({ + kind: "control", + control: { + time: "2026-08-06T12:00:00Z", + workflow: "build", + step: 1, + content: "Run tests", + command: "echo hi", + status: "start", + kind: "user" + } + }); + }); + + it("leaves the optional control fields off when the spindle omits them", () => { + const frame = decodeLogFrame(bytes(frames.controlMinimal)); + expect(frame).toEqual({ + kind: "control", + control: { + time: "2026-08-06T12:00:09Z", + workflow: "build", + step: 1, + content: "Run tests" + } + }); + }); + + it("keeps a data line's ansi bytes and trailing newline intact", () => { + expect(decodeLogFrame(bytes(frames.data))).toEqual({ + kind: "data", + data: { + time: "2026-08-06T12:00:01Z", + workflow: "build", + step: 1, + content: "\u001b[31mboom\u001b[0m\n", + stream: "stderr" + } + }); + }); + + it("surfaces the stream's error frame", () => { + expect(decodeLogFrame(bytes(frames.error))).toEqual({ + kind: "error", + error: "WorkflowNotFound", + message: "no such workflow" + }); + }); + + it("ignores a message type it doesn't know", () => { + // same header shape, unknown t + const unknown = "a2617464236e6577626f7001a0"; + expect(decodeLogFrame(bytes(unknown))).toBeNull(); + }); +}); + +describe("pipelineLogsUrl", () => { + it("upgrades the spindle's scheme and repeats the workflow param", () => { + expect(pipelineLogsUrl("spindle.tangled.org", "3lz", ["build", "test"])).toBe( + "wss://spindle.tangled.org/xrpc/sh.tangled.ci.subscribePipelineLogs?pipeline=3lz&workflows=build&workflows=test" + ); + }); + + it("stays plaintext for a local spindle", () => { + expect(pipelineLogsUrl("localhost:6555", "3lz", ["build"])).toBe( + "ws://localhost:6555/xrpc/sh.tangled.ci.subscribePipelineLogs?pipeline=3lz&workflows=build" + ); + }); +}); diff --git a/web/src/lib/api/spindleLogs.ts b/web/src/lib/api/spindleLogs.ts new file mode 100644 index 00000000..7e9dc84a --- /dev/null +++ b/web/src/lib/api/spindleLogs.ts @@ -0,0 +1,94 @@ +// spindle websocket log stream +// replays from line one on connect and closes when all workflows settle + +import { decodeFirst } from "@atcute/cbor"; +import { spindleUrl } from "./spindle"; +import type * as SubscribeLogs from "./lexicons/types/sh/tangled/ci/subscribePipelineLogs"; + +export type LogControl = SubscribeLogs.Control; + +export type LogData = SubscribeLogs.Data; + +export type LogFrame = + | { kind: "control"; control: LogControl } + | { kind: "data"; data: LogData } + | { kind: "error"; error: string; message: string }; + +// indigo event header ops +const OP_MESSAGE = 1; +const OP_ERROR = -1; + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null ? (value as Record) : {}; + +const str = (value: unknown): string => (typeof value === "string" ? value : ""); + +// decodes binary websocket frame into dag-cbor header and body +export const decodeLogFrame = (bytes: Uint8Array): LogFrame | null => { + const [rawHeader, rest] = decodeFirst(bytes); + const header = asRecord(rawHeader); + if (header.op !== OP_MESSAGE && header.op !== OP_ERROR) return null; + + const [rawBody] = decodeFirst(rest); + const body = asRecord(rawBody); + + if (header.op === OP_ERROR) { + return { kind: "error", error: str(body.error), message: str(body.message) }; + } + + switch (header.t) { + case "#control": + return { kind: "control", control: body as unknown as LogControl }; + case "#data": + return { kind: "data", data: body as unknown as LogData }; + default: + return null; + } +}; + +export const pipelineLogsUrl = (host: string, pipeline: string, workflows: string[]): string => { + const url = new URL( + "/xrpc/sh.tangled.ci.subscribePipelineLogs", + `${spindleUrl(host).replace(/^http/, "ws")}/` + ); + url.searchParams.set("pipeline", pipeline); + for (const workflow of workflows) url.searchParams.append("workflows", workflow); + return url.toString(); +}; + +export interface LogStreamHandlers { + onFrame: (frame: LogFrame) => void; + onClose?: (clean: boolean) => void; +} + +// opens log subscription, returns teardown function +export const subscribePipelineLogs = ( + host: string, + pipeline: string, + workflows: string[], + handlers: LogStreamHandlers +): (() => void) => { + const socket = new WebSocket(pipelineLogsUrl(host, pipeline, workflows)); + socket.binaryType = "arraybuffer"; + let live = true; + + socket.onmessage = (event: MessageEvent) => { + if (!live || typeof event.data === "string") return; + const frame = decodeLogFrame(new Uint8Array(event.data)); + if (frame) handlers.onFrame(frame); + }; + + socket.onclose = (event: CloseEvent) => { + if (!live) return; + live = false; + handlers.onClose?.(event.wasClean); + }; + + // refused upgrades trigger an error then close, close handler handles it + socket.onerror = () => {}; + + return () => { + live = false; + socket.close(); + }; +}; diff --git a/web/src/lib/components/repo/pipelines/Elapsed.svelte b/web/src/lib/components/repo/pipelines/Elapsed.svelte new file mode 100644 index 00000000..42204187 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/Elapsed.svelte @@ -0,0 +1,30 @@ + + +{#if elapsed !== null} + {formatDuration(elapsed)} +{/if} diff --git a/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte index a1f8d61b..ea97e10c 100644 --- a/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte @@ -63,7 +63,6 @@ const duration = $derived(totalDuration(pipeline)); // links to the first workflow's logs, plain text until a spindle reports one - // TODO: the workflow log route does not exist in the svelte app yet const href = $derived( pipeline.workflows.length > 0 ? resolve( diff --git a/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte index 3da060f6..ed251e27 100644 --- a/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineWorkflows.svelte @@ -69,7 +69,7 @@ pipeline, variant = "count", summary = "short", - icon, + icon: Icon, iconClass, size = "regular" }: Props = $props(); @@ -77,9 +77,6 @@ const count = $derived(pipeline.workflows.length); const finished = $derived(finishedCount(pipeline)); const noun = $derived(count === 1 ? "workflow" : "workflows"); - // show the ratio only while something is still running - const label = $derived(finished === count ? `${count} ${noun}` : `${finished}/${count} ${noun}`); - const Icon = $derived(icon); const classes = $derived(pipelineWorkflows({ variant })); @@ -128,7 +125,6 @@ {/snippet} {#each pipeline.workflows as workflow (workflow.name)} - + import { defineMeta } from "@storybook/addon-svelte-csf"; + import WorkflowLogs from "./WorkflowLogs.svelte"; + import { longWorkflowLog, runningWorkflowLog, workflowLog } from "./mock"; + + const { Story } = defineMeta({ + title: "Repo/Pipelines/WorkflowLogs", + component: WorkflowLogs, + tags: ["autodocs"], + args: { + steps: workflowLog, + status: "done" as const + } + }); + + + + + + + + + + + + + + diff --git a/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte new file mode 100644 index 00000000..95d75e17 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowLogs.svelte @@ -0,0 +1,259 @@ + + + + +{#if steps.length === 0} +
+ {#if status === "connecting"} + Waiting for the spindle + {:else} +
+{: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) + })} +
+ {#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} + +{#if error} +
+
+{:else if status === "connecting" && steps.length > 0} +
Reconnecting to the spindle
+{/if} diff --git a/web/src/lib/components/repo/pipelines/WorkflowSidebar.stories.svelte b/web/src/lib/components/repo/pipelines/WorkflowSidebar.stories.svelte new file mode 100644 index 00000000..dc1b6aac --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowSidebar.stories.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte new file mode 100644 index 00000000..5b53ef86 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/WorkflowSidebar.svelte @@ -0,0 +1,81 @@ + + + + +
+ {#each workflows as workflow (workflow.name)} + {@const slots = classes({ active: workflow.name === active })} +
+ +
+
+ + {STATUS_LABELS[workflow.status]} +
+ {#if workflow.duration > 0} + {formatDuration(workflow.duration)} + {/if} +
+
+ {/each} +
diff --git a/web/src/lib/components/repo/pipelines/logStream.svelte.ts b/web/src/lib/components/repo/pipelines/logStream.svelte.ts new file mode 100644 index 00000000..69b44001 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/logStream.svelte.ts @@ -0,0 +1,102 @@ +import { subscribePipelineLogs } from "$lib/api/spindleLogs"; +import { createLogAccumulator, type LogStep } from "./logs"; + +export type LogStreamStatus = + | "connecting" + | "streaming" + | "done" + | "dropped"; + +export interface LogStreamTarget { + host: string; + pipeline: string; + workflow: string; + live: boolean; +} + +// reconnects replay from line one, so keep retries short +const RETRY_DELAYS = [1_000, 2_000, 4_000]; + +// workflow live log subscription state +export class LogStream { + steps = $state.raw([]); + status = $state("connecting"); + error = $state(null); + + readonly #target: LogStreamTarget; + #accumulator = createLogAccumulator(); + #close: (() => void) | null = null; + #flush: number | undefined; + #retry: number | undefined; + #attempt = 0; + #dirty = false; + + constructor(target: LogStreamTarget) { + this.#target = target; + } + + open(): void { + const { host, pipeline, workflow, live } = this.#target; + this.#close = subscribePipelineLogs(host, pipeline, [workflow], { + onFrame: (frame) => { + if (this.status === "connecting") this.status = "streaming"; + if (frame.kind === "error") { + this.error = frame.message || frame.error; + return; + } + // ignore frames for other workflows + const name = frame.kind === "control" ? frame.control.workflow : frame.data.workflow; + if (name !== workflow) return; + this.#dirty = this.#accumulator.push(frame) || this.#dirty; + this.#schedule(); + }, + onClose: (clean) => { + this.#close = null; + this.#render(); + if (clean || !live || this.#attempt >= RETRY_DELAYS.length) { + this.status = clean || !live ? "done" : "dropped"; + return; + } + this.#reconnect(); + } + }); + } + + destroy(): void { + this.#close?.(); + this.#close = null; + clearTimeout(this.#retry); + if (this.#flush !== undefined) { + cancelAnimationFrame(this.#flush); + this.#flush = undefined; + } + } + + #reconnect(): void { + const delay = RETRY_DELAYS[this.#attempt]; + this.#attempt += 1; + this.status = "connecting"; + this.#retry = window.setTimeout(() => { + this.#retry = undefined; + // clear accumulator before replay + this.#accumulator = createLogAccumulator(); + this.steps = []; + this.open(); + }, delay); + } + + #schedule(): void { + if (this.#flush !== undefined) return; + this.#flush = requestAnimationFrame(() => { + this.#flush = undefined; + this.#render(); + }); + } + + #render(): void { + if (!this.#dirty) return; + this.#dirty = false; + // trigger view update with fresh array reference + this.steps = [...this.#accumulator.steps]; + } +} diff --git a/web/src/lib/components/repo/pipelines/logs.test.ts b/web/src/lib/components/repo/pipelines/logs.test.ts new file mode 100644 index 00000000..71cdf693 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/logs.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import type { LogFrame } from "$lib/api/spindleLogs"; +import { createLogAccumulator, isLastRow, toRows, type LogStep } from "./logs"; + +const start = (step: number, name: string, over: Record = {}): LogFrame => ({ + kind: "control", + control: { + time: "2026-08-06T12:00:00Z", + workflow: "build", + step, + content: name, + status: "start", + ...over + } +}); + +const end = (step: number, time: string): LogFrame => ({ + kind: "control", + control: { time, workflow: "build", step, content: "", status: "end" } +}); + +const line = (step: number, content: string): LogFrame => ({ + kind: "data", + data: { time: "2026-08-06T12:00:01Z", workflow: "build", step, content, stream: "stdout" } +}); + +const drain = (frames: LogFrame[]): LogStep[] => { + const accumulator = createLogAccumulator(); + for (const frame of frames) accumulator.push(frame); + return accumulator.steps; +}; + +describe("LogAccumulator", () => { + it("opens a step, collects its lines and closes it", () => { + const steps = drain([ + start(1, "Run tests", { command: "go test ./..." }), + line(1, "ok\n"), + line(1, "PASS\n"), + end(1, "2026-08-06T12:00:30Z") + ]); + + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ + id: 1, + name: "Run tests", + command: "go test ./...", + system: false, + startedAt: Date.parse("2026-08-06T12:00:00Z"), + finishedAt: Date.parse("2026-08-06T12:00:30Z"), + lines: ["ok", "PASS"] + }); + }); + + it("marks runner-injected steps so the view can fold them", () => { + const steps = drain([start(0, "Clone", { kind: "system" }), start(1, "Build")]); + expect(steps.map((step) => step.system)).toEqual([true, false]); + }); + + it("keeps the step order the spindle sent, not the step ids", () => { + const steps = drain([start(3, "Third"), start(1, "First")]); + expect(steps.map((step) => step.name)).toEqual(["Third", "First"]); + }); + + it("keeps a line the runner never wrapped in a step", () => { + const steps = drain([line(0, "stray\n")]); + expect(steps).toMatchObject([{ id: 0, name: "Step 0", lines: ["stray"] }]); + }); + + it("renders ansi to classed spans and escapes the text", () => { + const steps = drain([start(1, "Run"), line(1, "\u001b[31m\u001b[0m\n")]); + expect(steps[0].lines[0]).toBe('<boom>'); + }); + + it("carries an unterminated colour across lines, like the terminal would", () => { + const steps = drain([start(1, "Run"), line(1, "\u001b[32mstart\n"), line(1, "still green\n")]); + expect(steps[0].lines[1]).toBe('still green'); + }); + + it("starts each step from a clean palette", () => { + const steps = drain([ + start(1, "One"), + line(1, "\u001b[32mgreen\n"), + start(2, "Two"), + line(2, "plain\n") + ]); + expect(steps[1].lines[0]).toBe("plain"); + }); + + it("reports whether a frame changed anything", () => { + const accumulator = createLogAccumulator(); + expect(accumulator.push(start(1, "Run"))).toBe(true); + expect(accumulator.push({ kind: "error", error: "Boom", message: "boom" })).toBe(false); + // a control frame with no status only names a step we already know about + expect( + accumulator.push({ + kind: "control", + control: { time: "2026-08-06T12:00:00Z", workflow: "build", step: 1, content: "" } + }) + ).toBe(false); + }); +}); + +describe("toRows", () => { + const steps = drain([ + start(1, "One", { command: "make" }), + line(1, "a\n"), + line(1, "b\n"), + start(2, "Two"), + line(2, "c\n") + ]); + + it("lays each step out as a header, its command and its lines", () => { + const rows = toRows(steps, new Set()); + expect(rows.map((row) => row.kind)).toEqual([ + "step", + "command", + "line", + "line", + "gap", + "step", + "line" + ]); + expect(rows.filter((row) => row.kind === "line").map((row) => row.number)).toEqual([1, 2, 1]); + }); + + it("drops the body of a collapsed step but keeps its header", () => { + const rows = toRows(steps, new Set([1])); + expect(rows.map((row) => row.kind)).toEqual(["step", "gap", "step", "line"]); + }); + + it("closes a card on its last row", () => { + const rows = toRows(steps, new Set()); + // the second "b" line ends the first card, the gap doesn't + expect(isLastRow(rows, 3)).toBe(true); + expect(isLastRow(rows, 2)).toBe(false); + expect(isLastRow(rows, rows.length - 1)).toBe(true); + }); +}); diff --git a/web/src/lib/components/repo/pipelines/logs.ts b/web/src/lib/components/repo/pipelines/logs.ts new file mode 100644 index 00000000..e2acd027 --- /dev/null +++ b/web/src/lib/components/repo/pipelines/logs.ts @@ -0,0 +1,122 @@ +// converts log frames into step cards and line rows + +import { AnsiUp } from "ansi_up"; +import type { LogControl, LogData, LogFrame } from "$lib/api/spindleLogs"; + +export interface LogStep { + id: number; + name: string; + command?: string; + // runner-injected steps fold by default + system: boolean; + startedAt: number; + finishedAt?: number; + // ansi resolved to html + lines: string[]; +} + +const stamp = (time: string): number => Date.parse(time); + +const newAnsi = (): AnsiUp => { + const ansi = new AnsiUp(); + // use classes so colors adapt to theme + ansi.use_classes = true; + return ansi; +}; + +// accumulates a workflow log stream +export interface LogAccumulator { + steps: LogStep[]; + push: (frame: LogFrame) => boolean; +} + +// accumulates a workflow log stream +export const createLogAccumulator = (): LogAccumulator => { + const steps: LogStep[] = []; + const byId = new Map(); + const ansi = new Map(); + + const getOrCreateStep = (id: number, time: string): LogStep => { + let step = byId.get(id); + if (step) return step; + + step = { id, name: `Step ${id}`, system: false, startedAt: stamp(time), lines: [] }; + byId.set(id, step); + steps.push(step); + return step; + }; + + return { + steps, + push(frame: LogFrame): boolean { + if (frame.kind === "control") { + const c = frame.control; + const step = getOrCreateStep(c.step, c.time); + if (c.status === "start") { + step.name = c.content; + step.command = c.command; + step.system = c.kind === "system"; + step.startedAt = stamp(c.time); + return true; + } + if (c.status === "end") { + step.finishedAt = stamp(c.time); + return true; + } + return false; + } + if (frame.kind === "data") { + const d = frame.data; + const step = getOrCreateStep(d.step, d.time); + let converter = ansi.get(step.id); + if (!converter) { + converter = newAnsi(); + ansi.set(step.id, converter); + } + step.lines.push(converter.ansi_to_html(d.content.replace(/\n$/, ""))); + return true; + } + return false; + } + }; +}; + +export type LogRow = + | { kind: "step"; step: LogStep } + | { kind: "command"; step: LogStep; command: string } + | { kind: "line"; step: LogStep; html: string; number: number } + // gap between step cards + | { kind: "gap"; stepId: number }; + +// flattens steps into virtualized rows +export const toRows = (steps: LogStep[], collapsed: ReadonlySet): LogRow[] => { + const rows: LogRow[] = []; + for (const step of steps) { + if (rows.length > 0) rows.push({ kind: "gap", stepId: step.id }); + rows.push({ kind: "step", step }); + if (collapsed.has(step.id)) continue; + if (step.command) rows.push({ kind: "command", step, command: step.command }); + for (let i = 0; i < step.lines.length; i++) { + rows.push({ kind: "line", step, html: step.lines[i], number: i + 1 }); + } + } + return rows; +}; + +// reports if a row is the last in its step card +export const isLastRow = (rows: readonly LogRow[], index: number): boolean => + index === rows.length - 1 || rows[index + 1].kind === "gap"; + +// stable key for virtualizer row heights +export const rowKey = (row: LogRow, index: number): string => { + switch (row.kind) { + case "step": + return `step:${row.step.id}`; + case "command": + return `command:${row.step.id}`; + case "line": + return `line:${row.step.id}:${row.number}`; + case "gap": + return `gap:${row.stepId}`; + } +}; diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts index 14d1782f..533c3955 100644 --- a/web/src/lib/components/repo/pipelines/mock.ts +++ b/web/src/lib/components/repo/pipelines/mock.ts @@ -2,6 +2,8 @@ // reads from a spindle now import type { PipelineSummary } from "$lib/components/repo/types"; +import type { LogControl, LogFrame } from "$lib/api/spindleLogs"; +import { createLogAccumulator, type LogStep } from "./logs"; const minutes = (n: number) => n * 60_000; @@ -100,3 +102,84 @@ export const pipelinesByCommitHash = (hashes: string[]): Record = {}): LogFrame => ({ + kind: "control", + control: { time: ago(6), workflow: "build", step, content, status: "start", ...over } +}); + +const finish = (step: number, seconds: number): LogFrame => ({ + kind: "control", + control: { + time: new Date(base - minutes(6) + seconds * 1000).toISOString(), + workflow: "build", + step, + content: "", + status: "end" + } +}); + +const output = (step: number, content: string): LogFrame => ({ + kind: "data", + data: { time: ago(6), workflow: "build", step, content, stream: "stdout" } +}); + +const drain = (frames: LogFrame[]): LogStep[] => { + const accumulator = createLogAccumulator(); + for (const frame of frames) accumulator.push(frame); + return accumulator.steps; +}; + +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"), + finish(-1, 3), + control(0, "Clone repository into workspace", { + kind: "system", + command: "git init\ngit fetch --depth=1 origin 9f2c1ab\ngit checkout FETCH_HEAD" + }), + output(0, "warning: templates not found\n"), + output(0, "HEAD is now at 9f2c1ab\n"), + finish(0, 9), + control(1, "install dependencies", { command: "pnpm install --frozen-lockfile" }), + ...Array.from({ length: 12 }, (_, index) => + output(1, `\u001b[90m+\u001b[0m package-${index} \u001b[32m1.${index}.0\u001b[0m\n`) + ), + finish(1, 41), + control(2, "run tests", { command: "pnpm test" }), + output(2, "\u001b[1mRUN\u001b[0m v4.1.10\n"), + output(2, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n"), + output(2, "\u001b[31m✗\u001b[0m src/lib/api/spindle.test.ts (1 failed)\n"), + output(2, "\u001b[31mAssertionError: expected 'pending' to be 'running'\u001b[0m\n"), + output( + 2, + " a line long enough to wrap on a narrow viewport, which is what the log rows measure themselves for\n" + ), + finish(2, 96) +]); + +/** the same run mid-flight: the last step is open, so its timer keeps counting */ +export const runningWorkflowLog: LogStep[] = drain([ + control(0, "Clone repository into workspace", { kind: "system", command: "git init" }), + output(0, "HEAD is now at 9f2c1ab\n"), + finish(0, 9), + // the one stamp that has to be live, so the ticking timer shows something sane + control(1, "run tests", { + time: new Date(Date.now() - 12_000).toISOString(), + command: "pnpm test" + }), + output(1, "\u001b[1mRUN\u001b[0m v4.1.10\n"), + output(1, "\u001b[32m✓\u001b[0m src/lib/format.test.ts (12)\n") +]); + +/** 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" }), + ...Array.from({ length: 4_000 }, (_, index) => + output(1, `[${index}] \u001b[36mCC\u001b[0m src/module-${index}.c\n`) + ) +]); diff --git a/web/src/lib/components/repo/pipelines/pipeline.ts b/web/src/lib/components/repo/pipelines/pipeline.ts index 2ecb21a0..36205ab9 100644 --- a/web/src/lib/components/repo/pipelines/pipeline.ts +++ b/web/src/lib/components/repo/pipelines/pipeline.ts @@ -91,3 +91,18 @@ export const longSummary = (pipeline: PipelineSummary): string => { /** sum of the workflows, not wall time across the run */ export const totalDuration = (pipeline: PipelineSummary): number => pipeline.workflows.reduce((sum, workflow) => sum + workflow.duration, 0); + +/** the spindle's error, first line only — mirrors WorkflowStatus.ErrorMessage */ +export const errorMessage = (error: string): string => error.split("\n", 1)[0]; + +/** everything under the first line, capped the way the appview caps it */ +export const errorDetails = (error: string): string => { + const lines = error.split("\n").slice(1); + if (lines.length === 0) return ""; + const capped = lines.slice(0, 15); + return lines.length > capped.length ? `${capped.join("\n")}\n…` : capped.join("\n"); +}; + +/** whether the workflow can still produce log output */ +export const isLive = (status: PipelineStatus): boolean => + status === "pending" || status === "running"; diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts index 0633e3d6..10217c72 100644 --- a/web/src/lib/components/repo/types.ts +++ b/web/src/lib/components/repo/types.ts @@ -60,6 +60,10 @@ export interface WorkflowSummary { duration: number; /** first line of the spindle's error, when it failed before running */ error?: string; + /** absent until the spindle picks the workflow up */ + startedAt?: string; + /** absent while the workflow is still going */ + finishedAt?: string; } export type PipelineTrigger = diff --git a/web/src/logs.css b/web/src/logs.css new file mode 100644 index 00000000..5f0c4c45 --- /dev/null +++ b/web/src/logs.css @@ -0,0 +1,101 @@ +/* ansi_up's palette classes, mapped onto the terminal tokens in app.css. + only the 16 base colours get classes; 256-colour and truecolor sgr codes + come through as inline rgb, same as the appview's terminal-to-html. */ + +.ansi-black-fg { + color: var(--color-ansi-black); +} +.ansi-red-fg { + color: var(--color-ansi-red); +} +.ansi-green-fg { + color: var(--color-ansi-green); +} +.ansi-yellow-fg { + color: var(--color-ansi-yellow); +} +.ansi-blue-fg { + color: var(--color-ansi-blue); +} +.ansi-magenta-fg { + color: var(--color-ansi-magenta); +} +.ansi-cyan-fg { + color: var(--color-ansi-cyan); +} +.ansi-white-fg { + color: var(--color-ansi-white); +} +.ansi-bright-black-fg { + color: var(--color-ansi-bright-black); +} +.ansi-bright-red-fg { + color: var(--color-ansi-bright-red); +} +.ansi-bright-green-fg { + color: var(--color-ansi-bright-green); +} +.ansi-bright-yellow-fg { + color: var(--color-ansi-bright-yellow); +} +.ansi-bright-blue-fg { + color: var(--color-ansi-bright-blue); +} +.ansi-bright-magenta-fg { + color: var(--color-ansi-bright-magenta); +} +.ansi-bright-cyan-fg { + color: var(--color-ansi-bright-cyan); +} +.ansi-bright-white-fg { + color: var(--color-ansi-bright-white); +} + +.ansi-black-bg { + background-color: var(--color-ansi-black); +} +.ansi-red-bg { + background-color: var(--color-ansi-red); +} +.ansi-green-bg { + background-color: var(--color-ansi-green); +} +.ansi-yellow-bg { + background-color: var(--color-ansi-yellow); +} +.ansi-blue-bg { + background-color: var(--color-ansi-blue); +} +.ansi-magenta-bg { + background-color: var(--color-ansi-magenta); +} +.ansi-cyan-bg { + background-color: var(--color-ansi-cyan); +} +.ansi-white-bg { + background-color: var(--color-ansi-white); +} +.ansi-bright-black-bg { + background-color: var(--color-ansi-bright-black); +} +.ansi-bright-red-bg { + background-color: var(--color-ansi-bright-red); +} +.ansi-bright-green-bg { + background-color: var(--color-ansi-bright-green); +} +.ansi-bright-yellow-bg { + background-color: var(--color-ansi-bright-yellow); +} +.ansi-bright-blue-bg { + background-color: var(--color-ansi-bright-blue); +} +.ansi-bright-magenta-bg { + background-color: var(--color-ansi-bright-magenta); +} +.ansi-bright-cyan-bg { + background-color: var(--color-ansi-bright-cyan); +} +.ansi-bright-white-bg { + background-color: var(--color-ansi-bright-white); +} 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 new file mode 100644 index 00000000..1e35c431 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.svelte @@ -0,0 +1,69 @@ + + + + {data.workflow.name} · Pipeline {data.pipeline.id} · Tangled + + + + + + +
+
+ +
+
+ {#if data.workflow.error} +
+
+ {/if} + +
+
diff --git a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts new file mode 100644 index 00000000..37e5aa4b --- /dev/null +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts @@ -0,0 +1,26 @@ +import { error } from "@sveltejs/kit"; +import { createSpindleClient, getPipeline, toPipelineSummary } from "$lib/api/spindle"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const { spindle, repoDid } = parent.repo; + + if (!spindle || !repoDid) error(404, "This repo has no spindle"); + + const ctx = createSpindleClient(spindle, event.fetch); + + const result = await getPipeline(ctx, { pipeline: event.params.pipeline }).catch(() => { + // unknown id or dead spindle is a 404 for this page + error(404, `Could not load pipeline ${event.params.pipeline} from ${spindle}`); + }); + + // spindle returning another repo is invalid + if (result.repo !== repoDid) error(404, "Pipeline belongs to another repo"); + + const pipeline = toPipelineSummary(result, repoDid); + const workflow = pipeline.workflows.find((each) => each.name === event.params.workflow); + if (!workflow) error(404, `No workflow named ${event.params.workflow} in this run`); + + return { spindle, pipeline, workflow }; +}; -- 2.51.2