From 7fa7ee2c7dafcafe72390126a6261b1a7878aa03 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:35:39 +0300 Subject: [PATCH] fix(web): follow server job log output automatically --- apps/web/e2e/server-management.spec.ts | 46 +++++++++++++++++++ .../features/server-management/JobPanel.tsx | 15 +++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/server-management.spec.ts b/apps/web/e2e/server-management.spec.ts index 3d5b96e..7bb5a8f 100644 --- a/apps/web/e2e/server-management.spec.ts +++ b/apps/web/e2e/server-management.spec.ts @@ -9,6 +9,52 @@ async function login(page: Page, username = 'mobile-admin', target = '/') { await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible(); } +test('job log follows new output and pauses while reading older lines', async ({ page }) => { + await login(page); + await page.evaluate(() => { + let lines = 100; + window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent).detail; }); + const original = window.fetch; + window.fetch = async (input, init) => { + const response = await original(input, init); + const request = new Request(input, init); + if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') { + const data = await response.json(); + data.job = { + id: 'scroll-job', kind: 'update', state: 'running', target: 'main', + started_at: '2026-10-22T09:00:00Z', finished_at: '', + log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'), + }; + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + } + return response; + }; + history.pushState({}, '', '/admin/updates'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + const log = page.getByLabel('Журнал выполнения'); + const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop); + await expect(log).toContainText('Build line 100'); + expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true); + await expect.poll(bottomGap).toBeLessThanOrEqual(4); + + await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 }))); + await expect(log).toContainText('Build line 120'); + await expect.poll(bottomGap).toBeLessThanOrEqual(4); + + await log.evaluate((el) => { el.scrollTop = 100; }); + await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100); + await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 }))); + await expect(log).toContainText('Build line 140'); + expect(await log.evaluate((el) => el.scrollTop)).toBe(100); + + await log.evaluate((el) => { el.scrollTop = el.scrollHeight; }); + await expect.poll(bottomGap).toBeLessThanOrEqual(4); + await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 }))); + await expect(log).toContainText('Build line 160'); + await expect.poll(bottomGap).toBeLessThanOrEqual(4); +}); + test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => { await login(page, 'mobile-admin', '/admin/updates'); await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible(); diff --git a/apps/web/src/features/server-management/JobPanel.tsx b/apps/web/src/features/server-management/JobPanel.tsx index 0149372..abb9c0d 100644 --- a/apps/web/src/features/server-management/JobPanel.tsx +++ b/apps/web/src/features/server-management/JobPanel.tsx @@ -1,4 +1,5 @@ import type { components } from '@pipu/api-client'; +import { useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import s from './management.module.css'; @@ -6,6 +7,15 @@ export type Job = components['schemas']['ServerJob']; export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) { const { t } = useTranslation('admin'); + const logRef = useRef(null); + const followLog = useRef(true); + useLayoutEffect(() => { + followLog.current = true; + }, [job?.id]); + useLayoutEffect(() => { + const log = logRef.current; + if (log && followLog.current) log.scrollTop = log.scrollHeight; + }, [job?.id, job?.log]); if (!job) return null; return
@@ -15,6 +25,9 @@ export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; r

{t('management.started')}: {new Date(job.started_at).toLocaleString()} {job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}}

{job.state === 'running' &&

{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}

} -
{job.log || t('management.waiting')}
+
 {
+      const log = event.currentTarget;
+      followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
+    }}>{job.log || t('management.waiting')}
; }