From 8aa97365129b873fd933a769682b8afb518eecd7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 15 Aug 2026 05:30:52 +0000 Subject: [PATCH] fix(gateway): preserve log scroll position --- .../e2e/server-operations.spec.ts | 204 ++++++++++++++++++ .../src/views/ServerOperationsView.vue | 20 +- 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 388b9de6..adfcaffb 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -18,6 +18,15 @@ type Operation = { updatedAt: string; }; +type LogEntry = { + cursor: string; + operationId: string; + level: 'INFO' | 'OUTPUT' | 'ERROR'; + phase: string; + message: string; + createdAt: string; +}; + type FixtureState = { operations: Operation[]; gatewayOperations: Array<{ @@ -39,8 +48,12 @@ type FixtureState = { profileLogPollCount?: number; profileLogProgress?: boolean; profileLogsEmpty?: boolean; + profileLogBatches?: LogEntry[][]; + profileLogPollGate?: (pollCount: number) => Promise; gatewayLogPollCount?: number; gatewayLogsEmpty?: boolean; + gatewayLogBatches?: LogEntry[][]; + gatewayLogPollGate?: (pollCount: number) => Promise; gatewayStateFailuresAfterRequest?: number; gatewayStateFailuresRemaining?: number; gatewayStateFailureCount?: number; @@ -160,6 +173,12 @@ const installFixture = async (page: Page, state: FixtureState) => { if (names.includes('admin.operations.logs') && !state.profileLogProgress) { await new Promise((resolve) => setTimeout(resolve, 50)); } + if (names.includes('admin.operations.logs') && state.profileLogPollGate) { + await state.profileLogPollGate((state.profileLogPollCount ?? 0) + 1); + } + if (names.includes('admin.releases.logs') && state.gatewayLogPollGate) { + await state.gatewayLogPollGate((state.gatewayLogPollCount ?? 0) + 1); + } const results = names.map((name) => { if (route.request().method() === 'POST') { state.requestBodies.push({ operation: name, body }); @@ -197,6 +216,20 @@ const installFixture = async (page: Page, state: FixtureState) => { const operation = state.operations[0]; if (!operation) throw new Error('Profile operation fixture is missing'); state.profileLogPollCount = (state.profileLogPollCount ?? 0) + 1; + if (state.profileLogBatches) { + const entries = state.profileLogBatches[state.profileLogPollCount - 1] ?? []; + const completed = state.profileLogPollCount >= state.profileLogBatches.length; + const nextOperation = { + ...operation, + status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const), + }; + state.operations[0] = nextOperation; + return response({ + operation: nextOperation, + entries, + nextCursor: entries.at(-1)?.cursor, + }); + } if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(operation.status)) { return response({ operation, entries: [] }); } @@ -258,6 +291,20 @@ const installFixture = async (page: Page, state: FixtureState) => { const releaseOperation = state.gatewayOperations[0]; if (!releaseOperation) throw new Error('Release operation fixture is missing'); state.gatewayLogPollCount = (state.gatewayLogPollCount ?? 0) + 1; + if (state.gatewayLogBatches) { + const entries = state.gatewayLogBatches[state.gatewayLogPollCount - 1] ?? []; + const completed = state.gatewayLogPollCount >= state.gatewayLogBatches.length; + const nextOperation = { + ...releaseOperation, + status: completed ? ('SUCCEEDED' as const) : ('RUNNING' as const), + }; + state.gatewayOperations[0] = nextOperation; + return response({ + operation: nextOperation, + entries, + nextCursor: entries.at(-1)?.cursor, + }); + } if (state.gatewayLogsEmpty) { return response({ operation: releaseOperation, entries: [] }); } @@ -408,6 +455,27 @@ const installFixture = async (page: Page, state: FixtureState) => { }); }; +const makeLogEntries = (operationId: string, prefix: string, startCursor: number, count: number): LogEntry[] => + Array.from({ length: count }, (_, index) => { + const cursor = startCursor + index; + return { + cursor: String(cursor), + operationId, + level: 'OUTPUT', + phase: 'build', + message: `${prefix} ${cursor}`, + createdAt: '2026-08-01T01:00:00.000Z', + }; + }); + +const deferred = () => { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +}; + test('separates branch and commit semantics and submits a reset from the dedicated page', async ({ page, }, testInfo) => { @@ -634,6 +702,142 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page } expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false); }); +for (const viewportSize of [ + { name: 'desktop', width: 1280, height: 720 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + test(`follows new profile and Gateway logs only while each viewport is near the end on ${viewportSize.name}`, async ({ + page, + }, testInfo) => { + await page.setViewportSize(viewportSize); + const profileOperationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const gatewayOperationId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const profileSecondPoll = deferred(); + const profileThirdPoll = deferred(); + const gatewaySecondPoll = deferred(); + const gatewayThirdPoll = deferred(); + const state: FixtureState = { + operations: [ + { + id: profileOperationId, + profileName: 'che:default', + type: 'DEPLOY', + status: 'RUNNING', + sourceMode: 'BRANCH', + sourceRef: 'main', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-01T01:00:00.000Z', + updatedAt: '2026-08-01T01:00:00.000Z', + }, + ], + gatewayOperations: [ + { + id: gatewayOperationId, + type: 'DEPLOY', + status: 'RUNNING', + sourceMode: 'BRANCH', + sourceRef: 'main', + payload: {}, + requestedBy: 'admin', + createdAt: '2026-08-01T02:00:00.000Z', + updatedAt: '2026-08-01T02:00:00.000Z', + }, + ], + runtimeRunning: true, + requestBodies: [], + profileLogProgress: true, + profileLogBatches: [ + makeLogEntries(profileOperationId, 'profile history', 1, 80), + makeLogEntries(profileOperationId, 'profile while reading', 81, 1), + makeLogEntries(profileOperationId, 'profile near end', 82, 1), + ], + profileLogPollGate: async (pollCount) => { + if (pollCount === 2) await profileSecondPoll.promise; + if (pollCount === 3) await profileThirdPoll.promise; + }, + gatewayLogBatches: [ + makeLogEntries(gatewayOperationId, 'gateway history', 1, 80), + makeLogEntries(gatewayOperationId, 'gateway while reading', 81, 1), + makeLogEntries(gatewayOperationId, 'gateway near end', 82, 1), + ], + gatewayLogPollGate: async (pollCount) => { + if (pollCount === 2) await gatewaySecondPoll.promise; + if (pollCount === 3) await gatewayThirdPoll.promise; + }, + }; + await installFixture(page, state); + + const verifyViewport = async ( + url: string, + testId: 'profile-operation-log' | 'gateway-release-log', + historyText: string, + readingText: string, + nearEndText: string, + releaseSecondPoll: () => void, + releaseThirdPoll: () => void, + screenshotName: string + ) => { + await page.goto(url); + const viewport = page.getByTestId(testId); + await expect(viewport).toContainText(historyText); + await expect + .poll(() => + viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) + ) + .toBeLessThanOrEqual(1); + + const readingPosition = await viewport.evaluate((element) => { + element.scrollTop = 200; + return element.scrollTop; + }); + expect(readingPosition).toBe(200); + releaseSecondPoll(); + await expect(viewport).toContainText(readingText); + await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(readingPosition); + + const nearEndGap = await viewport.evaluate((element) => { + element.scrollTop = element.scrollHeight - element.clientHeight - 20; + return element.scrollHeight - element.clientHeight - element.scrollTop; + }); + expect(nearEndGap).toBeGreaterThan(0); + expect(nearEndGap).toBeLessThanOrEqual(40); + releaseThirdPoll(); + await expect(viewport).toContainText(nearEndText); + await expect + .poll(() => + viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop) + ) + .toBeLessThanOrEqual(1); + await page.screenshot({ + path: testInfo.outputPath(`${viewportSize.name}-${screenshotName}`), + fullPage: true, + }); + }; + + await verifyViewport( + 'admin/servers/che%3Adefault/version', + 'profile-operation-log', + 'profile history 80', + 'profile while reading 81', + 'profile near end 82', + profileSecondPoll.resolve, + profileThirdPoll.resolve, + 'profile-log-scroll-follow.png' + ); + await verifyViewport( + 'admin/releases', + 'gateway-release-log', + 'gateway history 80', + 'gateway while reading 81', + 'gateway near end 82', + gatewaySecondPoll.resolve, + gatewayThirdPoll.resolve, + 'gateway-log-scroll-follow.png' + ); + }); +} + test('loads server metadata defaults into the reset form and submits them', async ({ page }) => { const state: FixtureState = { operations: [], diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 7031f4d3..c01bdffe 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -115,6 +115,12 @@ let releaseLogLoopGeneration = 0; let profileLogLoopGeneration = 0; let componentMounted = false; let gatewayReleaseTransitionActive = false; +const LOG_SCROLL_FOLLOW_THRESHOLD_PX = 40; + +const isLogViewportNearEnd = (viewport?: HTMLElement): boolean => { + if (!viewport) return true; + return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop <= LOG_SCROLL_FOLLOW_THRESHOLD_PX; +}; const form = reactive({ sourceMode: (props.mode === 'scenario' ? 'CURRENT' : 'BRANCH') as 'CURRENT' | 'BRANCH' | 'COMMIT', @@ -326,10 +332,10 @@ const loadState = async (quiet = false) => { } }; -const scrollProfileOperationLogToEnd = async () => { +const scrollProfileOperationLogToEnd = async (shouldFollow: boolean) => { await nextTick(); const viewport = profileOperationLogViewport.value; - if (viewport) viewport.scrollTop = viewport.scrollHeight; + if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight; }; const pollProfileOperationLogs = async (operationId: string, generation: number) => { @@ -349,11 +355,12 @@ const pollProfileOperationLogs = async (operationId: string, generation: number) profileOperationLogConnection.value = 'connected'; const entries = result.entries as GatewayReleaseLog[]; if (entries.length) { + const shouldFollow = isLogViewportNearEnd(profileOperationLogViewport.value); const known = new Set(profileOperationLogs.value.map((entry) => entry.cursor)); profileOperationLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor))); profileOperationLogs.value = profileOperationLogs.value.slice(-1_000); profileOperationLogCursor.value = result.nextCursor; - await scrollProfileOperationLogToEnd(); + await scrollProfileOperationLogToEnd(shouldFollow); } const operation = result.operation as Operation; profileOperationLogStatus.value = operation.status; @@ -381,10 +388,10 @@ const selectProfileOperation = (operationId: string) => { selectedProfileOperationId.value = operationId; }; -const scrollReleaseLogToEnd = async () => { +const scrollReleaseLogToEnd = async (shouldFollow: boolean) => { await nextTick(); const viewport = gatewayReleaseLogViewport.value; - if (viewport) viewport.scrollTop = viewport.scrollHeight; + if (viewport && shouldFollow) viewport.scrollTop = viewport.scrollHeight; }; const pollGatewayReleaseLogs = async (operationId: string, generation: number) => { @@ -404,11 +411,12 @@ const pollGatewayReleaseLogs = async (operationId: string, generation: number) = gatewayReleaseLogConnection.value = 'connected'; const entries = result.entries as GatewayReleaseLog[]; if (entries.length) { + const shouldFollow = isLogViewportNearEnd(gatewayReleaseLogViewport.value); const known = new Set(gatewayReleaseLogs.value.map((entry) => entry.cursor)); gatewayReleaseLogs.value.push(...entries.filter((entry) => !known.has(entry.cursor))); gatewayReleaseLogs.value = gatewayReleaseLogs.value.slice(-1_000); gatewayReleaseLogCursor.value = result.nextCursor; - await scrollReleaseLogToEnd(); + await scrollReleaseLogToEnd(shouldFollow); } const operation = result.operation as GatewayReleaseOperation; gatewayReleaseLogStatus.value = operation.status;