feat(admin): wire instance health panel + settings instance (A9/A10)

AdminInstance shows backing-service health (db/redis/ml), read-only source
status (env-configured), and a library reindex with graceful handling when no
indexable source exists. InstanceSettings shows the active instance and offers
switch/sign-out to /connect. Adds getServices/getSources/reindex endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 20:43:27 +03:00
parent 3eba3701d0
commit 87fac7a5a1
6 changed files with 327 additions and 8 deletions
+23 -1
View File
@@ -1,6 +1,11 @@
import { api } from '../index';
import { toUser, type RawUser } from '../mappers';
import type { User } from '../types';
import type { User, ServicesStatus, SourceInfo } from '../types';
interface ReindexJob {
source: string;
jobId: string;
}
/**
* Admin user management. The backend models authorization as `is_superuser` /
@@ -59,6 +64,20 @@ export const adminApi = api.injectEndpoints({
body: { new_password: newPassword },
}),
}),
// -- instance (§A9) -------------------------------------------------------
getServices: build.query<ServicesStatus, void>({
query: () => '/admin/services',
}),
getSources: build.query<SourceInfo[], void>({
query: () => '/admin/sources',
}),
reindex: build.mutation<ReindexJob[], void>({
query: () => ({ url: '/admin/reindex', method: 'POST' }),
transformResponse: (raw: { jobs: { source: string; job_id: string }[] }) =>
raw.jobs.map((j) => ({ source: j.source, jobId: j.job_id })),
// A fresh scan changes the catalogue once the worker runs.
invalidatesTags: ['Track', 'Album', 'Artist', 'Storage'],
}),
}),
overrideExisting: false,
});
@@ -70,4 +89,7 @@ export const {
useUpdateUserMutation,
useDeleteUserMutation,
useResetPasswordMutation,
useGetServicesQuery,
useGetSourcesQuery,
useReindexMutation,
} = adminApi;
+10
View File
@@ -140,6 +140,16 @@ export interface UploadResponse {
already_exists: boolean;
}
/** One backing-dependency probe result (GET /admin/services). `skipped` = the
* dependency is optional and not configured (e.g. ML with no ML_SERVICE_URL). */
export type ServiceStatus = 'ok' | 'down' | 'skipped';
export interface ServicesStatus {
database: ServiceStatus;
redis: ServiceStatus;
ml: ServiceStatus;
}
export interface StorageFormatBreakdown {
fileFormat: string;
trackCount: number;
+172 -3
View File
@@ -29,8 +29,11 @@ import {
useUpdateUserMutation,
useDeleteUserMutation,
useResetPasswordMutation,
useGetServicesQuery,
useGetSourcesQuery,
useReindexMutation,
} from '../../api/endpoints/admin';
import type { User } from '../../api/types';
import type { User, ServiceStatus } from '../../api/types';
function StubPanel({ title }: { title: string }) {
const { t } = useTranslation();
@@ -475,8 +478,174 @@ export function AdminSources() {
return <StubPanel title={t('admin.tabs.sources')} />;
}
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
const SERVICE_VARIANT: Record<ServiceStatus, 'lime' | 'ember' | 'neutral'> = {
ok: 'lime',
down: 'ember',
skipped: 'neutral',
};
/** `/admin/instance` — backing-service health, source status, library reindex. */
export function AdminInstance() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.instance')} />;
const {
data: servicesData,
isLoading: servicesLoading,
isError: servicesError,
refetch: refetchServices,
} = useGetServicesQuery();
const {
data: sourcesData,
isLoading: sourcesLoading,
isError: sourcesError,
refetch: refetchSources,
} = useGetSourcesQuery();
const [reindex, { isLoading: isReindexing }] = useReindexMutation();
const [reindexMsg, setReindexMsg] = useState<
{ kind: 'success' | 'danger'; text: string } | null
>(null);
const handleReindex = async () => {
setReindexMsg(null);
try {
const jobs = await reindex().unwrap();
setReindexMsg({
kind: 'success',
text: t('admin.instance.reindex.enqueued', { count: jobs.length }),
});
} catch {
// 400 when no indexable source is configured (see admin.py::trigger_reindex).
setReindexMsg({
kind: 'danger',
text: t('admin.instance.reindex.noSource'),
});
}
};
return (
<Window title={t('admin.tabs.instance')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* ── Services ─────────────────────────────────────────────── */}
<section>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '0.95rem' }}>
{t('admin.instance.services.title')}
</h3>
{servicesLoading && <LoadingSkeleton rows={3} height={40} />}
{servicesError && <ErrorState onRetry={refetchServices} />}
{servicesData && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
{(['database', 'redis', 'ml'] as const).map((key) => {
const status = servicesData[key];
return (
<Card
key={key}
style={{
padding: '0.625rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}}
>
<span style={{ flex: 1 }}>
{t(`admin.instance.services.${key}`)}
</span>
<Badge variant={SERVICE_VARIANT[status]} dot>
{t(`admin.instance.status.${status}`)}
</Badge>
</Card>
);
})}
</div>
)}
</section>
{/* ── Sources (read-only; configured via env) ──────────────── */}
<section>
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
{t('admin.instance.sources.title')}
</h3>
<p
style={{
margin: '0 0 0.5rem',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('admin.instance.sources.envNote')}
</p>
{sourcesLoading && <LoadingSkeleton rows={2} height={40} />}
{sourcesError && <ErrorState onRetry={refetchSources} />}
{sourcesData && sourcesData.length === 0 && (
<EmptyState icon="⊘" title={t('admin.instance.sources.empty')} />
)}
{sourcesData && sourcesData.length > 0 && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
{sourcesData.map((s) => (
<Card
key={s.name}
style={{
padding: '0.625rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}}
>
<span style={{ flex: 1 }}>
{s.label}
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
}}
>
{s.kind}
</span>
</span>
<Badge variant={s.available ? 'lime' : 'ember'} dot>
{t(
s.available
? 'admin.instance.sources.available'
: 'admin.instance.sources.unavailable',
)}
</Badge>
</Card>
))}
</div>
)}
</section>
{/* ── Reindex ──────────────────────────────────────────────── */}
<section>
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
{t('admin.instance.reindex.title')}
</h3>
<p
style={{
margin: '0 0 0.75rem',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{t('admin.instance.reindex.description')}
</p>
{reindexMsg && (
<div style={{ marginBottom: '0.75rem' }}>
<Callout variant={reindexMsg.kind}>{reindexMsg.text}</Callout>
</div>
)}
<Button
variant="primary"
disabled={isReindexing}
onClick={() => void handleReindex()}
>
{t('admin.instance.reindex.button')}
</Button>
</section>
</div>
</Window>
);
}
+54 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
Window,
@@ -12,6 +13,9 @@ import {
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { logout } from '../../store/slices/auth';
import { getActiveInstance } from '../../config/instances';
import {
useGetSettingsQuery,
useUpdateSettingsMutation,
@@ -316,14 +320,60 @@ export function ScrobblingSettings() {
);
}
/** `/settings/instance` — switch/forget instance. Scaffold. */
/** `/settings/instance` — show the active instance; switch or sign out (both
* routes back to /connect, the full instance manager). */
export function InstanceSettings() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const instance = getActiveInstance();
const handleSignOut = () => {
dispatch(logout());
void navigate('/connect');
};
return (
<Window title={t('settings.tabs.instance')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<div>
<span style={labelStyle}>{t('settings.instance.current')}</span>
{instance ? (
<div>
<div style={{ fontWeight: 600 }}>{instance.name}</div>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
wordBreak: 'break-all',
}}
>
{instance.baseUrl}
</div>
</div>
) : (
<p style={{ margin: 0, color: 'var(--color-text-2)' }}>
{t('settings.instance.none')}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button variant="ghost" onClick={() => void navigate('/connect')}>
{t('settings.instance.manage')}
</Button>
<Button variant="ember" onClick={handleSignOut}>
{t('settings.instance.signOut')}
</Button>
</div>
</div>
</Window>
);
}
+34
View File
@@ -390,6 +390,12 @@ const en = {
save: 'Save',
saving: 'Saving…',
},
instance: {
current: 'Current instance',
none: 'No instance selected.',
manage: 'Manage instances',
signOut: 'Sign out',
},
playback: {
streamQuality: 'Streaming quality',
streamQualityHint:
@@ -472,6 +478,34 @@ const en = {
cancel: 'Cancel',
},
},
instance: {
status: {
ok: 'Healthy',
down: 'Down',
skipped: 'Not configured',
},
services: {
title: 'Services',
database: 'Database',
redis: 'Redis',
ml: 'ML service',
},
sources: {
title: 'Sources',
envNote: 'Sources are configured via environment variables.',
empty: 'No sources configured.',
available: 'Available',
unavailable: 'Unavailable',
},
reindex: {
title: 'Reindex library',
description:
'Re-scan every indexable source. Runs in the background and is idempotent.',
button: 'Reindex',
enqueued: 'Reindex queued ({{count}} sources).',
noSource: 'No indexable source is configured.',
},
},
},
notFound: {
title: 'Page not found',
+34
View File
@@ -391,6 +391,12 @@ const ru: Translations = {
save: 'Сохранить',
saving: 'Сохранение…',
},
instance: {
current: 'Текущий инстанс',
none: 'Инстанс не выбран.',
manage: 'Управление инстансами',
signOut: 'Выйти',
},
playback: {
streamQuality: 'Качество потока',
streamQualityHint:
@@ -473,6 +479,34 @@ const ru: Translations = {
cancel: 'Отмена',
},
},
instance: {
status: {
ok: 'Работает',
down: 'Недоступен',
skipped: 'Не настроен',
},
services: {
title: 'Сервисы',
database: 'База данных',
redis: 'Redis',
ml: 'ML-сервис',
},
sources: {
title: 'Источники',
envNote: 'Источники настраиваются через переменные окружения.',
empty: 'Источники не настроены.',
available: 'Доступен',
unavailable: 'Недоступен',
},
reindex: {
title: 'Переиндексация библиотеки',
description:
'Пересканировать все индексируемые источники. Выполняется в фоне, идемпотентно.',
button: 'Переиндексировать',
enqueued: 'Переиндексация запущена ({{count}} источников).',
noSource: 'Индексируемые источники не настроены.',
},
},
},
notFound: {
title: 'Страница не найдена',