From a6c9fd8a00e38c56c7d066d95cfa8f97ec3d5de3 Mon Sep 17 00:00:00 2001
From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:20:59 +0300
Subject: [PATCH] fix(ui): reflect update availability and config changes;
expand mobile player metadata
---
.gitea/workflows/docker.yml | 4 +-
api/openapi.yaml | 16 +-
apps/web/e2e/mobile.spec.ts | 10 +-
apps/web/e2e/playlists.spec.ts | 4 +-
apps/web/e2e/server-management.spec.ts | 67 +-
.../features/server-management/ConfigPage.tsx | 22 +-
.../server-management/UpdatesPage.tsx | 43 +-
.../server-management/management.module.css | 2 +
apps/web/src/test/fakeApi.ts | 10 +-
apps/web/src/widgets/AppShell.module.css | 2 +-
apps/web/src/widgets/PlayerBar.module.css | 25 +-
apps/web/src/widgets/PlayerBar.tsx | 6 +-
deploy/compose.yaml | 4 +-
deploy/config.schema.json | 8 +
deploy/server-agent.py | 117 ++-
deploy/test_deployment_scripts.py | 3 +-
deploy/test_server_agent.py | 57 ++
deploy/update.sh | 8 +
docs/server-management.md | 14 +
docs/web.md | 5 +
packages/api-client/src/generated/schema.ts | 9 +
packages/i18n/locales/en/admin.json | 14 +-
packages/i18n/locales/en/errors.json | 6 +-
packages/i18n/locales/ru/admin.json | 14 +-
packages/i18n/locales/ru/errors.json | 6 +-
services/api/Dockerfile | 5 +-
services/api/internal/api/gen/api.gen.go | 702 ++++++++++--------
services/api/internal/buildinfo/buildinfo.go | 3 +
services/api/internal/management/handler.go | 9 +-
.../api/internal/management/handler_test.go | 33 +-
30 files changed, 833 insertions(+), 395 deletions(-)
diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml
index 5abf9e6..968b3dc 100644
--- a/.gitea/workflows/docker.yml
+++ b/.gitea/workflows/docker.yml
@@ -47,8 +47,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - name: Установка агента при обновлении (изолированные тесты)
- run: python3 -m unittest discover -s deploy -p 'test_deployment_scripts.py' -v
+ - name: Обновление сервера, агент и конфигурация
+ run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 5725260..c912fc9 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -30,6 +30,15 @@ paths:
- in: query
name: source_id
schema: {type: string}
+ - in: query
+ name: channel
+ schema: {type: string, enum: [latest, version, main], default: latest}
+ - in: query
+ name: version
+ schema: {type: string}
+ - in: query
+ name: fresh
+ schema: {type: boolean, default: false}
responses:
"200":
description: Server management result
@@ -1958,7 +1967,7 @@ components:
log: {type: string}
ServerUpdates:
type: object
- required: [sources, versions, current_version, job, source_error]
+ required: [sources, versions, current_version, job, source_error, current_commit, target_version, target_commit, update_state]
properties:
sources:
type: array
@@ -1967,6 +1976,10 @@ components:
type: array
items: {type: string}
current_version: {type: string}
+ current_commit: {type: string}
+ target_version: {type: string}
+ target_commit: {type: string}
+ update_state: {type: string, enum: [available, current, ahead, unknown, unavailable, select_version]}
source_error: {type: string}
job:
allOf: [{$ref: "#/components/schemas/ServerJob"}]
@@ -1978,6 +1991,7 @@ components:
source_id: {type: string}
channel: {type: string, enum: [latest, version, main]}
version: {type: string}
+ expected_commit: {type: string, pattern: '^[a-f0-9]{40,64}$'}
ServerConfigEntry:
type: object
required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
diff --git a/apps/web/e2e/mobile.spec.ts b/apps/web/e2e/mobile.spec.ts
index ba7ae5c..5a6de45 100644
--- a/apps/web/e2e/mobile.spec.ts
+++ b/apps/web/e2e/mobile.spec.ts
@@ -102,7 +102,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
- expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
+ expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
+ const artist = player.getByRole('link', { name: 'Кино', exact: true });
+ await expect(artist).toBeVisible();
+ const artistArea = await artist.locator('..').boundingBox();
+ expect(artistArea!.width).toBeGreaterThan(220);
+ const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
+ const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
+ expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
+ await page.screenshot({ path: info.outputPath('player-readable.png') });
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
diff --git a/apps/web/e2e/playlists.spec.ts b/apps/web/e2e/playlists.spec.ts
index 6b8ed1d..ff3dac5 100644
--- a/apps/web/e2e/playlists.spec.ts
+++ b/apps/web/e2e/playlists.spec.ts
@@ -34,7 +34,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
- expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
+ const shared = new URL(sharedUrl);
+ expect(shared.origin).toBe(new URL(page.url()).origin);
+ expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
diff --git a/apps/web/e2e/server-management.spec.ts b/apps/web/e2e/server-management.spec.ts
index a521658..d22ed8f 100644
--- a/apps/web/e2e/server-management.spec.ts
+++ b/apps/web/e2e/server-management.spec.ts
@@ -27,9 +27,9 @@ test('admin selects repository and version, confirms and sees persistent job log
await expect(dialog).toContainText('Тестовая репа');
await expect(dialog).toContainText('v1.10.0');
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
- await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible();
+ await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
- await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled();
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
});
@@ -37,12 +37,15 @@ test('config keeps secret unchanged, saves local override and applies separately
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
+ await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
- await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled();
+ await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
+ await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
@@ -64,3 +67,61 @@ test('ordinary user is redirected away from server management', async ({ page })
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
+
+
+test('installed main and release show no install action; another version can be installed', async ({ page }) => {
+ await login(page, 'mobile-admin', '/admin/updates');
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
+ await page.getByRole('button', { name: 'main', exact: true }).click();
+ await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
+ await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
+ await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
+ await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
+ await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
+ await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
+ await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
+});
+
+test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
+ await login(page, 'mobile-admin', '/admin/updates');
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
+ await page.evaluate(() => {
+ const original = window.fetch;
+ window.fetch = async (input, init) => {
+ const request = new Request(input, init);
+ if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ const response = await original(input, init);
+ const data = await response.json();
+ return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
+ }
+ return original(input, init);
+ };
+ });
+ await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
+ await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
+ await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
+});
+
+test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
+ await login(page, 'mobile-admin', '/admin/config');
+ await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
+ const save = page.getByRole('button', { name: 'Сохранить', exact: true });
+ const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
+ await expect(save).not.toBeVisible();
+ await expect(apply).not.toBeVisible();
+ const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
+ await field.fill('6');
+ await expect(save).toBeVisible();
+ await field.clear();
+ await expect(save).not.toBeVisible();
+ await expect(apply).not.toBeVisible();
+ await field.fill('7');
+ await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
+ await expect(save).not.toBeVisible();
+ await expect(field).toHaveValue('');
+});
diff --git a/apps/web/src/features/server-management/ConfigPage.tsx b/apps/web/src/features/server-management/ConfigPage.tsx
index 55a040f..e80b5f3 100644
--- a/apps/web/src/features/server-management/ConfigPage.tsx
+++ b/apps/web/src/features/server-management/ConfigPage.tsx
@@ -46,8 +46,13 @@ export function ConfigPage() {
},
});
const change = (key: string, value: string | null) => {
- setBaseRevision((v) => v ?? query.data!.revision);
- setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset();
+ const entry = query.data!.entries.find((v) => v.key === key)!;
+ const unchanged = value === null ? entry.local === null
+ : entry.secret ? value === '' : value === (entry.local ?? '');
+ const next = { ...draft };
+ if (unchanged) delete next[key]; else next[key] = value;
+ setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
+ setDraft(next); setSaved(false); save.reset();
};
if (query.isPending) return ;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
@@ -71,10 +76,15 @@ export function ConfigPage() {
;
})}
{!entries.length &&
{t('management.no_results')}
}
-
- {saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}
-
-
+ {saved && !dirty && {t('management.saved')}
}
+ {dirty &&
+ {t('management.unsaved', { count: Object.keys(draft).length })}
+
+ }
+ {!dirty && query.data.restart_required && !running &&
+ {t('management.pending_restart')}
+
+ }
{save.error && {errorText(save.error)}}
{apply.error && {errorText(apply.error)}}
diff --git a/apps/web/src/features/server-management/UpdatesPage.tsx b/apps/web/src/features/server-management/UpdatesPage.tsx
index 51911d1..f9bca52 100644
--- a/apps/web/src/features/server-management/UpdatesPage.tsx
+++ b/apps/web/src/features/server-management/UpdatesPage.tsx
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useState } from 'react';
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
@@ -22,16 +22,26 @@ export function UpdatesPage() {
const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState(null);
+ const fresh = useRef(false);
+ const queryKey = ['server-updates', sourceId, channel, version];
const query = useQuery({
- queryKey: ['server-updates', sourceId],
- queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', {
- params: { query: { source_id: sourceId || undefined } },
- })),
- refetchInterval: 3000,
+ queryKey,
+ queryFn: async () => {
+ const force = fresh.current;
+ fresh.current = false;
+ return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
+ params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
+ }));
+ },
+ placeholderData: keepPreviousData,
+ refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running';
+ const checking = query.isFetching;
+ const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
+ const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
const save = useMutation({
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
@@ -39,18 +49,19 @@ export function UpdatesPage() {
});
const start = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
- body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined },
+ body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
})),
onSuccess: (job) => {
setConfirm(false);
- qc.setQueryData(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
+ qc.setQueryData(queryKey, (old) => old ? { ...old, job } : old);
void refresh();
},
+ onError: () => { void refresh(); },
});
if (query.isPending) return ;
return
{query.error &&
{errorText(query.error)}}
{query.data && <>
@@ -68,15 +79,21 @@ export function UpdatesPage() {
{channel === 'version' &&
}
{query.data.source_error &&
{t('management.source_unavailable')}}
{start.error &&
{errorText(start.error)}}
-
+
+
+
{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}
+ {query.data.target_version &&
{query.data.target_version} · {query.data.target_commit.slice(0, 12)}
}
+
+ {canInstall &&
}
+
>}
{confirm &&
}
{editing &&