From ce60862976aab80f3783a8bd8fd82fe1e96ff457 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Tue, 26 May 2026 08:34:02 +0200 Subject: [PATCH 1/9] feat: add FlatLogLine type and flattenLogs utility --- app/types/logs.ts | 25 ++++++++++++++ test/utils/flatten-logs.test.ts | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 app/types/logs.ts create mode 100644 test/utils/flatten-logs.test.ts diff --git a/app/types/logs.ts b/app/types/logs.ts new file mode 100644 index 0000000..5a11198 --- /dev/null +++ b/app/types/logs.ts @@ -0,0 +1,25 @@ +import type { PodLog } from "~/services/Api"; + +export interface FlatLogLine { + content: string; + level?: string | null; + timestamp: string; + isStacktrace: boolean; +} + +export function flattenLogs(logs: PodLog[]): FlatLogLine[] { + return logs.flatMap((entry) => [ + { + content: entry.message, + level: entry.level, + timestamp: entry.timestamp, + isStacktrace: false, + }, + ...(entry.stacktrace?.split("\n") ?? []).map((line) => ({ + content: line, + level: null, + timestamp: entry.timestamp, + isStacktrace: true, + })), + ]); +} diff --git a/test/utils/flatten-logs.test.ts b/test/utils/flatten-logs.test.ts new file mode 100644 index 0000000..a8f5fe7 --- /dev/null +++ b/test/utils/flatten-logs.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { flattenLogs } from "~/types/logs"; +import type { PodLog } from "~/services/Api"; + +const makeLog = (overrides: Partial = {}): PodLog => ({ + timestamp: "2024-01-01T00:00:00Z", + message: "hello", + level: "INFO", + stacktrace: null, + ...overrides, +}); + +describe("flattenLogs", () => { + it("returns empty array for empty input", () => { + expect(flattenLogs([])).toEqual([]); + }); + + it("flattens a single log with no stacktrace into one line", () => { + const result = flattenLogs([makeLog({ message: "hello", level: "INFO" })]); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + content: "hello", + level: "INFO", + timestamp: "2024-01-01T00:00:00Z", + isStacktrace: false, + }); + }); + + it("flattens a log with a two-line stacktrace into three lines", () => { + const result = flattenLogs([ + makeLog({ message: "boom", level: "ERROR", stacktrace: "at A\nat B" }), + ]); + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ content: "boom", isStacktrace: false }); + expect(result[1]).toMatchObject({ content: "at A", isStacktrace: true, level: null }); + expect(result[2]).toMatchObject({ content: "at B", isStacktrace: true, level: null }); + }); + + it("stacktrace lines inherit the parent timestamp", () => { + const result = flattenLogs([ + makeLog({ timestamp: "2024-06-01T12:00:00Z", stacktrace: "frame" }), + ]); + expect(result[1].timestamp).toBe("2024-06-01T12:00:00Z"); + }); + + it("handles multiple logs in order", () => { + const result = flattenLogs([ + makeLog({ message: "first" }), + makeLog({ message: "second" }), + ]); + expect(result).toHaveLength(2); + expect(result[0].content).toBe("first"); + expect(result[1].content).toBe("second"); + }); + + it("handles null level", () => { + const result = flattenLogs([makeLog({ level: null })]); + expect(result[0].level).toBeNull(); + }); +}); From c7dc98226a6783afd99151497be0fa08bbd5c10e Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Tue, 26 May 2026 09:11:30 +0200 Subject: [PATCH 2/9] feat: add useLogChunks composable for chunked log fetching Implements chunked log fetching with initialize/loadNextChunk/appendPolled/reset API, supporting 300-entry pages, hasMore tracking, and error capture. --- app/composables/useLogChunks.ts | 105 +++++++++++++++ test/composables/useLogChunks.test.ts | 178 ++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 app/composables/useLogChunks.ts create mode 100644 test/composables/useLogChunks.test.ts diff --git a/app/composables/useLogChunks.ts b/app/composables/useLogChunks.ts new file mode 100644 index 0000000..9029265 --- /dev/null +++ b/app/composables/useLogChunks.ts @@ -0,0 +1,105 @@ +import { ref } from "vue"; +import { useNuxtApp } from "nuxt/app"; +import type { AnalysisLogsResponse } from "~/services/Api"; +import { flattenLogs, type FlatLogLine } from "~/types/logs"; + +const CHUNK_SIZE = 300; + +export function useLogChunks(analysisId: string) { + const nginxLines = ref([]); + const analysisLines = ref([]); + const offset = ref(0); + const hasMore = ref(false); + const isLoading = ref(false); + const initialized = ref(false); + const httpError = ref(null); + const runNumber = ref(null); + + async function fetchChunk( + chunkOffset: number, + ): Promise { + return useNuxtApp() + .$hubApi(`/logs/${analysisId}`, { + method: "GET", + query: { limit: CHUNK_SIZE, offset: chunkOffset }, + }) + .catch((err: any) => { + httpError.value = err?.statusCode ?? err?.status ?? null; + return undefined; + }); + } + + async function initialize(): Promise { + isLoading.value = true; + try { + const result = await fetchChunk(0); + if (result) { + nginxLines.value = flattenLogs(result.nginx_logs); + analysisLines.value = flattenLogs(result.analysis_logs); + offset.value = CHUNK_SIZE; + hasMore.value = + result.nginx_logs.length === CHUNK_SIZE || + result.analysis_logs.length === CHUNK_SIZE; + initialized.value = true; + runNumber.value = result.run_number; + } + } finally { + isLoading.value = false; + } + } + + async function loadNextChunk(): Promise { + if (isLoading.value || !hasMore.value) return; + isLoading.value = true; + try { + const result = await fetchChunk(offset.value); + if (result) { + nginxLines.value = [...nginxLines.value, ...flattenLogs(result.nginx_logs)]; + analysisLines.value = [ + ...analysisLines.value, + ...flattenLogs(result.analysis_logs), + ]; + offset.value += CHUNK_SIZE; + hasMore.value = + result.nginx_logs.length === CHUNK_SIZE || + result.analysis_logs.length === CHUNK_SIZE; + } + } finally { + isLoading.value = false; + } + } + + function appendPolled(result: AnalysisLogsResponse): void { + nginxLines.value = [...nginxLines.value, ...flattenLogs(result.nginx_logs)]; + analysisLines.value = [ + ...analysisLines.value, + ...flattenLogs(result.analysis_logs), + ]; + } + + function reset(): void { + nginxLines.value = []; + analysisLines.value = []; + offset.value = 0; + hasMore.value = false; + isLoading.value = false; + initialized.value = false; + httpError.value = null; + runNumber.value = null; + } + + return { + nginxLines, + analysisLines, + offset, + hasMore, + isLoading, + initialized, + httpError, + runNumber, + initialize, + loadNextChunk, + appendPolled, + reset, + }; +} diff --git a/test/composables/useLogChunks.test.ts b/test/composables/useLogChunks.test.ts new file mode 100644 index 0000000..1f1fdb8 --- /dev/null +++ b/test/composables/useLogChunks.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; +import { testServer } from "@/test/mockapi/setup"; +import { useLogChunks } from "~/composables/useLogChunks"; +import type { AnalysisLogsResponse, PodLog } from "~/services/Api"; + +const ANALYSIS_ID = "test-analysis-123"; + +const makePodLog = (message: string): PodLog => ({ + timestamp: "2024-01-01T00:00:00Z", + message, + level: "INFO", + stacktrace: null, +}); + +function makeLogsResponse( + count: number, + offset = 0, +): AnalysisLogsResponse { + return { + analysis_id: ANALYSIS_ID, + run_number: 1, + nginx_logs: Array.from({ length: count }, (_, i) => + makePodLog(`nginx-${offset + i}`), + ), + analysis_logs: Array.from({ length: count }, (_, i) => + makePodLog(`analysis-${offset + i}`), + ), + }; +} + +function setupLogsHandler( + analysisId: string, + chunkSize: number, + totalLogs: number, +) { + testServer.use( + http.get(`/logs/${analysisId}`, ({ request }) => { + const url = new URL(request.url, "http://localhost"); + const offset = Number(url.searchParams.get("offset") ?? 0); + const limit = Number(url.searchParams.get("limit") ?? totalLogs); + const remaining = Math.max(0, totalLogs - offset); + const count = Math.min(limit, remaining); + return HttpResponse.json(makeLogsResponse(count, offset)); + }), + ); +} + +describe("useLogChunks", () => { + describe("initialize", () => { + it("loads first chunk and sets lines", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 5); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + + expect(chunks.nginxLines.value).toHaveLength(5); + expect(chunks.analysisLines.value).toHaveLength(5); + expect(chunks.nginxLines.value[0].content).toBe("nginx-0"); + }); + + it("sets hasMore true when chunk is full (300 returned)", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 600); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + + expect(chunks.hasMore.value).toBe(true); + }); + + it("sets hasMore false when fewer than 300 returned", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 50); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + + expect(chunks.hasMore.value).toBe(false); + }); + + it("sets initialized true after successful fetch", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 5); + const chunks = useLogChunks(ANALYSIS_ID); + expect(chunks.initialized.value).toBe(false); + await chunks.initialize(); + expect(chunks.initialized.value).toBe(true); + }); + + it("sets runNumber from the response", async () => { + testServer.use( + http.get(`/logs/${ANALYSIS_ID}`, () => + HttpResponse.json({ + analysis_id: ANALYSIS_ID, + run_number: 7, + nginx_logs: [], + analysis_logs: [], + }), + ), + ); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + expect(chunks.runNumber.value).toBe(7); + }); + + it("redirects to 403 page when server returns 403", async () => { + testServer.use( + http.get(`/logs/${ANALYSIS_ID}`, () => + HttpResponse.json({ detail: "forbidden" }, { status: 403 }), + ), + ); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + expect(chunks.httpError.value).toBe(403); + }); + }); + + describe("loadNextChunk", () => { + it("appends next chunk to existing lines", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 600); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + await chunks.loadNextChunk(); + + expect(chunks.nginxLines.value).toHaveLength(600); + expect(chunks.nginxLines.value[300].content).toBe("nginx-300"); + }); + + it("sets hasMore false when second chunk is partial", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 350); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + await chunks.loadNextChunk(); + + expect(chunks.hasMore.value).toBe(false); + expect(chunks.nginxLines.value).toHaveLength(350); + }); + + it("does not fetch when hasMore is false", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 50); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + const lineCountBefore = chunks.nginxLines.value.length; + await chunks.loadNextChunk(); // hasMore is false, should no-op + expect(chunks.nginxLines.value).toHaveLength(lineCountBefore); + }); + }); + + describe("appendPolled", () => { + it("appends new polled logs to the end without changing offset or hasMore", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 5); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + + const polledResponse: AnalysisLogsResponse = { + analysis_id: ANALYSIS_ID, + run_number: 1, + nginx_logs: [makePodLog("new-nginx")], + analysis_logs: [makePodLog("new-analysis")], + }; + chunks.appendPolled(polledResponse); + + expect(chunks.nginxLines.value).toHaveLength(6); + expect(chunks.nginxLines.value[5].content).toBe("new-nginx"); + expect(chunks.hasMore.value).toBe(false); // unchanged + }); + }); + + describe("reset", () => { + it("clears all state", async () => { + setupLogsHandler(ANALYSIS_ID, 300, 5); + const chunks = useLogChunks(ANALYSIS_ID); + await chunks.initialize(); + chunks.reset(); + + expect(chunks.nginxLines.value).toHaveLength(0); + expect(chunks.analysisLines.value).toHaveLength(0); + expect(chunks.hasMore.value).toBe(false); + expect(chunks.initialized.value).toBe(false); + expect(chunks.runNumber.value).toBeNull(); + }); + }); +}); From 8f6e21d4422d1bf9d5220fc8fc8d61e70b972926 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Tue, 26 May 2026 09:19:18 +0200 Subject: [PATCH 3/9] fix: add isLoading guard and clear httpError on initialize in useLogChunks --- app/composables/useLogChunks.ts | 2 ++ test/composables/useLogChunks.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/composables/useLogChunks.ts b/app/composables/useLogChunks.ts index 9029265..210bbb0 100644 --- a/app/composables/useLogChunks.ts +++ b/app/composables/useLogChunks.ts @@ -30,6 +30,8 @@ export function useLogChunks(analysisId: string) { } async function initialize(): Promise { + if (isLoading.value) return; + httpError.value = null; isLoading.value = true; try { const result = await fetchChunk(0); diff --git a/test/composables/useLogChunks.test.ts b/test/composables/useLogChunks.test.ts index 1f1fdb8..5107dd6 100644 --- a/test/composables/useLogChunks.test.ts +++ b/test/composables/useLogChunks.test.ts @@ -98,7 +98,7 @@ describe("useLogChunks", () => { expect(chunks.runNumber.value).toBe(7); }); - it("redirects to 403 page when server returns 403", async () => { + it("sets httpError to 403 when server returns 403", async () => { testServer.use( http.get(`/logs/${ANALYSIS_ID}`, () => HttpResponse.json({ detail: "forbidden" }, { status: 403 }), From 6f1f286cbef54da1298367b70781e7ded6a8a5e5 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Tue, 26 May 2026 09:20:57 +0200 Subject: [PATCH 4/9] test: register VirtualScroller in global test setup --- test/mockapi/setup.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/mockapi/setup.ts b/test/mockapi/setup.ts index 8fc7895..3e19d92 100644 --- a/test/mockapi/setup.ts +++ b/test/mockapi/setup.ts @@ -20,6 +20,7 @@ import MultiSelect from "primevue/multiselect"; import InputIcon from "primevue/inputicon"; import InputText from "primevue/inputtext"; import IconField from "primevue/iconfield"; +import VirtualScroller from "primevue/virtualscroller"; /* eslint-disable @typescript-eslint/no-explicit-any */ const globalThis = global as any; @@ -46,6 +47,7 @@ config.global.components = { InputIcon, InputText, IconField, + VirtualScroller, }; // Stub NuxtLink via stubs (matches by component `name` property) rather than From 286f2dff8afd0699f31db6bb4b9de6f41502aa9c Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Tue, 26 May 2026 13:06:45 +0200 Subject: [PATCH 5/9] feat(logs): lazy load logs for active run --- .../actions/build-docker-image/action.yaml | 7 - .gitignore | 1 + .../analysis/logs/AnalysisLogCardContent.vue | 305 +++++++++++------- .../analysis/logs/ContainerLogs.vue | 185 ++++++----- .../analysis/logs/RefreshSwitch.vue | 37 +-- app/composables/useLogChunks.ts | 51 +-- .../logs/AnalysisLogCardContent.spec.ts | 98 +++++- .../analysis/logs/ContainerLogs.spec.ts | 95 ++++-- .../analysis/logs/RefreshSwitch.spec.ts | 8 +- test/composables/useLogChunks.test.ts | 90 +++--- 10 files changed, 556 insertions(+), 321 deletions(-) diff --git a/.github/actions/build-docker-image/action.yaml b/.github/actions/build-docker-image/action.yaml index 07650a4..4fb04a8 100644 --- a/.github/actions/build-docker-image/action.yaml +++ b/.github/actions/build-docker-image/action.yaml @@ -39,13 +39,6 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - # - name: Login to Docker Hub - # if: inputs.push == true - # uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # tag=v3.3.0 - # with: - # username: ${{ secrets.docker_username }} - # password: ${{ secrets.docker_password }} - - name: Login to GitHub Container Registry if: ${{ inputs.push == 'true' }} uses: docker/login-action@v4.1.0 diff --git a/.gitignore b/.gitignore index 43a9cb4..ea0e415 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ sw.* # Claude .claude/ +.playwright-mcp/ docs/ \ No newline at end of file diff --git a/app/components/analysis/logs/AnalysisLogCardContent.vue b/app/components/analysis/logs/AnalysisLogCardContent.vue index 89f5097..91d3020 100644 --- a/app/components/analysis/logs/AnalysisLogCardContent.vue +++ b/app/components/analysis/logs/AnalysisLogCardContent.vue @@ -1,57 +1,80 @@