Files
mcma-webui/src/features/admin/panels.tsx
T
Цвылев Александр Вадимович 87fac7a5a1 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>
2026-07-28 20:43:27 +03:00

652 lines
20 KiB
TypeScript

import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
Window,
Card,
Button,
TextField,
SegmentedControl,
Dialog,
AlertDialog,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
IconButton,
Badge,
Callout,
} from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState';
import { usePermissions } from '../../hooks/usePermissions';
import {
useGetUsersQuery,
useGetUserQuery,
useCreateUserMutation,
useUpdateUserMutation,
useDeleteUserMutation,
useResetPasswordMutation,
useGetServicesQuery,
useGetSourcesQuery,
useReindexMutation,
} from '../../api/endpoints/admin';
import type { User, ServiceStatus } from '../../api/types';
function StubPanel({ title }: { title: string }) {
const { t } = useTranslation();
return (
<Window title={title}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** Role + active-status badges shared by the list and the detail view. */
function UserBadges({ user }: { user: User }) {
const { t } = useTranslation();
return (
<div style={{ display: 'flex', gap: '0.375rem', alignItems: 'center' }}>
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'}>
{t(`admin.users.role.${user.role}`)}
</Badge>
{!user.isActive && (
<Badge variant="ember">{t('admin.users.status.blocked')}</Badge>
)}
</div>
);
}
/** `/admin/users` — user list with add / role toggle / (de)activate / reset password. */
export function AdminUsers() {
const { t } = useTranslation();
const { data: users, isLoading, isError, refetch } = useGetUsersQuery();
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
const [createOpen, setCreateOpen] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'admin' | 'user'>('user');
const [createError, setCreateError] = useState(false);
const resetForm = () => {
setUsername('');
setPassword('');
setRole('user');
setCreateError(false);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const name = username.trim();
if (!name || !password) return;
setCreateError(false);
try {
await createUser({ username: name, password, role }).unwrap();
setCreateOpen(false);
resetForm();
} catch {
setCreateError(true);
}
};
return (
<Window title={t('admin.tabs.users')}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '1rem',
}}
>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
title={t('admin.users.newDialog.title')}
trigger={
<Button variant="primary">
<Icon name="plus" /> {t('admin.users.add')}
</Button>
}
footer={
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
}}
>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
{t('admin.users.newDialog.cancel')}
</Button>
<Button
variant="primary"
disabled={!username.trim() || !password || isCreating}
onClick={(e) => void handleCreate(e)}
>
{t('admin.users.newDialog.create')}
</Button>
</div>
}
>
<form
onSubmit={(e) => void handleCreate(e)}
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
>
<TextField
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('admin.users.newDialog.username')}
autoComplete="off"
/>
<TextField
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('admin.users.newDialog.password')}
autoComplete="new-password"
/>
<SegmentedControl
value={role}
onValueChange={(v) => setRole(v === 'admin' ? 'admin' : 'user')}
items={[
{ value: 'user', label: t('admin.users.role.user') },
{ value: 'admin', label: t('admin.users.role.admin') },
]}
/>
{createError && (
<Callout variant="danger">
{t('admin.users.newDialog.error')}
</Callout>
)}
</form>
</Dialog>
</div>
{isLoading && <LoadingSkeleton rows={5} height={56} />}
{isError && <ErrorState onRetry={refetch} />}
{!isLoading && !isError && (users?.length ?? 0) === 0 && (
<EmptyState icon="☺" title={t('admin.users.empty')} />
)}
{!isLoading && !isError && users && users.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{users.map((user) => (
<UserRow key={user.id} user={user} />
))}
</div>
)}
</Window>
);
}
/** One row in the user list: identity + badges + actions menu. */
function UserRow({ user }: { user: User }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user: currentUser } = usePermissions();
const isSelf = currentUser?.id === user.id;
const [updateUser] = useUpdateUserMutation();
const [deleteUser] = useDeleteUserMutation();
const [resetOpen, setResetOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const toggleRole = () =>
void updateUser({
id: user.id,
role: user.role === 'admin' ? 'user' : 'admin',
});
const toggleActive = () =>
void updateUser({ id: user.id, isActive: !user.isActive });
return (
<Card
style={{
padding: '0.75rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
}}
>
<button
type="button"
onClick={() => void navigate(`/admin/users/${user.id}`)}
style={{
flex: 1,
minWidth: 0,
textAlign: 'left',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
font: 'inherit',
color: 'inherit',
}}
>
<div
style={{
fontWeight: 600,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{user.username}
{isSelf && (
<span
style={{
marginLeft: '0.5rem',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
fontWeight: 400,
}}
>
{t('admin.users.you')}
</span>
)}
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('admin.users.added', {
date: new Date(user.createdAt).toLocaleDateString(),
})}
</div>
</button>
<UserBadges user={user} />
<Menu>
<MenuTrigger asChild>
<IconButton
variant="ghost"
size="sm"
aria-label={t('admin.users.menu.options')}
>
⋯
</IconButton>
</MenuTrigger>
<MenuContent>
<MenuItem onSelect={toggleRole}>
{user.role === 'admin'
? t('admin.users.menu.makeUser')
: t('admin.users.menu.makeAdmin')}
</MenuItem>
<MenuItem onSelect={() => setResetOpen(true)}>
{t('admin.users.menu.resetPassword')}
</MenuItem>
{/* Guard against locking yourself out of the instance. */}
{!isSelf && (
<MenuItem
onSelect={() =>
user.isActive ? setDeactivateOpen(true) : toggleActive()
}
>
{user.isActive
? t('admin.users.menu.deactivate')
: t('admin.users.menu.activate')}
</MenuItem>
)}
</MenuContent>
</Menu>
<ResetPasswordDialog
user={user}
open={resetOpen}
onOpenChange={setResetOpen}
/>
<AlertDialog
open={deactivateOpen}
onOpenChange={setDeactivateOpen}
title={t('admin.users.deactivateDialog.title')}
description={t('admin.users.deactivateDialog.description', {
name: user.username,
})}
cancelLabel={t('admin.users.deactivateDialog.cancel')}
actionLabel={t('admin.users.deactivateDialog.confirm')}
destructive
onAction={() => void deleteUser(user.id)}
/>
</Card>
);
}
/** Reset-password dialog: admin sets a new password for the account. */
function ResetPasswordDialog({
user,
open,
onOpenChange,
}: {
user: User;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [resetPassword, { isLoading }] = useResetPasswordMutation();
const [value, setValue] = useState('');
const [done, setDone] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!value) return;
await resetPassword({ id: user.id, newPassword: value }).unwrap();
setValue('');
setDone(true);
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
onOpenChange(o);
if (!o) {
setValue('');
setDone(false);
}
}}
title={t('admin.users.resetDialog.title', { name: user.username })}
footer={
<div
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('admin.users.resetDialog.cancel')}
</Button>
<Button
variant="primary"
disabled={!value || isLoading}
onClick={(e) => void handleSubmit(e)}
>
{t('admin.users.resetDialog.submit')}
</Button>
</div>
}
>
{done ? (
<Callout variant="success">{t('admin.users.resetDialog.done')}</Callout>
) : (
<form onSubmit={(e) => void handleSubmit(e)}>
<TextField
autoFocus
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={t('admin.users.resetDialog.newPassword')}
autoComplete="new-password"
/>
</form>
)}
</Dialog>
);
}
/** `/admin/users/:userId` — per-user detail: identity, role toggle, reset, status. */
export function AdminUserDetail() {
const { t } = useTranslation();
const navigate = useNavigate();
const { userId = '' } = useParams();
const { user: currentUser } = usePermissions();
const { data: user, isLoading, isError, refetch } = useGetUserQuery(userId, {
skip: !userId,
});
const [updateUser] = useUpdateUserMutation();
const [resetOpen, setResetOpen] = useState(false);
const isSelf = currentUser?.id === user?.id;
return (
<Window title={t('admin.userDetail')}>
<Button variant="ghost" size="sm" onClick={() => void navigate('/admin/users')}>
← {t('common.back')}
</Button>
<div style={{ marginTop: '1rem' }}>
{isLoading && <LoadingSkeleton rows={3} height={40} />}
{isError && <ErrorState onRetry={refetch} />}
{user && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
>
<div
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>{user.username}</h2>
<UserBadges user={user} />
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('admin.users.added', {
date: new Date(user.createdAt).toLocaleDateString(),
})}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<Button
variant="ghost"
onClick={() =>
void updateUser({
id: user.id,
role: user.role === 'admin' ? 'user' : 'admin',
})
}
>
{user.role === 'admin'
? t('admin.users.menu.makeUser')
: t('admin.users.menu.makeAdmin')}
</Button>
<Button variant="ghost" onClick={() => setResetOpen(true)}>
{t('admin.users.menu.resetPassword')}
</Button>
{!isSelf && (
<Button
variant={user.isActive ? 'ember' : 'ghost'}
onClick={() =>
void updateUser({ id: user.id, isActive: !user.isActive })
}
>
{user.isActive
? t('admin.users.menu.deactivate')
: t('admin.users.menu.activate')}
</Button>
)}
</div>
<ResetPasswordDialog
user={user}
open={resetOpen}
onOpenChange={setResetOpen}
/>
</div>
)}
</div>
</Window>
);
}
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
export function AdminSources() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.sources')} />;
}
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();
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>
);
}