Files
glchat/web/src/pages/settings/InstanceSettingsPage.tsx
T

239 lines
9.0 KiB
TypeScript
Raw Normal View History

import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
fetchAudit,
fetchInstanceGuilds,
fetchInstanceSettings,
fetchInstanceUsers,
instanceAuditQueryKey,
instanceGuildsQueryKey,
instanceSettingsQueryKey,
instanceUsersQueryKey,
setInstanceAdmin,
type InstanceSettings,
} from '@/api/instance';
import { InstanceDashboard } from '@/components/instance/InstanceDashboard';
import { InstanceGuildsPanel } from '@/components/instance/InstanceGuildsPanel';
import { InstanceHealth } from '@/components/instance/InstanceHealth';
import { InstanceLimitsPanel } from '@/components/instance/InstanceLimitsPanel';
import { InstanceUsersPanel } from '@/components/instance/InstanceUsersPanel';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { formatDateTime } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useInstanceMetrics } from '@/lib/useInstanceMetrics';
const AUDIT_LIMIT = 50;
/** Разделы админ-панели (AGENT.md 7.19). */
const tabs = ['overview', 'limits', 'users', 'guilds', 'audit'] as const;
type InstanceTab = (typeof tabs)[number];
/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
export default function InstanceSettingsPage() {
const { t, i18n } = useTranslation();
const locale = i18n.resolvedLanguage ?? 'ru';
const currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const queryClient = useQueryClient();
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const openSettings = useUiStore((state) => state.openSettings);
const [tab, setTab] = useState<InstanceTab>('overview');
const settings = useQuery({
queryKey: instanceSettingsQueryKey,
queryFn: ({ signal }) => fetchInstanceSettings(signal),
enabled: isAdmin,
retry: 0,
});
const guilds = useQuery({
queryKey: instanceGuildsQueryKey,
queryFn: ({ signal }) => fetchInstanceGuilds(signal),
enabled: isAdmin,
retry: 0,
});
const users = useQuery({
queryKey: instanceUsersQueryKey,
queryFn: ({ signal }) => fetchInstanceUsers(signal),
enabled: isAdmin,
retry: 0,
});
const audit = useQuery({
queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
enabled: isAdmin,
retry: 0,
});
// Живые метрики: опрос раз в секунду, пока раздел открыт (AGENT.md 7.19).
const metrics = useInstanceMetrics(isAdmin);
const admin = useMutation({
mutationFn: (input: { userId: string; makeAdmin: boolean }) =>
setInstanceAdmin(input.userId, input.makeAdmin),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: instanceUsersQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceAuditQueryKey(AUDIT_LIMIT) });
},
});
if (!isAdmin) {
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.notAdmin')}</p>
</Card>
);
}
const tabLabels: Record<InstanceTab, string> = {
overview: t('settings.instance.tabs.overview'),
limits: t('settings.instance.tabs.limits'),
users: t('settings.instance.tabs.users', { count: users.data?.length ?? 0 }),
guilds: t('settings.instance.tabs.guilds', { count: guilds.data?.length ?? 0 }),
audit: t('settings.instance.tabs.audit'),
};
return (
<div className="flex flex-col gap-5">
<header>
<h2 className="text-lg font-semibold">{t('settings.instance.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.description')}</p>
</header>
{/* Вкладки админ-панели: обзор и здоровье, лимиты, пользователи, серверы, аудит. */}
<div
role="tablist"
aria-label={t('settings.instance.tabs.label')}
className="flex flex-wrap gap-1 border-b border-border/40"
data-testid="instance-tabs"
>
{tabs.map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={tab === item}
data-testid={`instance-tab-${item}`}
onClick={() => setTab(item)}
className={`-mb-px rounded-t-[var(--radius-sm)] border-b-2 px-3 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
tab === item
? 'border-accent text-fg'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
{tabLabels[item]}
</button>
))}
</div>
{tab === 'overview' ? (
<>
<InstanceDashboard state={metrics} />
<InstanceHealth state={metrics} />
</>
) : null}
{tab === 'limits' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.limits.title')}</h3>
<p className="mt-1 text-sm text-fg-muted">{t('settings.instance.limits.description')}</p>
{settings.isPending ? <LoadingNotice /> : null}
{settings.isError ? (
<ErrorNotice
className="mt-3"
error={settings.error}
onRetry={() => void settings.refetch()}
/>
) : null}
{settings.data === undefined ? null : (
<InstanceLimitsPanel
settings={settings.data}
onSaved={(updated: InstanceSettings) => {
void queryClient.setQueryData(instanceSettingsQueryKey, updated);
}}
/>
)}
</Card>
) : null}
{tab === 'users' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.usersTitle')}</h3>
{/* ID пользователей — единственное место, где он виден: раздел открыт
только администратору инстанса. */}
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
<InstanceUsersPanel
currentUserId={currentUser.data?.id}
users={users.data ?? []}
isPending={users.isPending}
error={users.error}
onRetry={() => void users.refetch()}
onToggleAdmin={(userId, makeAdmin) => admin.mutate({ userId, makeAdmin })}
adminPending={admin.isPending}
adminError={admin.error}
/>
</Card>
) : null}
{tab === 'guilds' ? (
<Card>
<h3 className="text-base font-semibold">{t('settings.instance.guildsTitle')}</h3>
<InstanceGuildsPanel
guilds={guilds.data ?? []}
isPending={guilds.isPending}
error={guilds.error}
onRetry={() => void guilds.refetch()}
onOpenSettings={(guildId) => {
// Настройки сервера открываются обычным разделом: у администратора
// инстанса там все права (AGENTS.md 7.19).
selectSettingsGuild(guildId);
openSettings({ section: 'server', guildId });
}}
/>
</Card>
) : null}
{tab === 'audit' ? (
<Card>
<h3 className="text-base font-semibold">
{t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
</h3>
{audit.isPending ? <LoadingNotice /> : null}
{audit.isError ? (
<ErrorNotice
className="mt-3"
error={audit.error}
onRetry={() => void audit.refetch()}
/>
) : null}
{audit.data !== undefined && audit.data.length === 0 ? (
<p className="mt-2 text-sm text-fg-muted">{t('settings.instance.auditEmpty')}</p>
) : null}
{audit.data === undefined || audit.data.length === 0 ? null : (
<ul className="mt-3 flex flex-col gap-1 text-sm" data-testid="instance-audit">
{audit.data.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
>
<span className="truncate">
{entry.action}
{entry.target === undefined ? '' : ` → ${entry.target}`}
</span>
<span className="shrink-0 text-xs text-fg-muted">
{formatDateTime(entry.created_at, locale)}
</span>
</li>
))}
</ul>
)}
</Card>
) : null}
</div>
);
}