fix(web): error/404 экраны, сплит бандла и bulk-мутации

- RouteErrorComponent/RouteNotFoundComponent на root-маршруте (RU-копирайт,
  retry + переход на главную; redirect в портал не мигает ошибкой)
- settings: форма инициализируется один раз — фоновый refetch больше не
  затирает ввод пользователя
- code splitting: autoCodeSplitting роутов + manualChunks (react/router/query/
  charts/dnd); вход ~507KB вместо единого чанка 1.57MB, recharts (330KB) грузится
  лениво; версия recharts в packages/ui выровнена с apps/web (3.8.0)
- bulk-эндпоинты: POST /agents/approve-bulk и PUT /policy-sets/:id/agents —
  назначение набора агентам одним запросом вместо N×(GET+PUT)
- оптимистичные обновления с rollback: approve, approve-bulk, удаление агента,
  переключение набора
- тесты bulk-операций (45 passed)
This commit is contained in:
Denozordec
2026-09-20 19:12:58 +07:00
parent 40030ce06c
commit 454c5009d1
12 changed files with 418 additions and 129 deletions
+66
View File
@@ -0,0 +1,66 @@
import { Link } from '@tanstack/react-router'
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import { Button } from '@evofw/ui/components/button'
/**
* Route-level error/not-found fallbacks (ReUI Frame look, RU copy).
* Preview: https://reui.io/preview/base/feature-4 (centered error panel)
*/
/** Error thrown by the root guard while the browser is already navigating to the portal. */
const PORTAL_REDIRECT_MESSAGE = 'redirecting to portal'
export function RouteErrorComponent({
error,
reset,
}: {
error: Error
reset: () => void
}) {
if (error.message === PORTAL_REDIRECT_MESSAGE) return null
return (
<div
role="alert"
className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center"
>
<CircleAlertIcon className="text-destructive size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Что-то пошло не так</h1>
<p className="text-muted-foreground max-w-md text-sm">
Раздел не загрузился. Проверьте соединение с API и попробуйте снова.
</p>
{import.meta.env.DEV && error.message ? (
<p className="text-muted-foreground/80 font-mono text-xs">
{error.message}
</p>
) : null}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={reset}>
Повторить
</Button>
<Button size="sm" variant="outline" render={<Link to="/" />}>
На главную
</Button>
</div>
</div>
)
}
export function RouteNotFoundComponent() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center">
<SearchXIcon className="text-muted-foreground size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Страница не найдена</h1>
<p className="text-muted-foreground max-w-md text-sm">
Адрес не существует или был перемещён.
</p>
</div>
<Button size="sm" render={<Link to="/" />}>
На главную
</Button>
</div>
)
}
+6
View File
@@ -6,6 +6,10 @@ import {
isTokenValid,
redirectToPortalLogin,
} from '@/lib/auth'
import {
RouteErrorComponent,
RouteNotFoundComponent,
} from '@/components/route-error'
export type RouterContext = {
queryClient: QueryClient
@@ -22,4 +26,6 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}
},
component: () => <Outlet />,
errorComponent: RouteErrorComponent,
notFoundComponent: RouteNotFoundComponent,
})
+39 -7
View File
@@ -125,21 +125,46 @@ function AgentsPage() {
[navigate],
)
/** Optimistically patch the agents list; returns a rollback fn. */
const patchAgentsCache = useCallback(
(patch: (items: Agent[]) => Agent[]) => {
const prev = qc.getQueryData<{ items: Agent[] }>(['agents'])
if (prev) qc.setQueryData(['agents'], { items: patch(prev.items) })
return () => {
if (prev) qc.setQueryData(['agents'], prev)
}
},
[qc],
)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onMutate: (id) =>
patchAgentsCache((items) =>
items.map((a) => (a.id === id ? { ...a, status: 'approved' } : a)),
),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(
ids.map((id) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.map((a) =>
target.has(a.id) ? { ...a, status: 'approved' } : a,
),
)
},
@@ -147,12 +172,16 @@ function AgentsPage() {
toast.success('Все pending одобрены')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const removeAgent = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onMutate: (id) => patchAgentsCache((items) => items.filter((a) => a.id !== id)),
onSuccess: (_data, id) => {
toast.success('Агент удалён')
setDeleteAgentId(null)
@@ -162,7 +191,10 @@ function AgentsPage() {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const items = agentsQ.data?.items ?? []
+20 -20
View File
@@ -44,7 +44,7 @@ import {
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { Agent } from '@evofw/shared'
import type { Agent, PolicySet } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Frame,
@@ -107,33 +107,33 @@ function PolicySetDetailPage() {
method: 'PATCH',
body: JSON.stringify(body),
}),
onMutate: (body) => {
const key = ['policy-sets', setId] as const
const prev = qc.getQueryData<PolicySet & { agent_ids: string[] }>(key)
if (prev && body.enabled !== undefined) {
qc.setQueryData(key, { ...prev, enabled: body.enabled })
}
return () => {
if (prev) qc.setQueryData(key, prev)
}
},
onSuccess: () => {
toast.success('Набор обновлён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _body, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const saveAgents = useMutation({
mutationFn: async () => {
const allAgents = agentsQ.data?.items ?? []
await Promise.all(
allAgents.map(async (a) => {
const current = await apiFetch<{
items: { set_id: string }[]
}>(`/api/v1/agents/${a.id}/policy-sets`)
const others = current.items
.map((i) => i.set_id)
.filter((id) => id !== setId)
const next = assignedIds.includes(a.id) ? [...others, setId] : others
await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, {
method: 'PUT',
body: JSON.stringify({ set_ids: next }),
})
}),
)
},
mutationFn: () =>
apiFetch(`/api/v1/policy-sets/${setId}/agents`, {
method: 'PUT',
body: JSON.stringify({ agent_ids: assignedIds }),
}),
onSuccess: () => {
toast.success('Назначение агентов сохранено')
setSelectedAgents(null)
+8 -2
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import {
Frame,
@@ -32,8 +32,13 @@ function SettingsPage() {
const settingsQ = useQuery(settingsQueryOptions())
const [form, setForm] = useState<Record<string, string>>({})
// Seed the form once — a background refetch must not wipe in-progress edits.
const initialized = useRef(false)
useEffect(() => {
if (settingsQ.data) setForm(settingsQ.data)
if (settingsQ.data && !initialized.current) {
initialized.current = true
setForm(settingsQ.data)
}
}, [settingsQ.data])
const save = useMutation({
@@ -133,6 +138,7 @@ function SettingsPage() {
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={!initialized.current}
loadingLabel="Сохранение…"
>
Сохранить