diff --git a/src/api/endpoints/admin.ts b/src/api/endpoints/admin.ts index 142aa14..775ffa4 100644 --- a/src/api/endpoints/admin.ts +++ b/src/api/endpoints/admin.ts @@ -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({ + query: () => '/admin/services', + }), + getSources: build.query({ + query: () => '/admin/sources', + }), + reindex: build.mutation({ + 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; diff --git a/src/api/types.ts b/src/api/types.ts index 7434aa4..6e16cb3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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; diff --git a/src/features/admin/panels.tsx b/src/features/admin/panels.tsx index 0e7c70a..f03f0cb 100644 --- a/src/features/admin/panels.tsx +++ b/src/features/admin/panels.tsx @@ -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 ; } -/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */ +const SERVICE_VARIANT: Record = { + ok: 'lime', + down: 'ember', + skipped: 'neutral', +}; + +/** `/admin/instance` — backing-service health, source status, library reindex. */ export function AdminInstance() { const { t } = useTranslation(); - return ; + 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 ( + +
+ {/* ── Services ─────────────────────────────────────────────── */} +
+

+ {t('admin.instance.services.title')} +

+ {servicesLoading && } + {servicesError && } + {servicesData && ( +
+ {(['database', 'redis', 'ml'] as const).map((key) => { + const status = servicesData[key]; + return ( + + + {t(`admin.instance.services.${key}`)} + + + {t(`admin.instance.status.${status}`)} + + + ); + })} +
+ )} +
+ + {/* ── Sources (read-only; configured via env) ──────────────── */} +
+

+ {t('admin.instance.sources.title')} +

+

+ {t('admin.instance.sources.envNote')} +

+ {sourcesLoading && } + {sourcesError && } + {sourcesData && sourcesData.length === 0 && ( + + )} + {sourcesData && sourcesData.length > 0 && ( +
+ {sourcesData.map((s) => ( + + + {s.label} + + {s.kind} + + + + {t( + s.available + ? 'admin.instance.sources.available' + : 'admin.instance.sources.unavailable', + )} + + + ))} +
+ )} +
+ + {/* ── Reindex ──────────────────────────────────────────────── */} +
+

+ {t('admin.instance.reindex.title')} +

+

+ {t('admin.instance.reindex.description')} +

+ {reindexMsg && ( +
+ {reindexMsg.text} +
+ )} + +
+
+
+ ); } diff --git a/src/features/settings/panels.tsx b/src/features/settings/panels.tsx index 4352aab..109eda8 100644 --- a/src/features/settings/panels.tsx +++ b/src/features/settings/panels.tsx @@ -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 ( -

- {t('common.comingSoon')} -

+
+
+ {t('settings.instance.current')} + {instance ? ( +
+
{instance.name}
+
+ {instance.baseUrl} +
+
+ ) : ( +

+ {t('settings.instance.none')} +

+ )} +
+ +
+ + +
+
); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ffaaf0f..d9156b8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9aef23a..6388d10 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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: 'Страница не найдена',