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:
@@ -1,6 +1,11 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toUser, type RawUser } from '../mappers';
|
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` /
|
* Admin user management. The backend models authorization as `is_superuser` /
|
||||||
@@ -59,6 +64,20 @@ export const adminApi = api.injectEndpoints({
|
|||||||
body: { new_password: newPassword },
|
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,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -70,4 +89,7 @@ export const {
|
|||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useDeleteUserMutation,
|
useDeleteUserMutation,
|
||||||
useResetPasswordMutation,
|
useResetPasswordMutation,
|
||||||
|
useGetServicesQuery,
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useReindexMutation,
|
||||||
} = adminApi;
|
} = adminApi;
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ export interface UploadResponse {
|
|||||||
already_exists: boolean;
|
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 {
|
export interface StorageFormatBreakdown {
|
||||||
fileFormat: string;
|
fileFormat: string;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ import {
|
|||||||
useUpdateUserMutation,
|
useUpdateUserMutation,
|
||||||
useDeleteUserMutation,
|
useDeleteUserMutation,
|
||||||
useResetPasswordMutation,
|
useResetPasswordMutation,
|
||||||
|
useGetServicesQuery,
|
||||||
|
useGetSourcesQuery,
|
||||||
|
useReindexMutation,
|
||||||
} from '../../api/endpoints/admin';
|
} from '../../api/endpoints/admin';
|
||||||
import type { User } from '../../api/types';
|
import type { User, ServiceStatus } from '../../api/types';
|
||||||
|
|
||||||
function StubPanel({ title }: { title: string }) {
|
function StubPanel({ title }: { title: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -475,8 +478,174 @@ export function AdminSources() {
|
|||||||
return <StubPanel title={t('admin.tabs.sources')} />;
|
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() {
|
export function AdminInstance() {
|
||||||
const { t } = useTranslation();
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Window,
|
Window,
|
||||||
@@ -12,6 +13,9 @@ import {
|
|||||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
|
import { logout } from '../../store/slices/auth';
|
||||||
|
import { getActiveInstance } from '../../config/instances';
|
||||||
import {
|
import {
|
||||||
useGetSettingsQuery,
|
useGetSettingsQuery,
|
||||||
useUpdateSettingsMutation,
|
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() {
|
export function InstanceSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const instance = getActiveInstance();
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
dispatch(logout());
|
||||||
|
void navigate('/connect');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Window title={t('settings.tabs.instance')}>
|
<Window title={t('settings.tabs.instance')}>
|
||||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
<div
|
||||||
{t('common.comingSoon')}
|
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>
|
</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>
|
</Window>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -390,6 +390,12 @@ const en = {
|
|||||||
save: 'Save',
|
save: 'Save',
|
||||||
saving: 'Saving…',
|
saving: 'Saving…',
|
||||||
},
|
},
|
||||||
|
instance: {
|
||||||
|
current: 'Current instance',
|
||||||
|
none: 'No instance selected.',
|
||||||
|
manage: 'Manage instances',
|
||||||
|
signOut: 'Sign out',
|
||||||
|
},
|
||||||
playback: {
|
playback: {
|
||||||
streamQuality: 'Streaming quality',
|
streamQuality: 'Streaming quality',
|
||||||
streamQualityHint:
|
streamQualityHint:
|
||||||
@@ -472,6 +478,34 @@ const en = {
|
|||||||
cancel: 'Cancel',
|
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: {
|
notFound: {
|
||||||
title: 'Page not found',
|
title: 'Page not found',
|
||||||
|
|||||||
@@ -391,6 +391,12 @@ const ru: Translations = {
|
|||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
saving: 'Сохранение…',
|
saving: 'Сохранение…',
|
||||||
},
|
},
|
||||||
|
instance: {
|
||||||
|
current: 'Текущий инстанс',
|
||||||
|
none: 'Инстанс не выбран.',
|
||||||
|
manage: 'Управление инстансами',
|
||||||
|
signOut: 'Выйти',
|
||||||
|
},
|
||||||
playback: {
|
playback: {
|
||||||
streamQuality: 'Качество потока',
|
streamQuality: 'Качество потока',
|
||||||
streamQualityHint:
|
streamQualityHint:
|
||||||
@@ -473,6 +479,34 @@ const ru: Translations = {
|
|||||||
cancel: 'Отмена',
|
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: {
|
notFound: {
|
||||||
title: 'Страница не найдена',
|
title: 'Страница не найдена',
|
||||||
|
|||||||
Reference in New Issue
Block a user