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
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:
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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user