fix(library): expose scan activity and failed analysis file details
ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s

This commit is contained in:
mrleo1nid committed 2026-10-22 15:25:48 +03:00
1 parent 17b6dd2041
commit 0dc0384c78
20 files changed
+1114 -326

No files matched your search

@@ -0,0 +1,59 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
afterEach(cleanup);
async function fixture(mode: 'pages' | 'empty' | 'error' = 'pages') {
const cursors: (string | null)[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
const cursor = new URL(req.url).searchParams.get('cursor');
cursors.push(cursor);
if (mode === 'error') return new Response(JSON.stringify({ code: 'server.error' }), { status: 500 });
return new Response(JSON.stringify({
items: mode === 'empty' ? [] : cursor ? [{ track_id: 'second', title: 'Без файла', source_id: null, source_name: null, rel_path: null, error: '' }]
: [{ track_id: 'first', title: 'Короткий трек', source_id: 'source', source_name: 'Base', rel_path: 'Исполнитель/Альбом/track.mp3', error: 'audio is shorter than 1 s' }],
next_cursor: mode === 'pages' && !cursor ? 'next-page' : null,
}), { headers: { 'Content-Type': 'application/json' } });
} });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const i18n = await createI18n('ru');
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<AnalysisErrorsDialog onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { close, cursors, user: userEvent.setup() };
}
it('shows storage, path and error, loads the next page and closes with Escape', async () => {
const { user, cursors, close } = await fixture();
expect(screen.getByRole('dialog', { name: 'Ошибки аудиоанализа' })).toBeVisible();
expect(await screen.findByText('Исполнитель/Альбом/track.mp3')).toBeVisible();
expect(screen.getByText('Хранилище: Base')).toBeVisible();
expect(screen.getByText('audio is shorter than 1 s')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Показать ещё' }));
expect(await screen.findByText('В библиотеке больше нет файла этого трека.')).toBeVisible();
expect(screen.getByText('Короткий трек')).toBeVisible();
expect(cursors).toEqual([null, 'next-page']);
expect(screen.queryByRole('button', { name: 'Показать ещё' })).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(close).toHaveBeenCalledOnce();
});
it('shows an empty state after failed jobs have been retried', async () => {
await fixture('empty');
expect(await screen.findByText('Неудавшихся задач анализа нет.')).toBeVisible();
});
it('shows a request failure without claiming the list is empty', async () => {
await fixture('error');
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.queryByText('Неудавшихся задач анализа нет.')).not.toBeInTheDocument();
});
@@ -0,0 +1,40 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import s from './library.module.css';
export function AnalysisErrorsDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const errors = useInfiniteQuery({
queryKey: ['analysis-errors'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => unwrap(await session.client.GET('/api/v1/admin/library/analysis/errors', {
params: { query: { limit: 50, cursor: pageParam } },
})),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const items = errors.data?.pages.flatMap((page) => page.items) ?? [];
return <Dialog title={t('library.analysis_errors_title')} onClose={onClose}
footer={<Button onClick={onClose}>{t('common:form.close')}</Button>}>
<p>{t('library.analysis_errors_hint')}</p>
{errors.isPending && <p role="status">{t('common:loading')}</p>}
<FormError>{errors.error ? errorText(errors.error) : null}</FormError>
{errors.isSuccess && items.length === 0 && <p>{t('library.analysis_errors_empty')}</p>}
{items.length > 0 && <ul className={s.analysisErrors}>
{items.map((item) => <li key={item.track_id}>
<strong>{item.title}</strong>
{item.source_name && <span className={s.scanSummary}>{t('library.analysis_error_source', { name: item.source_name })}</span>}
<code className={s.analysisPath}>{item.rel_path ?? t('library.analysis_error_no_file')}</code>
<span className={s.scanError}>{item.error || t('library.analysis_error_unknown')}</span>
</li>)}
</ul>}
{errors.hasNextPage && <Button busy={errors.isFetchingNextPage} onClick={() => void errors.fetchNextPage()}>{t('library.analysis_errors_more')}</Button>}
</Dialog>;
}
+15 -5
View File
@@ -1,6 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() {
@@ -18,6 +19,7 @@ export function LibraryPage() {
const user = useUser();
const errorText = useErrorText();
const admin = user.role === 'admin';
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
const status = useQuery({
queryKey: ['library-status'],
@@ -27,6 +29,7 @@ export function LibraryPage() {
const errors = useQuery({
queryKey: ['library-errors'],
enabled: admin,
refetchInterval: status.data?.sources.some((source) => source.scan?.running) ? 2000 : 15000,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
});
const reindex = useMutation({
@@ -35,7 +38,10 @@ export function LibraryPage() {
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['library-status'] });
void queryClient.invalidateQueries({ queryKey: ['analysis-errors'] });
},
});
const d = status.data;
const tiles = d
@@ -89,13 +95,17 @@ export function LibraryPage() {
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
<div className={l.analysisActions}>
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
</div>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
</section>
)}
{d && (
@@ -0,0 +1,42 @@
import type { components } from '@pipu/api-client';
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it } from 'vitest';
import { ScanLine } from './ScanStatus';
afterEach(cleanup);
const scan: components['schemas']['ScanProgress'] = {
running: true, full: false, started_at: '2026-10-22T12:00:00Z', finished_at: null,
found: 12, to_process: 0, processed: 0, added: 0, updated: 0, moved: 0, missing: 0, errors: 0, error: null,
phase: 'walking', walked_dirs: 4, current_dir: 'Artist/Album',
};
async function fixture(overrides: Partial<typeof scan> = {}) {
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><ScanLine scan={{ ...scan, ...overrides }} lastScanAt={null} /></I18nextProvider>);
}
it('shows changing folder and file counts during the walk without a misleading percentage', async () => {
await fixture();
expect(screen.getByText(/Проверено папок: 4 · найдено аудиофайлов: 12/)).toBeVisible();
expect(screen.getByText('Текущая папка: Artist/Album')).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('shows a percentage only once file processing has a known total', async () => {
await fixture({ phase: 'processing', to_process: 12, processed: 3 });
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
expect(screen.queryByText(/Текущая папка/)).not.toBeInTheDocument();
});
it.each([['preparing', 'Сопоставление файлов с библиотекой…'], ['finalizing', 'Завершение сканирования…']] as const)('labels the %s stage', async (phase, label) => {
await fixture({ phase });
expect(screen.getByText(label)).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('supports servers without the new progress fields', async () => {
await fixture({ phase: undefined, walked_dirs: undefined, current_dir: undefined, found: 0 });
expect(screen.getByText('Обход папок…')).toBeVisible();
});
+11 -3
View File
@@ -19,14 +19,22 @@ export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; last
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const phase = scan.phase ?? (scan.found === 0 ? 'walking' : scan.to_process > 0 ? 'processing' : 'preparing');
const determinate = phase === 'processing' && scan.to_process > 0;
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
const state = phase === 'walking'
? scan.walked_dirs === undefined ? t('sources.scan_walking') : t('sources.scan_walk_progress', { dirs: scan.walked_dirs.toLocaleString(i18n.language), found: scan.found.toLocaleString(i18n.language) })
: phase === 'preparing' ? t('sources.scan_preparing')
: phase === 'finalizing' ? t('sources.scan_finalizing')
: t('sources.scanning', { processed: scan.processed, total: scan.to_process });
return (
<div className={s.scan}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
{state}
</div>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
{phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
<div className={cx(s.bar, !determinate && s.indeterminate)} role="progressbar" aria-labelledby={id} aria-valuenow={determinate ? pct : undefined} aria-valuemin={0} aria-valuemax={100}>
<span style={determinate ? { width: `${pct}%` } : undefined} />
</div>
</div>
);
@@ -123,8 +123,14 @@
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.analysisActions { display: flex; flex-wrap: wrap; gap: 8px; }
.analysisErrors { display: grid; gap: 16px; list-style: none; margin: 16px 0; padding: 0; }
.analysisErrors li { display: grid; gap: 6px; padding-bottom: 16px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
.analysisPath { font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.errorDetail {
display: block;
margin-top: 4px;
@@ -146,6 +152,26 @@
transition: width 0.4s;
}
.scanPath {
font-size: 12px;
color: var(--text3);
overflow-wrap: anywhere;
}
.indeterminate span {
width: 35%;
animation: scanActivity 1.5s ease-in-out infinite alternate;
}
@keyframes scanActivity {
from { transform: translateX(0); }
to { transform: translateX(185%); }
}
@media (prefers-reduced-motion: reduce) {
.indeterminate span { animation: none; }
}
.test {
font-size: 13px;
}