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
+27
View File
@@ -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)
+56
View File
@@ -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,
}
},
)
}
+153
View File
@@ -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)
})
})