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:
@@ -5,6 +5,7 @@ import {
|
||||
putAgentPolicySetsBodySchema,
|
||||
patchAgentBodySchema,
|
||||
cloneFromBodySchema,
|
||||
agentIdsBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
|
||||
@@ -147,6 +148,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return mapAgent(updated!)
|
||||
})
|
||||
|
||||
app.post('/agents/approve-bulk', async (req) => {
|
||||
const body = agentIdsBodySchema.parse(req.body)
|
||||
const now = new Date().toISOString()
|
||||
const approved: string[] = []
|
||||
app.sqlite.transaction(() => {
|
||||
for (const id of body.agent_ids) {
|
||||
const a = repos.getAgent(app.db, id)
|
||||
if (!a || (a.status !== 'pending' && a.status !== 'invited')) continue
|
||||
repos.updateAgent(app.db, a.id, {
|
||||
status: 'approved',
|
||||
approvedAt: now,
|
||||
})
|
||||
repos.ensureSharedSetAssigned(app.db, a.id)
|
||||
approved.push(a.id)
|
||||
}
|
||||
})()
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.approve',
|
||||
targetType: 'app_resource',
|
||||
targetId: approved[0] ?? '',
|
||||
summary: `Массовое одобрение агентов: ${approved.length}`,
|
||||
details: { agent_ids: approved },
|
||||
})
|
||||
return { items: approved.map((id) => mapAgent(repos.getAgent(app.db, id)!)) }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
|
||||
const a = repos.getAgent(app.db, req.params.id)
|
||||
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
|
||||
import {
|
||||
createPolicySetBodySchema,
|
||||
patchPolicySetBodySchema,
|
||||
agentIdsBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Replace which agents have this set assigned: listed agents gain the set
|
||||
* (other assignments preserved), unlisted agents lose it.
|
||||
*/
|
||||
app.put<{ Params: { id: string } }>(
|
||||
'/policy-sets/:id/agents',
|
||||
async (req) => {
|
||||
const set = repos.getPolicySet(app.db, req.params.id)
|
||||
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||
const body = agentIdsBodySchema.parse(req.body)
|
||||
|
||||
const target = new Set(body.agent_ids)
|
||||
for (const agentId of body.agent_ids) {
|
||||
if (!repos.getAgent(app.db, agentId)) {
|
||||
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
|
||||
}
|
||||
}
|
||||
|
||||
const current = repos.listAgentIdsForSet(app.db, set.id)
|
||||
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
|
||||
const toRemove = current.filter((id) => !target.has(id))
|
||||
|
||||
const applyAssignment = (agentId: string, withSet: boolean) => {
|
||||
const others = repos
|
||||
.listSetsForAgent(app.db, agentId)
|
||||
.map((s) => s.setId)
|
||||
.filter((id) => id !== set.id)
|
||||
const next = withSet ? [...others, set.id] : others
|
||||
repos.setAgentPolicySets(app.db, agentId, next)
|
||||
}
|
||||
|
||||
app.sqlite.transaction(() => {
|
||||
for (const agentId of toAdd) applyAssignment(agentId, true)
|
||||
for (const agentId of toRemove) applyAssignment(agentId, false)
|
||||
})()
|
||||
|
||||
auditMutation(app, config, req, {
|
||||
action: 'policy_set.agents.update',
|
||||
targetType: 'app_resource',
|
||||
targetId: set.id,
|
||||
summary: `Назначение набора ${set.name} обновлено`,
|
||||
details: {
|
||||
set_id: set.id,
|
||||
added: toAdd,
|
||||
removed: toRemove,
|
||||
},
|
||||
})
|
||||
return {
|
||||
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
|
||||
added: toAdd,
|
||||
removed: toRemove,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
const testConfig: AppConfig = {
|
||||
databaseUrl: 'sqlite::memory:',
|
||||
jwtSecret: 'test',
|
||||
jwtTtlHours: 24,
|
||||
serverPort: 8080,
|
||||
staticDir: null,
|
||||
logLevel: 'error',
|
||||
authRequired: false,
|
||||
authIssuer: 'https://auth.test',
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
corsOrigins: [],
|
||||
secretKey: null,
|
||||
}
|
||||
|
||||
async function createInvitedAgent(
|
||||
app: Awaited<ReturnType<typeof buildApp>>,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name, platform: 'linux' },
|
||||
})
|
||||
expect(created.statusCode).toBe(201)
|
||||
return (created.json() as { agent_id: string }).agent_id
|
||||
}
|
||||
|
||||
describe('bulk agent operations', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('approve-bulk approves invited agents in one request', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const a1 = await createInvitedAgent(app, 'bulk-01')
|
||||
const a2 = await createInvitedAgent(app, 'bulk-02')
|
||||
const a3 = await createInvitedAgent(app, 'bulk-03')
|
||||
|
||||
const bulk = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/agents/approve-bulk',
|
||||
payload: { agent_ids: [a1, a2, a3] },
|
||||
})
|
||||
expect(bulk.statusCode).toBe(200)
|
||||
const body = bulk.json() as { items: { id: string; status: string }[] }
|
||||
expect(body.items.map((i) => i.id).sort()).toEqual([a1, a2, a3].sort())
|
||||
expect(body.items.every((i) => i.status === 'approved')).toBe(true)
|
||||
|
||||
// shared default set assigned on approve
|
||||
for (const id of [a1, a2, a3]) {
|
||||
const sets = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${id}/policy-sets`,
|
||||
})
|
||||
const items = (sets.json() as { items: { set_id: string }[] }).items
|
||||
expect(items.some((s) => s.set_id === 'set-shared-default')).toBe(true)
|
||||
}
|
||||
|
||||
// repeated bulk is a no-op (already approved)
|
||||
const again = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/agents/approve-bulk',
|
||||
payload: { agent_ids: [a1] },
|
||||
})
|
||||
expect(again.statusCode).toBe(200)
|
||||
expect((again.json() as { items: unknown[] }).items).toEqual([])
|
||||
})
|
||||
|
||||
it('PUT /policy-sets/:id/agents replaces assignment of the set', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const a1 = await createInvitedAgent(app, 'assign-01')
|
||||
const a2 = await createInvitedAgent(app, 'assign-02')
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/agents/approve-bulk',
|
||||
payload: { agent_ids: [a1, a2] },
|
||||
})
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/policy-sets',
|
||||
payload: { name: 'bulk-assign-set' },
|
||||
})
|
||||
const setId = (created.json() as { id: string }).id
|
||||
|
||||
// assign both
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||
payload: { agent_ids: [a1, a2] },
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect((put.json() as { added: string[] }).added.sort()).toEqual(
|
||||
[a1, a2].sort(),
|
||||
)
|
||||
|
||||
// a1 keeps set when a2 removed; shared default preserved for both
|
||||
const drop = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||
payload: { agent_ids: [a1] },
|
||||
})
|
||||
expect(drop.statusCode).toBe(200)
|
||||
const dropBody = drop.json() as {
|
||||
agent_ids: string[]
|
||||
removed: string[]
|
||||
}
|
||||
expect(dropBody.agent_ids).toEqual([a1])
|
||||
expect(dropBody.removed).toEqual([a2])
|
||||
|
||||
const setsA1 = (
|
||||
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a1}/policy-sets` }))
|
||||
.json() as { items: { set_id: string }[] }
|
||||
).items.map((s) => s.set_id)
|
||||
const setsA2 = (
|
||||
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a2}/policy-sets` }))
|
||||
.json() as { items: { set_id: string }[] }
|
||||
).items.map((s) => s.set_id)
|
||||
expect(setsA1).toContain(setId)
|
||||
expect(setsA2).not.toContain(setId)
|
||||
expect(setsA2).toContain('set-shared-default')
|
||||
|
||||
// empty array clears the whole assignment
|
||||
const clear = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||
payload: { agent_ids: [] },
|
||||
})
|
||||
expect(clear.statusCode).toBe(200)
|
||||
expect((clear.json() as { agent_ids: string[] }).agent_ids).toEqual([])
|
||||
|
||||
// unknown agent → 404, and nothing changed
|
||||
const bad = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/policy-sets/${setId}/agents`,
|
||||
payload: { agent_ids: ['no-such-agent'] },
|
||||
})
|
||||
expect(bad.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -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,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,
|
||||
})
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
|
||||
+35
-1
@@ -6,10 +6,44 @@ import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ routesDirectory: './src/routes', target: 'react' }),
|
||||
TanStackRouterVite({
|
||||
routesDirectory: './src/routes',
|
||||
target: 'react',
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (
|
||||
/node_modules[\\/](recharts|victory-vendor|d3-[a-z-]+|react-smooth|recharts-scale)[\\/]/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'charts'
|
||||
}
|
||||
if (/node_modules[\\/]@dnd-kit[\\/]/.test(id)) return 'dnd'
|
||||
if (
|
||||
/node_modules[\\/](react|react-dom|scheduler|clsx|tailwind-merge)[\\/]/.test(
|
||||
id,
|
||||
)
|
||||
) {
|
||||
return 'react'
|
||||
}
|
||||
if (/node_modules[\\/]@tanstack[\\/]react-router[\\/]/.test(id)) {
|
||||
return 'router'
|
||||
}
|
||||
if (/node_modules[\\/]@tanstack[\\/]react-query[\\/]/.test(id)) {
|
||||
return 'query'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
@@ -181,6 +181,11 @@ export const putAgentPolicySetsBodySchema = z.object({
|
||||
set_ids: z.array(z.string()),
|
||||
})
|
||||
|
||||
/** Shared body for bulk agent operations (approve-bulk, policy-set assignment). Empty array = clear assignment. */
|
||||
export const agentIdsBodySchema = z.object({
|
||||
agent_ids: z.array(z.string().min(1)).max(1000),
|
||||
})
|
||||
|
||||
export const createOverrideBodySchema = z.object({
|
||||
cidr: z.string().min(1),
|
||||
action: policyActionSchema,
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-day-picker": "^9.4.0",
|
||||
"recharts": "^2.15.0",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^3.0.0",
|
||||
"tw-animate-css": "^1.0.0"
|
||||
|
||||
Generated
+2
-98
@@ -296,8 +296,8 @@ importers:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.7([email protected])
|
||||
recharts:
|
||||
specifier: ^2.15.0
|
||||
version: 2.15.4([email protected]([email protected]))(react@19.2.7)
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0(@types/[email protected])([email protected]([email protected]))(react[email protected])([email protected])([email protected])
|
||||
sonner:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.4([email protected]([email protected]))([email protected])
|
||||
@@ -2974,9 +2974,6 @@ packages:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==}
|
||||
|
||||
@@ -3195,9 +3192,6 @@ packages:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
@@ -3223,10 +3217,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -4628,12 +4618,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -4649,12 +4633,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
||||
peerDependencies:
|
||||
react: '>=16.6.0'
|
||||
react-dom: '>=16.6.0'
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4701,17 +4679,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5383,9 +5350,6 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
@@ -8069,11 +8033,6 @@ snapshots:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
csstype: 3.2.3
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
@@ -8288,8 +8247,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -8327,8 +8284,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -9566,14 +9521,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
fast-equals: 5.4.1
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
react-transition-group: 4.4.5([email protected]([email protected]))([email protected])
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -9588,15 +9535,6 @@ snapshots:
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.7
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
dom-helpers: 5.2.1
|
||||
loose-envify: 1.4.0
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -9655,23 +9593,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
decimal.js-light: 2.5.1
|
||||
|
||||
[email protected]([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
eventemitter3: 4.0.7
|
||||
lodash: 4.18.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
react-is: 18.3.1
|
||||
react-smooth: 4.0.4([email protected]([email protected]))([email protected])
|
||||
recharts-scale: 0.4.5
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
[email protected](@types/[email protected])([email protected]([email protected]))([email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.12.0([email protected](@types/[email protected])([email protected])([email protected]))([email protected])
|
||||
@@ -10351,23 +10272,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
|
||||
Reference in New Issue
Block a user