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
+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>
);
}