From f160992d9410fe19a2bdb25c3c9051ba1034efd6 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 30 Jul 2026 14:13:05 +0700 Subject: [PATCH] feat(api, web): enhance agent management and linting capabilities - Added a new linting command for OpenAPI specifications in the package.json, improving code quality checks. - Updated frontend documentation to clarify component usage and structure, including detailed descriptions for `SettingsShell` and `Auth callback`. - Refactored agent-related API routes to streamline control-plane functionalities, consolidating multiple routes for better organization. - Improved error handling in the API to provide more informative responses for validation errors, enhancing user feedback during interactions. These changes enhance the overall development experience and improve the management of agents within the application. --- .cursor/rules/frontend-shadcn.mdc | 7 +- .cursor/rules/frontend-ui-patterns.mdc | 25 +- .cursor/rules/reui-mcp.mdc | 4 +- apps/api/src/app.ts | 4 +- apps/api/src/plugins/error-handler.ts | 8 + apps/api/src/routes/agents.ts | 329 +++++ apps/api/src/routes/control.ts | 1086 +---------------- apps/api/src/routes/dashboard.ts | 48 + apps/api/src/routes/install-links.ts | 88 ++ apps/api/src/routes/integrations-evobgp.ts | 48 + apps/api/src/routes/lists.ts | 185 +++ apps/api/src/routes/policy-sets.ts | 103 ++ apps/api/src/routes/rules.ts | 199 +++ apps/api/src/routes/settings.ts | 34 + apps/api/src/routes/stats.ts | 61 + apps/api/src/services/agents-policy.test.ts | 106 ++ apps/api/src/services/lists/entries.ts | 7 +- apps/api/src/services/lists/refresh.ts | 11 +- apps/api/src/services/policy/evaluate.ts | 17 +- .../src/services/policy/resolve-hostname.ts | 7 +- apps/api/src/services/row-mappers.ts | 87 ++ apps/api/src/services/settings-bump.test.ts | 82 ++ apps/api/src/services/uniq.ts | 17 + .../src/components/agents/add-agent-sheet.tsx | 278 +++-- apps/web/src/components/agents/agent-card.tsx | 1 - .../components/agents/agents-fleet-chrome.tsx | 119 ++ .../blocks/card-3/components/data.tsx | 64 - .../card-3/components/investor-card.tsx | 150 --- .../web/src/components/blocks/card-3/page.tsx | 9 - .../solution-agents-2/components/data.tsx | 469 ------- .../components/run-queue-columns.tsx | 614 ---------- .../components/run-queue.tsx | 565 --------- .../blocks/solution-agents-2/page.tsx | 15 - .../solution-agents-3/components/data.tsx | 260 ---- .../components/editable-detail-row.tsx | 222 ---- .../components/run-detail.tsx | 23 - .../components/run-facts.tsx | 703 ----------- .../components/run-header.tsx | 167 --- .../components/run-trace.tsx | 212 ---- .../blocks/solution-agents-3/page.tsx | 15 - .../blocks/solution-crm-4/components/data.tsx | 263 ---- .../components/deal-detail-sheet.tsx | 617 ---------- .../components/blocks/solution-crm-4/page.tsx | 15 - .../solution-inventory-9/components/data.ts | 75 -- .../components/track-shipping-sheet.tsx | 428 ------- .../blocks/solution-inventory-9/page.tsx | 9 - .../components/reui-kit/settings-shell.tsx | 29 +- apps/web/src/lib/ui-surface.ts | 2 + apps/web/src/routes/_auth/agents/index.tsx | 154 +-- apps/web/src/routes/_auth/index.tsx | 43 +- apps/web/src/routes/_auth/lists/index.tsx | 8 +- apps/web/src/routes/_auth/settings.tsx | 36 +- apps/web/src/routes/auth.callback.tsx | 37 +- docs/README.md | 7 +- docs/openapi.yaml | 426 ++++++- package.json | 3 +- packages/db/src/repositories/agents.ts | 33 + packages/db/src/repositories/index.ts | 721 +++-------- packages/db/src/repositories/install-links.ts | 86 ++ packages/db/src/repositories/lists.ts | 76 ++ packages/db/src/repositories/policy.ts | 395 ++++++ packages/db/src/repositories/settings.ts | 25 + packages/db/src/repositories/stats.ts | 35 + .../shared/src/contracts.settings.test.ts | 18 + packages/shared/src/contracts.ts | 24 + packages/shared/src/permissions.test.ts | 22 + packages/shared/src/permissions.ts | 5 +- redocly.yaml | 13 + 68 files changed, 3155 insertions(+), 6899 deletions(-) create mode 100644 apps/api/src/routes/agents.ts create mode 100644 apps/api/src/routes/dashboard.ts create mode 100644 apps/api/src/routes/install-links.ts create mode 100644 apps/api/src/routes/integrations-evobgp.ts create mode 100644 apps/api/src/routes/lists.ts create mode 100644 apps/api/src/routes/policy-sets.ts create mode 100644 apps/api/src/routes/rules.ts create mode 100644 apps/api/src/routes/settings.ts create mode 100644 apps/api/src/routes/stats.ts create mode 100644 apps/api/src/services/agents-policy.test.ts create mode 100644 apps/api/src/services/row-mappers.ts create mode 100644 apps/api/src/services/settings-bump.test.ts create mode 100644 apps/api/src/services/uniq.ts create mode 100644 apps/web/src/components/agents/agents-fleet-chrome.tsx delete mode 100644 apps/web/src/components/blocks/card-3/components/data.tsx delete mode 100644 apps/web/src/components/blocks/card-3/components/investor-card.tsx delete mode 100644 apps/web/src/components/blocks/card-3/page.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-2/components/data.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-2/components/run-queue-columns.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-2/components/run-queue.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-2/page.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/data.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/editable-detail-row.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/run-detail.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/run-facts.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/run-header.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/components/run-trace.tsx delete mode 100644 apps/web/src/components/blocks/solution-agents-3/page.tsx delete mode 100644 apps/web/src/components/blocks/solution-crm-4/components/data.tsx delete mode 100644 apps/web/src/components/blocks/solution-crm-4/components/deal-detail-sheet.tsx delete mode 100644 apps/web/src/components/blocks/solution-crm-4/page.tsx delete mode 100644 apps/web/src/components/blocks/solution-inventory-9/components/data.ts delete mode 100644 apps/web/src/components/blocks/solution-inventory-9/components/track-shipping-sheet.tsx delete mode 100644 apps/web/src/components/blocks/solution-inventory-9/page.tsx create mode 100644 apps/web/src/lib/ui-surface.ts create mode 100644 packages/db/src/repositories/agents.ts create mode 100644 packages/db/src/repositories/install-links.ts create mode 100644 packages/db/src/repositories/lists.ts create mode 100644 packages/db/src/repositories/policy.ts create mode 100644 packages/db/src/repositories/settings.ts create mode 100644 packages/db/src/repositories/stats.ts create mode 100644 packages/shared/src/contracts.settings.test.ts create mode 100644 packages/shared/src/permissions.test.ts create mode 100644 redocly.yaml diff --git a/.cursor/rules/frontend-shadcn.mdc b/.cursor/rules/frontend-shadcn.mdc index 7890977..6a6becf 100644 --- a/.cursor/rules/frontend-shadcn.mdc +++ b/.cursor/rules/frontend-shadcn.mdc @@ -36,9 +36,8 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m | `PageShell` | `page-shell.tsx` | | `ResourcePage` | `reui-kit/resource-page.tsx` | | `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` | -| `KanbanBoard` | `reui-kit/kanban-board.tsx` | | `DetailPanel` | `reui-kit/detail-panel.tsx` | -| `SettingsShell` | `reui-kit/settings-shell.tsx` | +| `SettingsShell` | `reui-kit/settings-shell.tsx` (multi-tab; single-page settings — PageShell + Frame) | | `EmptyState` | `empty-state.tsx` | | `QueryState` | `query-state.tsx` | | `ConfirmDialog` | `confirm-dialog.tsx` | @@ -85,10 +84,10 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y | Зона | Файл | Preview | |------|------|---------| | Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) | -| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) | +| Auth callback | `routes/auth.callback.tsx` (SSO portal) | [auth-13](https://reui.io/preview/base/auth-13) · [empty-state-12](https://reui.io/preview/base/empty-state-12) | | Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 | | Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) | -| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) | +| Settings | `routes/_auth/settings.tsx` | [settings-16](https://reui.io/preview/base/settings-16) | ## Чеклист diff --git a/.cursor/rules/frontend-ui-patterns.mdc b/.cursor/rules/frontend-ui-patterns.mdc index 423357e..95ce00e 100644 --- a/.cursor/rules/frontend-ui-patterns.mdc +++ b/.cursor/rules/frontend-ui-patterns.mdc @@ -48,11 +48,9 @@ apps/web/src/components/ ← shared + domain + layout page-shell.tsx reui-kit/ resource-page.tsx ← list: Frame + line Tabs + Filters + DataGrid - kanban-board.tsx ← kanban + KanbanBoardSkeleton detail-panel.tsx ← detail: Frame header/metrics - settings-shell.tsx + settings-shell.tsx ← multi-tab settings (tabs required; no phantom routes) ops-dashboard.tsx ← KPI stats-12 + charts - catalog-board-toggle.tsx empty-state.tsx query-state.tsx confirm-dialog.tsx @@ -70,12 +68,10 @@ apps/web/src/components/ ← shared + domain + layout |---------|--------|-----------| | Page wrapper | `PageShell` | — | | List page | `ResourcePage` | ReUI `Frame` + `data-grid` + `filters` + shadcn `Tabs` `variant="line"` | -| Catalog / Board | `CatalogBoardToggle` + `ResourcePage` / `KanbanBoard` | `?view=board` на `/groups`, `/services` | -| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` | | Detail | `DetailPanel` | ReUI `Frame` | -| Settings | `SettingsShell` | — | +| Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — | | Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid | -| Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» | +| Quick Actions | `QuickActionGrid` (gated `show_quick_actions`) | Frame tiles + Badge «Перейти» | | Empty | `EmptyState` | `Empty` | | Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` | | Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) | @@ -95,8 +91,6 @@ apps/web/src/components/ ← shared + domain + layout Max 1 primary (`default`) на экран; остальные `outline` / `ghost`. -Toggle «Доска» / «К каталогу» — всегда `outline` в `primaryAction` / `KanbanBoard.toolbarActions` (не отдельный Frame-shell). - ## Line tabs (project standard) Эталон: [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) + counted [filtering-2](https://reui.io/preview/base/data-grid-filtering-2). @@ -116,16 +110,9 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p - Active state — Base UI `data-active`, не Radix `data-[state=active]` - Не трогать internals `reui/date-selector` -## Catalog / Board (`/groups`, `/services`) +## Agents list view -| Режим | UI | Search | -|-------|-----|--------| -| Catalog (default) | `ResourcePage` + primary create | omit / `view=catalog` | -| Board | `KanbanBoard` DnD | `?view=board` | - -- Groups tabs: Все / С доменами / Пустые -- Services tabs: Все / Включены / Выключены / Без группы -- DnD только на board; kanban hooks/cards не удалять +`?view=cards|table` на `/agents` — `AgentsFleetChrome` (cards) / `ResourcePage` (table). Board/kanban в EvoFirewall нет. ## Dashboard KPI @@ -178,7 +165,7 @@ Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md` Каждый блок: **default, hover, focus, disabled, empty, loading, error**. -- **Loading** — `Skeleton` / `ResourcePage` skeleton / `KanbanBoardSkeleton` / `OpsDashboard` skeleton, не Spinner на странице +- **Loading** — `Skeleton` / `ResourcePage` skeleton / `OpsDashboard` skeleton, не Spinner на странице - **Empty** — `EmptyState` с CTA - **Zero-results** — message внутри DataGrid (+ «Сбросить») - **Error** — `QueryState` / `Alert` + `onRetry` diff --git a/.cursor/rules/reui-mcp.mdc b/.cursor/rules/reui-mcp.mdc index 677faf9..67472c4 100644 --- a/.cursor/rules/reui-mcp.mdc +++ b/.cursor/rules/reui-mcp.mdc @@ -61,14 +61,14 @@ alwaysApply: true |------|------|--------| | shadcn | `packages/ui/src/components/` | `@evofw/ui/components/*` | | ReUI CLI | `apps/web/src/components/reui/` | `@/components/reui/*` | -| PRO blocks (reference) | `apps/web/src/components/blocks/` | adapt into kit, не копипаст в routes | +| PRO blocks (reference) | — | install via CLI → adapt into kit; do not keep demo trees in `src/components/blocks/` | | Kit | `apps/web/src/components/reui-kit/` | `@/components/reui-kit/*` | ## Установленные ReUI (apps/web) **Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack` -**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell` +**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` **Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, … diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4926974..d88c633 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -101,9 +101,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { return reply.sendFile('index.html') } return reply.code(404).send({ - type: 'about:blank', - title: 'Not Found', - status: 404, + error: { code: 'NOT_FOUND', message: 'Not Found' }, }) }) diff --git a/apps/api/src/plugins/error-handler.ts b/apps/api/src/plugins/error-handler.ts index 8341ed0..f9d9c7c 100644 --- a/apps/api/src/plugins/error-handler.ts +++ b/apps/api/src/plugins/error-handler.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import fp from 'fastify-plugin' +import { ZodError } from 'zod' export class AppError extends Error { constructor( @@ -19,6 +20,13 @@ async function errorHandlerPlugin(app: FastifyInstance) { error: { code: err.code, message: err.message }, }) } + if (err instanceof ZodError) { + const message = + err.issues.map((i) => i.message).join('; ') || 'Validation error' + return reply.code(400).send({ + error: { code: 'VALIDATION_ERROR', message }, + }) + } const e = err as { statusCode?: number; message?: string } const status = e.statusCode ?? 500 const message = diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts new file mode 100644 index 0000000..541b4b9 --- /dev/null +++ b/apps/api/src/routes/agents.ts @@ -0,0 +1,329 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { + createOverrideBodySchema, + putAgentPolicySetsBodySchema, + patchAgentBodySchema, + cloneFromBodySchema, +} from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js' +import { buildInstallUrls } from '../services/install-links.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' +import { mapAgent } from '../services/row-mappers.js' + +export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/agents', async () => { + const all = repos.listAgents(app.db) + const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db) + return { + items: all.map((a) => { + const link = linksByAgent.get(a.id) + if (!link) { + return mapAgent(a) + } + const urls = buildInstallUrls( + config.publicBaseUrl, + link.id, + link.slug, + link.platform === 'mikrotik' ? 'mikrotik' : 'linux', + ) + return mapAgent(a, { + installCurl: urls.curl.by_slug, + installLinkId: link.id, + }) + }), + } + }) + + app.get<{ Params: { id: string } }>('/agents/:id', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + return mapAgent(a) + }) + + app.get<{ + Params: { id: string } + Querystring: { limit_cidrs?: string } + }>('/agents/:id/preview', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const policy = evaluateAgentPolicy(app.db, a.id) + const limitRaw = Number(req.query.limit_cidrs ?? '50') + const limit = Number.isFinite(limitRaw) + ? Math.min(Math.max(0, Math.floor(limitRaw)), 5000) + : 50 + return { + default_action: policy.defaultAction, + hash: policy.hash, + generation: policy.generation, + sync_interval_sec: policy.syncIntervalSec, + apply_version: policy.applyVersion, + summary: { + sets: policy.summary.sets, + rules_deny: policy.summary.rulesDeny, + rules_allow: policy.summary.rulesAllow, + cidrs_deny: policy.summary.cidrsDeny, + cidrs_allow: policy.summary.cidrsAllow, + overrides: policy.summary.overrides, + conflicts_dropped: policy.summary.conflictsDropped, + }, + chain: policy.chain.map((s) => ({ + set_id: s.setId, + set_name: s.setName, + rule_id: s.ruleId, + action: s.action, + source_kind: s.sourceKind, + source_label: s.sourceLabel, + cidr_count: s.cidrCount, + })), + deny_cidrs: truncateCidrs(policy.denyCidrs, limit), + allow_cidrs: truncateCidrs(policy.allowCidrs, limit), + deny_cidrs_total: policy.denyCidrs.length, + allow_cidrs_total: policy.allowCidrs.length, + } + }) + + app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => { + const body = patchAgentBodySchema.parse(req.body) + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const nextDefault = body.default_action + const updated = repos.updateAgent(app.db, a.id, { + name: body.name, + defaultAction: nextDefault, + settingsJson: body.settings + ? JSON.stringify(body.settings) + : undefined, + policyGeneration: + nextDefault && nextDefault !== a.defaultAction + ? a.policyGeneration + 1 + : a.policyGeneration, + }) + auditMutation(app, config, req, { + action: 'agent.update', + targetType: 'app_resource', + targetId: a.id, + summary: `Обновлён агент: ${updated!.name}`, + details: { + agent_id: a.id, + default_action: nextDefault, + name: body.name, + }, + }) + return mapAgent(updated!) + }) + + app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const updated = repos.updateAgent(app.db, a.id, { + status: 'approved', + approvedAt: new Date().toISOString(), + }) + repos.ensureSharedSetAssigned(app.db, a.id) + auditMutation(app, config, req, { + action: 'agent.approve', + targetType: 'app_resource', + targetId: a.id, + summary: `Агент одобрен: ${updated!.name}`, + details: { agent_id: a.id }, + }) + return mapAgent(updated!) + }) + + 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) + const updated = repos.updateAgent(app.db, a.id, { + status: 'revoked', + revokedAt: new Date().toISOString(), + }) + auditMutation(app, config, req, { + action: 'agent.revoke', + severity: 'warning', + targetType: 'app_resource', + targetId: a.id, + summary: `Агент отозван: ${updated!.name}`, + details: { agent_id: a.id }, + }) + return mapAgent(updated!) + }) + + app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + repos.deleteAgent(app.db, req.params.id) + if (a) { + auditMutation(app, config, req, { + action: 'agent.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: a.id, + summary: `Агент удалён: ${a.name}`, + details: { agent_id: a.id }, + }) + } + return { ok: true } + }) + + app.post<{ Params: { id: string; sourceId: string } }>( + '/agents/:id/clone-from/:sourceId', + async (req) => { + const body = cloneFromBodySchema.parse(req.body ?? {}) + const updated = repos.cloneRulesFrom( + app.db, + req.params.sourceId, + req.params.id, + body.include_overrides ?? false, + ) + if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404) + auditMutation(app, config, req, { + action: 'agent.clone_rules', + targetType: 'app_resource', + targetId: updated.id, + summary: `Правила скопированы с ${req.params.sourceId} на ${updated.name}`, + details: { + agent_id: updated.id, + source_agent_id: req.params.sourceId, + include_overrides: body.include_overrides ?? false, + }, + }) + return mapAgent(updated) + }, + ) + + app.get<{ Params: { id: string } }>( + '/agents/:id/overrides', + async (req) => ({ + items: repos.listOverrides(app.db, req.params.id).map((o) => ({ + id: o.id, + agent_id: o.agentId, + cidr: o.cidr, + action: o.action, + comment: o.comment, + created_at: o.createdAt, + })), + }), + ) + + app.post<{ Params: { id: string } }>( + '/agents/:id/overrides', + async (req) => { + const body = createOverrideBodySchema.parse(req.body) + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const row = repos.insertOverride(app.db, { + id: crypto.randomUUID(), + agentId: a.id, + cidr: body.cidr, + action: body.action, + comment: body.comment ?? null, + createdByUserId: req.authUser?.id, + createdAt: new Date().toISOString(), + }) + repos.bumpAgentGeneration(app.db, a.id) + auditMutation(app, config, req, { + action: 'override.create', + targetType: 'app_resource', + targetId: row!.id, + summary: `Override ${body.action} ${body.cidr} для ${a.name}`, + details: { + override_id: row!.id, + agent_id: a.id, + cidr: body.cidr, + action: body.action, + }, + }) + return { + id: row!.id, + agent_id: row!.agentId, + cidr: row!.cidr, + action: row!.action, + comment: row!.comment, + created_at: row!.createdAt, + } + }, + ) + + app.delete<{ Params: { id: string; overrideId: string } }>( + '/agents/:id/overrides/:overrideId', + async (req) => { + repos.deleteOverride(app.db, req.params.overrideId) + repos.bumpAgentGeneration(app.db, req.params.id) + auditMutation(app, config, req, { + action: 'override.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: req.params.overrideId, + summary: `Override удалён у агента ${req.params.id}`, + details: { + override_id: req.params.overrideId, + agent_id: req.params.id, + }, + }) + return { ok: true } + }, + ) + + app.put<{ Params: { id: string } }>( + '/agents/:id/policy-sets', + async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const body = putAgentPolicySetsBodySchema.parse(req.body) + for (const setId of body.set_ids) { + if (!repos.getPolicySet(app.db, setId)) { + throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404) + } + } + try { + repos.setAgentPolicySets(app.db, a.id, body.set_ids) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + auditMutation(app, config, req, { + action: 'agent.policy_sets.update', + targetType: 'app_resource', + targetId: a.id, + summary: `Наборы политик агента ${a.name} обновлены`, + details: { agent_id: a.id, set_ids: body.set_ids }, + }) + return { + items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ + set_id: s.setId, + sort: s.sort, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + })), + } + }, + ) + + app.get<{ Params: { id: string } }>( + '/agents/:id/policy-sets', + async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + return { + items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ + set_id: s.setId, + sort: s.sort, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + })), + } + }, + ) +} diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 9b86ed3..0ecbe79 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -1,1076 +1,28 @@ import type { FastifyPluginAsync } from 'fastify' -import { repos } from '@evofw/db' -import { - createOverrideBodySchema, - createIpListBodySchema, - createPolicyRuleBodySchema, - createPolicySetBodySchema, - createInstallLinkBodySchema, - patchPolicySetBodySchema, - patchPolicyRuleBodySchema, - reorderPolicyRulesBodySchema, - putAgentPolicySetsBodySchema, - patchAgentBodySchema, - cloneFromBodySchema, - listEntriesBodySchema, - deleteListEntryBodySchema, - isManualListType, -} from '@evofw/shared' -import { AppError } from '../plugins/error-handler.js' -import { refreshIpList } from '../services/lists/refresh.js' -import { - addListEntries, - deleteListEntry, - mapListDetail, -} from '../services/lists/entries.js' -import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js' -import { - resolveAndStoreHostnameRule, - resolveHostnameToCidrs, -} from '../services/policy/resolve-hostname.js' -import { - mapInstallLink, - randomToken, - buildInstallUrls, -} from '../services/install-links.js' -import { hashToken } from '../plugins/auth.js' import type { AppConfig } from '../config.js' -import { auditMutation } from '../services/audit.js' - -function mapAgent( - a: NonNullable>, - opts?: { installCurl?: string | null; installLinkId?: string | null }, -) { - const defaultAction = - a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const) - return { - id: a.id, - name: a.name, - hostname: a.hostname, - platform: a.platform, - token_prefix: a.tokenPrefix, - status: a.status, - default_action: defaultAction, - policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const), - policy_generation: a.policyGeneration, - last_seen_at: a.lastSeenAt, - last_seen_ip: a.lastSeenIp, - last_apply_at: a.lastApplyAt, - last_apply_status: a.lastApplyStatus, - last_apply_error: a.lastApplyError, - last_apply_prefix_count: a.lastApplyPrefixCount, - last_apply_packets_dropped: a.lastApplyPacketsDropped, - last_apply_packets_accepted: a.lastApplyPacketsAccepted, - total_packets_dropped: a.totalPacketsDropped ?? 0, - total_packets_accepted: a.totalPacketsAccepted ?? 0, - last_apply_kernel_method: a.lastApplyKernelMethod, - client_version: a.clientVersion, - created_at: a.createdAt, - approved_at: a.approvedAt, - revoked_at: a.revokedAt, - install_curl: opts?.installCurl ?? null, - install_link_id: opts?.installLinkId ?? null, - } -} - -function mapPolicySet( - s: NonNullable>, - db: Parameters[0], -) { - return { - id: s.id, - name: s.name, - description: s.description, - enabled: s.enabled === 1, - rules_count: repos.countRulesInSet(db, s.id), - agents_count: repos.countAgentsForSet(db, s.id), - created_at: s.createdAt, - updated_at: s.updatedAt, - } -} - -function mapPolicyRule( - r: NonNullable>, - db: Parameters[0], -) { - return { - id: r.id, - set_id: r.setId, - priority: r.priority, - action: r.action, - enabled: r.enabled !== 0, - list_id: r.listId, - cidr: r.cidr, - hostname: r.hostname, - resolved_count: r.hostname - ? repos.listResolvedForRule(db, r.id).length - : undefined, - comment: r.comment, - created_at: r.createdAt, - updated_at: r.updatedAt, - } -} +import { dashboardRoutes } from './dashboard.js' +import { installLinksRoutes } from './install-links.js' +import { agentsRoutes } from './agents.js' +import { listsRoutes } from './lists.js' +import { policySetsRoutes } from './policy-sets.js' +import { rulesRoutes } from './rules.js' +import { statsRoutes } from './stats.js' +import { integrationsEvobgpRoutes } from './integrations-evobgp.js' +import { settingsRoutes } from './settings.js' +/** Aggregates domain control-plane route plugins under /api/v1. */ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app, opts, ) => { const { config } = opts - - app.get('/dashboard', async () => { - const all = repos.listAgents(app.db) - const now = Date.now() - const online = all.filter((a) => { - if (!a.lastSeenAt || a.status !== 'approved') return false - return now - Date.parse(a.lastSeenAt) < 5 * 60_000 - }) - return { - agents_total: all.length, - agents_approved: all.filter((a) => a.status === 'approved').length, - agents_online: online.length, - agents_pending: all.filter((a) => a.status === 'pending').length, - packets_dropped: all.reduce( - (s, a) => s + (a.totalPacketsDropped ?? a.lastApplyPacketsDropped ?? 0), - 0, - ), - packets_accepted: all.reduce( - (s, a) => s + (a.totalPacketsAccepted ?? a.lastApplyPacketsAccepted ?? 0), - 0, - ), - lists_total: repos.listIpLists(app.db).length, - } - }) - - app.get('/install-context', async () => { - const seed = - repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed - return { - suggested_cp_url: config.publicBaseUrl, - enroll_seed: seed, - install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`, - mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`, - sync_interval_sec: Number( - repos.getSetting(app.db, 'agent_sync_interval_sec') || '60', - ), - } - }) - - // Install short-links - app.get('/install-links', async () => ({ - items: repos - .listInstallLinks(app.db) - .map((row) => mapInstallLink(row, config.publicBaseUrl)), - })) - - app.post('/install-links', async (req, reply) => { - const body = createInstallLinkBodySchema.parse(req.body) - const linkId = randomToken(10) - const slug = randomToken(16) - if ( - repos.getInstallLink(app.db, linkId) || - repos.getInstallLinkBySlug(app.db, slug) - ) { - throw new AppError('CONFLICT', 'Retry create (id collision)', 409) - } - - const agentId = crypto.randomUUID() - const inviteToken = `invite:${agentId}` - const now = new Date().toISOString() - const name = body.name.trim() - const platform = body.platform ?? 'linux' - - app.sqlite.transaction(() => { - repos.insertAgent(app.db, { - id: agentId, - name, - hostname: null, - platform, - tokenPrefix: inviteToken.slice(0, 12), - tokenHash: hashToken(inviteToken), - status: 'invited', - defaultAction: 'accept', - policyGeneration: 1, - clientVersion: null, - settingsJson: '{}', - createdAt: now, - }) - repos.insertInstallLink(app.db, { - id: linkId, - slug, - clientName: name, - platform, - agentId, - createdAt: now, - useCount: 0, - }) - })() - - const row = repos.getInstallLink(app.db, linkId)! - auditMutation(app, config, req, { - action: 'agent.create', - targetType: 'app_resource', - targetId: agentId, - summary: `Создан агент (invite): ${name}`, - details: { agent_id: agentId, platform, install_link_id: linkId }, - }) - return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl)) - }) - - app.delete<{ Params: { id: string } }>( - '/install-links/:id', - async (req) => { - const row = repos.getInstallLink(app.db, req.params.id) - if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404) - const updated = repos.revokeInstallLink(app.db, row.id) - return mapInstallLink(updated!, config.publicBaseUrl) - }, - ) - - // Agents - app.get('/agents', async () => { - const all = repos.listAgents(app.db) - return { - items: all.map((a) => { - const link = repos.getInstallLinkByAgentId(app.db, a.id) - if (!link || link.revokedAt) { - return mapAgent(a) - } - const urls = buildInstallUrls( - config.publicBaseUrl, - link.id, - link.slug, - link.platform === 'mikrotik' ? 'mikrotik' : 'linux', - ) - return mapAgent(a, { - installCurl: urls.curl.by_slug, - installLinkId: link.id, - }) - }), - } - }) - - app.get<{ Params: { id: string } }>('/agents/:id', async (req) => { - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - return mapAgent(a) - }) - - app.get<{ - Params: { id: string } - Querystring: { limit_cidrs?: string } - }>('/agents/:id/preview', async (req) => { - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const policy = evaluateAgentPolicy(app.db, a.id) - const limitRaw = Number(req.query.limit_cidrs ?? '50') - const limit = Number.isFinite(limitRaw) - ? Math.min(Math.max(0, Math.floor(limitRaw)), 5000) - : 50 - return { - default_action: policy.defaultAction, - hash: policy.hash, - generation: policy.generation, - sync_interval_sec: policy.syncIntervalSec, - apply_version: policy.applyVersion, - summary: { - sets: policy.summary.sets, - rules_deny: policy.summary.rulesDeny, - rules_allow: policy.summary.rulesAllow, - cidrs_deny: policy.summary.cidrsDeny, - cidrs_allow: policy.summary.cidrsAllow, - overrides: policy.summary.overrides, - conflicts_dropped: policy.summary.conflictsDropped, - }, - chain: policy.chain.map((s) => ({ - set_id: s.setId, - set_name: s.setName, - rule_id: s.ruleId, - action: s.action, - source_kind: s.sourceKind, - source_label: s.sourceLabel, - cidr_count: s.cidrCount, - })), - deny_cidrs: truncateCidrs(policy.denyCidrs, limit), - allow_cidrs: truncateCidrs(policy.allowCidrs, limit), - deny_cidrs_total: policy.denyCidrs.length, - allow_cidrs_total: policy.allowCidrs.length, - } - }) - - app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => { - const body = patchAgentBodySchema.parse(req.body) - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const nextDefault = body.default_action - const updated = repos.updateAgent(app.db, a.id, { - name: body.name, - defaultAction: nextDefault, - settingsJson: body.settings - ? JSON.stringify(body.settings) - : undefined, - policyGeneration: - nextDefault && nextDefault !== a.defaultAction - ? a.policyGeneration + 1 - : a.policyGeneration, - }) - auditMutation(app, config, req, { - action: 'agent.update', - targetType: 'app_resource', - targetId: a.id, - summary: `Обновлён агент: ${updated!.name}`, - details: { - agent_id: a.id, - default_action: nextDefault, - name: body.name, - }, - }) - return mapAgent(updated!) - }) - - app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => { - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const updated = repos.updateAgent(app.db, a.id, { - status: 'approved', - approvedAt: new Date().toISOString(), - }) - repos.ensureSharedSetAssigned(app.db, a.id) - auditMutation(app, config, req, { - action: 'agent.approve', - targetType: 'app_resource', - targetId: a.id, - summary: `Агент одобрен: ${updated!.name}`, - details: { agent_id: a.id }, - }) - return mapAgent(updated!) - }) - - 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) - const updated = repos.updateAgent(app.db, a.id, { - status: 'revoked', - revokedAt: new Date().toISOString(), - }) - auditMutation(app, config, req, { - action: 'agent.revoke', - severity: 'warning', - targetType: 'app_resource', - targetId: a.id, - summary: `Агент отозван: ${updated!.name}`, - details: { agent_id: a.id }, - }) - return mapAgent(updated!) - }) - - app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => { - const a = repos.getAgent(app.db, req.params.id) - repos.deleteAgent(app.db, req.params.id) - if (a) { - auditMutation(app, config, req, { - action: 'agent.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: a.id, - summary: `Агент удалён: ${a.name}`, - details: { agent_id: a.id }, - }) - } - return { ok: true } - }) - - app.post<{ Params: { id: string; sourceId: string } }>( - '/agents/:id/clone-from/:sourceId', - async (req) => { - const body = cloneFromBodySchema.parse(req.body ?? {}) - const updated = repos.cloneRulesFrom( - app.db, - req.params.sourceId, - req.params.id, - body.include_overrides ?? false, - ) - if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404) - auditMutation(app, config, req, { - action: 'agent.clone_rules', - targetType: 'app_resource', - targetId: updated.id, - summary: `Правила скопированы с ${req.params.sourceId} на ${updated.name}`, - details: { - agent_id: updated.id, - source_agent_id: req.params.sourceId, - include_overrides: body.include_overrides ?? false, - }, - }) - return mapAgent(updated) - }, - ) - - // Overrides - app.get<{ Params: { id: string } }>( - '/agents/:id/overrides', - async (req) => ({ - items: repos.listOverrides(app.db, req.params.id).map((o) => ({ - id: o.id, - agent_id: o.agentId, - cidr: o.cidr, - action: o.action, - comment: o.comment, - created_at: o.createdAt, - })), - }), - ) - - app.post<{ Params: { id: string } }>( - '/agents/:id/overrides', - async (req) => { - const body = createOverrideBodySchema.parse(req.body) - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const row = repos.insertOverride(app.db, { - id: crypto.randomUUID(), - agentId: a.id, - cidr: body.cidr, - action: body.action, - comment: body.comment ?? null, - createdByUserId: req.authUser?.id, - createdAt: new Date().toISOString(), - }) - repos.bumpAgentGeneration(app.db, a.id) - auditMutation(app, config, req, { - action: 'override.create', - targetType: 'app_resource', - targetId: row!.id, - summary: `Override ${body.action} ${body.cidr} для ${a.name}`, - details: { - override_id: row!.id, - agent_id: a.id, - cidr: body.cidr, - action: body.action, - }, - }) - return { - id: row!.id, - agent_id: row!.agentId, - cidr: row!.cidr, - action: row!.action, - comment: row!.comment, - created_at: row!.createdAt, - } - }, - ) - - app.delete<{ Params: { id: string; overrideId: string } }>( - '/agents/:id/overrides/:overrideId', - async (req) => { - repos.deleteOverride(app.db, req.params.overrideId) - repos.bumpAgentGeneration(app.db, req.params.id) - auditMutation(app, config, req, { - action: 'override.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: req.params.overrideId, - summary: `Override удалён у агента ${req.params.id}`, - details: { - override_id: req.params.overrideId, - agent_id: req.params.id, - }, - }) - return { ok: true } - }, - ) - - // Lists - app.get('/lists', async () => { - const items = repos.listIpLists(app.db).map((l) => ({ - id: l.id, - name: l.name, - type: l.type, - config_json: l.configJson, - content_hash: l.contentHash, - refreshed_at: l.refreshedAt, - last_error: l.lastError, - entry_count: repos.listIpListEntries(app.db, l.id).length, - created_at: l.createdAt, - updated_at: l.updatedAt, - })) - return { items } - }) - - app.post('/lists', async (req) => { - const body = createIpListBodySchema.parse(req.body) - const type = - body.type === 'domains' ? 'static' : body.type - const id = crypto.randomUUID() - const list = repos.insertIpList(app.db, { - id, - name: body.name, - type, - configJson: JSON.stringify(body.config ?? {}), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - if (body.entries?.length && isManualListType(type)) { - try { - await addListEntries(app.db, id, { values: body.entries }) - } catch (err) { - repos.deleteIpList(app.db, id) - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - } else if (!isManualListType(type)) { - await refreshIpList(app.db, id) - } - auditMutation(app, config, req, { - action: 'list.create', - targetType: 'app_resource', - targetId: list!.id, - summary: `Создан список: ${list!.name}`, - details: { list_id: list!.id, type: list!.type }, - }) - return { - id: list!.id, - name: list!.name, - type: list!.type, - config_json: list!.configJson, - created_at: list!.createdAt, - updated_at: list!.updatedAt, - } - }) - - app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { - const detail = mapListDetail(app.db, req.params.id) - if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) - return detail - }) - - app.post<{ Params: { id: string } }>( - '/lists/:id/entries', - async (req) => { - const l = repos.getIpList(app.db, req.params.id) - if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) - const body = listEntriesBodySchema.parse(req.body) - try { - const result = await addListEntries(app.db, l.id, { - values: body.values, - items: body.items, - }) - auditMutation(app, config, req, { - action: 'list.entries.add', - targetType: 'app_resource', - targetId: l.id, - summary: `Добавлены записи в список: ${l.name}`, - details: { - list_id: l.id, - entry_count: result.entries.length, - }, - }) - return mapListDetail(app.db, l.id) ?? result - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - }, - ) - - app.delete<{ Params: { id: string } }>( - '/lists/:id/entries', - async (req) => { - const l = repos.getIpList(app.db, req.params.id) - if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) - const body = deleteListEntryBodySchema.parse(req.body) - try { - await deleteListEntry(app.db, l.id, body.value) - auditMutation(app, config, req, { - action: 'list.entries.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: l.id, - summary: `Удалена запись из списка: ${l.name}`, - details: { list_id: l.id, value: body.value }, - }) - return mapListDetail(app.db, l.id) - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - }, - ) - - app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { - const l = repos.getIpList(app.db, req.params.id) - await refreshIpList(app.db, req.params.id) - const detail = mapListDetail(app.db, req.params.id) - if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) - auditMutation(app, config, req, { - action: 'list.refresh', - targetType: 'app_resource', - targetId: req.params.id, - summary: `Обновлён список: ${l?.name ?? req.params.id}`, - details: { list_id: req.params.id }, - }) - return detail - }) - - app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { - const l = repos.getIpList(app.db, req.params.id) - repos.deleteIpList(app.db, req.params.id) - if (l) { - auditMutation(app, config, req, { - action: 'list.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: l.id, - summary: `Список удалён: ${l.name}`, - details: { list_id: l.id }, - }) - } - return { ok: true } - }) - - // Policy sets - app.get('/policy-sets', async () => ({ - items: repos.listPolicySets(app.db).map((s) => mapPolicySet(s, app.db)), - })) - - app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { - const s = repos.getPolicySet(app.db, req.params.id) - if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) - return { - ...mapPolicySet(s, app.db), - agent_ids: repos.listAgentIdsForSet(app.db, s.id), - } - }) - - app.post('/policy-sets', async (req) => { - const body = createPolicySetBodySchema.parse(req.body) - const row = repos.insertPolicySet(app.db, { - id: crypto.randomUUID(), - name: body.name.trim(), - description: body.description ?? null, - enabled: body.enabled === false ? 0 : 1, - policyMode: 'blacklist', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - auditMutation(app, config, req, { - action: 'policy_set.create', - targetType: 'app_resource', - targetId: row!.id, - summary: `Создан набор политик: ${row!.name}`, - details: { set_id: row!.id }, - }) - return mapPolicySet(row!, app.db) - }) - - app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { - const body = patchPolicySetBodySchema.parse(req.body) - const s = repos.getPolicySet(app.db, req.params.id) - if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) - const updated = repos.updatePolicySet(app.db, s.id, { - name: body.name?.trim(), - description: body.description, - enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, - }) - if (body.enabled !== undefined) { - repos.bumpAgentsForSet(app.db, s.id) - } - auditMutation(app, config, req, { - action: 'policy_set.update', - targetType: 'app_resource', - targetId: s.id, - summary: `Обновлён набор политик: ${updated!.name}`, - details: { - set_id: s.id, - enabled: body.enabled, - name: body.name, - }, - }) - return mapPolicySet(updated!, app.db) - }) - - app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { - const s = repos.getPolicySet(app.db, req.params.id) - try { - const agentIds = repos.listAgentIdsForSet(app.db, req.params.id) - repos.deletePolicySet(app.db, req.params.id) - for (const id of agentIds) repos.bumpAgentGeneration(app.db, id) - if (s) { - auditMutation(app, config, req, { - action: 'policy_set.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: s.id, - summary: `Набор политик удалён: ${s.name}`, - details: { set_id: s.id, agents_affected: agentIds.length }, - }) - } - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - return { ok: true } - }) - - app.get<{ Params: { id: string } }>( - '/policy-sets/:id/rules', - async (req) => { - const s = repos.getPolicySet(app.db, req.params.id) - if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) - return { - items: repos - .listPolicyRules(app.db, s.id) - .map((r) => mapPolicyRule(r, app.db)), - } - }, - ) - - app.put<{ Params: { id: string } }>( - '/agents/:id/policy-sets', - async (req) => { - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const body = putAgentPolicySetsBodySchema.parse(req.body) - for (const setId of body.set_ids) { - if (!repos.getPolicySet(app.db, setId)) { - throw new AppError('NOT_FOUND', `Policy set not found: ${setId}`, 404) - } - } - try { - repos.setAgentPolicySets(app.db, a.id, body.set_ids) - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - auditMutation(app, config, req, { - action: 'agent.policy_sets.update', - targetType: 'app_resource', - targetId: a.id, - summary: `Наборы политик агента ${a.name} обновлены`, - details: { agent_id: a.id, set_ids: body.set_ids }, - }) - return { - items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ - set_id: s.setId, - sort: s.sort, - name: s.name, - description: s.description, - enabled: s.enabled === 1, - })), - } - }, - ) - - app.get<{ Params: { id: string } }>( - '/agents/:id/policy-sets', - async (req) => { - const a = repos.getAgent(app.db, req.params.id) - if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) - return { - items: repos.listSetsForAgent(app.db, a.id).map((s) => ({ - set_id: s.setId, - sort: s.sort, - name: s.name, - description: s.description, - enabled: s.enabled === 1, - })), - } - }, - ) - - // Rules - app.get<{ Querystring: { set_id?: string; agent_id?: string } }>( - '/rules', - async (req) => { - if (req.query.agent_id) { - return { - items: repos - .listPolicyRulesForAgent(app.db, req.query.agent_id) - .map((r) => mapPolicyRule(r, app.db)), - } - } - const items = repos - .listPolicyRules(app.db, req.query.set_id) - .map((r) => mapPolicyRule(r, app.db)) - return { items } - }, - ) - - app.post('/rules', async (req) => { - const body = createPolicyRuleBodySchema.parse(req.body) - const set = repos.getPolicySet(app.db, body.set_id) - if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404) - - const hostname = body.hostname?.trim() || null - const cidr = body.cidr?.trim() || null - const listId = body.list_id?.trim() || null - - if (hostname) { - try { - await resolveHostnameToCidrs(hostname) - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - } - - if (listId && !repos.getIpList(app.db, listId)) { - throw new AppError('NOT_FOUND', 'IP list not found', 404) - } - - const priority = - body.priority ?? repos.nextRulePriority(app.db, body.set_id) - - const id = crypto.randomUUID() - const row = repos.insertPolicyRule(app.db, { - id, - setId: body.set_id, - priority, - action: body.action, - enabled: body.enabled === false ? 0 : 1, - listId, - cidr, - hostname, - comment: body.comment ?? null, - createdByUserId: req.authUser?.id, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }) - - if (hostname) { - try { - await resolveAndStoreHostnameRule(app.db, id, hostname) - } catch (err) { - repos.deletePolicyRule(app.db, id) - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - } - - repos.bumpAgentsForSet(app.db, body.set_id) - auditMutation(app, config, req, { - action: 'rule.create', - targetType: 'app_resource', - targetId: row!.id, - summary: `Создано правило ${body.action} в наборе ${set.name}`, - details: { - rule_id: row!.id, - set_id: body.set_id, - action: body.action, - priority, - }, - }) - return mapPolicyRule(row!, app.db) - }) - - app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => { - const body = patchPolicyRuleBodySchema.parse(req.body) - const rule = repos.getPolicyRule(app.db, req.params.id) - if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) - const updated = repos.updatePolicyRule(app.db, rule.id, { - enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, - action: body.action, - comment: body.comment, - priority: body.priority, - }) - repos.bumpAgentsForSet(app.db, rule.setId) - auditMutation(app, config, req, { - action: 'rule.update', - targetType: 'app_resource', - targetId: rule.id, - summary: `Обновлено правило ${rule.id}`, - details: { - rule_id: rule.id, - set_id: rule.setId, - enabled: body.enabled, - action: body.action, - priority: body.priority, - }, - }) - return mapPolicyRule(updated!, app.db) - }) - - app.put<{ Params: { id: string } }>( - '/policy-sets/:id/rules/reorder', - async (req) => { - const body = reorderPolicyRulesBodySchema.parse(req.body) - const s = repos.getPolicySet(app.db, req.params.id) - if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) - try { - repos.reorderPolicyRules(app.db, s.id, body.ordered_ids) - } catch (err) { - throw new AppError( - 'VALIDATION_ERROR', - err instanceof Error ? err.message : String(err), - 400, - ) - } - repos.bumpAgentsForSet(app.db, s.id) - auditMutation(app, config, req, { - action: 'rule.reorder', - targetType: 'app_resource', - targetId: s.id, - summary: `Порядок правил изменён в наборе ${s.name}`, - details: { set_id: s.id, ordered_ids: body.ordered_ids }, - }) - return { - items: repos - .listPolicyRules(app.db, s.id) - .map((r) => mapPolicyRule(r, app.db)), - } - }, - ) - - app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => { - const rule = repos.getPolicyRule(app.db, req.params.id) - if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) - repos.deletePolicyRule(app.db, req.params.id) - repos.bumpAgentsForSet(app.db, rule.setId) - auditMutation(app, config, req, { - action: 'rule.delete', - severity: 'warning', - targetType: 'app_resource', - targetId: rule.id, - summary: `Правило удалено из набора ${rule.setId}`, - details: { rule_id: rule.id, set_id: rule.setId }, - }) - return { ok: true } - }) - - // Stats - app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({ - items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({ - id: s.id, - agent_id: s.agentId, - packets_dropped: s.packetsDropped, - packets_accepted: s.packetsAccepted, - prefix_count: s.prefixCount, - kernel_method: s.kernelMethod, - recorded_at: s.recordedAt, - })), - })) - - app.post<{ Params: { id: string } }>( - '/agents/:id/stats/reset', - async (req) => { - const agent = repos.getAgent(app.db, req.params.id) - if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404) - const updated = repos.updateAgent(app.db, agent.id, { - lastApplyPacketsDropped: 0, - lastApplyPacketsAccepted: 0, - totalPacketsDropped: 0, - totalPacketsAccepted: 0, - }) - repos.deleteStatsSamplesForAgent(app.db, agent.id) - auditMutation(app, config, req, { - action: 'agent.stats_reset', - severity: 'info', - targetType: 'app_resource', - targetId: agent.id, - summary: `Сброшена статистика counters агента ${agent.name}`, - details: { agent_id: agent.id }, - }) - return mapAgent(updated!) - }, - ) - - app.get('/stats/recent', async () => ({ - items: repos.listRecentStats(app.db).map((s) => ({ - id: s.id, - agent_id: s.agentId, - packets_dropped: s.packetsDropped, - packets_accepted: s.packetsAccepted, - prefix_count: s.prefixCount, - kernel_method: s.kernelMethod, - recorded_at: s.recordedAt, - })), - })) - - /** Proxy EvoBGP communities for UI autocomplete. */ - app.get('/integrations/evobgp/communities', async () => { - const apiUrl = repos.getSetting(app.db, 'evobgp_api_url') - const token = repos.getSetting(app.db, 'evobgp_api_token') - if (!apiUrl || !token) { - throw new AppError( - 'VALIDATION_ERROR', - 'Настройте evobgp_api_url и evobgp_api_token', - 400, - ) - } - const base = apiUrl.replace(/\/$/, '') - const res = await fetch(`${base}/v1/communities?limit=200`, { - headers: { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }, - signal: AbortSignal.timeout(20_000), - }) - if (!res.ok) { - throw new AppError( - 'UPSTREAM_ERROR', - `EvoBGP communities HTTP ${res.status}`, - 502, - ) - } - const data = (await res.json()) as { - items?: { - id?: string - community?: string - title?: string | null - }[] - } - const items = (data.items ?? []) - .filter((x) => x.id && x.community) - .map((x) => ({ - id: x.id!, - community: x.community!, - title: x.title ?? null, - })) - return { items } - }) - - // Settings - app.get('/settings', async () => { - const rows = repos.listSettings(app.db) - const map: Record = {} - for (const r of rows) { - if (r.key === 'evobgp_api_token' && r.value) { - map[r.key] = '********' - } else { - map[r.key] = r.value - } - } - if (!map.enroll_seed) map.enroll_seed = config.enrollSeed - return map - }) - - app.put('/settings', async (req) => { - const body = req.body as Record - for (const [k, v] of Object.entries(body)) { - if (typeof v !== 'string') continue - if (k === 'evobgp_api_token' && v === '********') continue - repos.setSetting(app.db, k, v) - } - return { ok: true } - }) + await app.register(dashboardRoutes, { config }) + await app.register(installLinksRoutes, { config }) + await app.register(agentsRoutes, { config }) + await app.register(listsRoutes, { config }) + await app.register(policySetsRoutes, { config }) + await app.register(rulesRoutes, { config }) + await app.register(statsRoutes, { config }) + await app.register(integrationsEvobgpRoutes) + await app.register(settingsRoutes, { config }) } diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts new file mode 100644 index 0000000..56c66f9 --- /dev/null +++ b/apps/api/src/routes/dashboard.ts @@ -0,0 +1,48 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import type { AppConfig } from '../config.js' + +export const dashboardRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/dashboard', async () => { + const all = repos.listAgents(app.db) + const now = Date.now() + const online = all.filter((a) => { + if (!a.lastSeenAt || a.status !== 'approved') return false + return now - Date.parse(a.lastSeenAt) < 5 * 60_000 + }) + return { + agents_total: all.length, + agents_approved: all.filter((a) => a.status === 'approved').length, + agents_online: online.length, + agents_pending: all.filter((a) => a.status === 'pending').length, + packets_dropped: all.reduce( + (s, a) => s + (a.totalPacketsDropped ?? a.lastApplyPacketsDropped ?? 0), + 0, + ), + packets_accepted: all.reduce( + (s, a) => s + (a.totalPacketsAccepted ?? a.lastApplyPacketsAccepted ?? 0), + 0, + ), + lists_total: repos.listIpLists(app.db).length, + } + }) + + app.get('/install-context', async () => { + const seed = + repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed + return { + suggested_cp_url: config.publicBaseUrl, + enroll_seed: seed, + install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`, + mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`, + sync_interval_sec: Number( + repos.getSetting(app.db, 'agent_sync_interval_sec') || '60', + ), + } + }) +} diff --git a/apps/api/src/routes/install-links.ts b/apps/api/src/routes/install-links.ts new file mode 100644 index 0000000..e5bbf63 --- /dev/null +++ b/apps/api/src/routes/install-links.ts @@ -0,0 +1,88 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { createInstallLinkBodySchema } from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import { + mapInstallLink, + randomToken, +} from '../services/install-links.js' +import { hashToken } from '../plugins/auth.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' + +export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/install-links', async () => ({ + items: repos + .listInstallLinks(app.db) + .map((row) => mapInstallLink(row, config.publicBaseUrl)), + })) + + app.post('/install-links', async (req, reply) => { + const body = createInstallLinkBodySchema.parse(req.body) + const linkId = randomToken(10) + const slug = randomToken(16) + if ( + repos.getInstallLink(app.db, linkId) || + repos.getInstallLinkBySlug(app.db, slug) + ) { + throw new AppError('CONFLICT', 'Retry create (id collision)', 409) + } + + const agentId = crypto.randomUUID() + const inviteToken = `invite:${agentId}` + const now = new Date().toISOString() + const name = body.name.trim() + const platform = body.platform ?? 'linux' + + app.sqlite.transaction(() => { + repos.insertAgent(app.db, { + id: agentId, + name, + hostname: null, + platform, + tokenPrefix: inviteToken.slice(0, 12), + tokenHash: hashToken(inviteToken), + status: 'invited', + defaultAction: 'accept', + policyGeneration: 1, + clientVersion: null, + settingsJson: '{}', + createdAt: now, + }) + repos.insertInstallLink(app.db, { + id: linkId, + slug, + clientName: name, + platform, + agentId, + createdAt: now, + useCount: 0, + }) + })() + + const row = repos.getInstallLink(app.db, linkId)! + auditMutation(app, config, req, { + action: 'agent.create', + targetType: 'app_resource', + targetId: agentId, + summary: `Создан агент (invite): ${name}`, + details: { agent_id: agentId, platform, install_link_id: linkId }, + }) + return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl)) + }) + + app.delete<{ Params: { id: string } }>( + '/install-links/:id', + async (req) => { + const row = repos.getInstallLink(app.db, req.params.id) + if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404) + const updated = repos.revokeInstallLink(app.db, row.id) + return mapInstallLink(updated!, config.publicBaseUrl) + }, + ) +} diff --git a/apps/api/src/routes/integrations-evobgp.ts b/apps/api/src/routes/integrations-evobgp.ts new file mode 100644 index 0000000..db4bb44 --- /dev/null +++ b/apps/api/src/routes/integrations-evobgp.ts @@ -0,0 +1,48 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { AppError } from '../plugins/error-handler.js' + +export const integrationsEvobgpRoutes: FastifyPluginAsync = async (app) => { + /** Proxy EvoBGP communities for UI autocomplete. */ + app.get('/integrations/evobgp/communities', async () => { + const apiUrl = repos.getSetting(app.db, 'evobgp_api_url') + const token = repos.getSetting(app.db, 'evobgp_api_token') + if (!apiUrl || !token) { + throw new AppError( + 'VALIDATION_ERROR', + 'Настройте evobgp_api_url и evobgp_api_token', + 400, + ) + } + const base = apiUrl.replace(/\/$/, '') + const res = await fetch(`${base}/v1/communities?limit=200`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(20_000), + }) + if (!res.ok) { + throw new AppError( + 'UPSTREAM_ERROR', + `EvoBGP communities HTTP ${res.status}`, + 502, + ) + } + const data = (await res.json()) as { + items?: { + id?: string + community?: string + title?: string | null + }[] + } + const items = (data.items ?? []) + .filter((x) => x.id && x.community) + .map((x) => ({ + id: x.id!, + community: x.community!, + title: x.title ?? null, + })) + return { items } + }) +} diff --git a/apps/api/src/routes/lists.ts b/apps/api/src/routes/lists.ts new file mode 100644 index 0000000..1df9c0c --- /dev/null +++ b/apps/api/src/routes/lists.ts @@ -0,0 +1,185 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { + createIpListBodySchema, + listEntriesBodySchema, + deleteListEntryBodySchema, + isManualListType, +} from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import { refreshIpList } from '../services/lists/refresh.js' +import { + addListEntries, + deleteListEntry, + mapListDetail, +} from '../services/lists/entries.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' + +export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/lists', async () => { + const lists = repos.listIpLists(app.db) + const counts = repos.countEntriesByListIds( + app.db, + lists.map((l) => l.id), + ) + const items = lists.map((l) => ({ + id: l.id, + name: l.name, + type: l.type, + config_json: l.configJson, + content_hash: l.contentHash, + refreshed_at: l.refreshedAt, + last_error: l.lastError, + entry_count: counts.get(l.id) ?? 0, + created_at: l.createdAt, + updated_at: l.updatedAt, + })) + return { items } + }) + + app.post('/lists', async (req) => { + const body = createIpListBodySchema.parse(req.body) + const type = + body.type === 'domains' ? 'static' : body.type + const id = crypto.randomUUID() + const list = repos.insertIpList(app.db, { + id, + name: body.name, + type, + configJson: JSON.stringify(body.config ?? {}), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + if (body.entries?.length && isManualListType(type)) { + try { + await addListEntries(app.db, id, { values: body.entries }) + } catch (err) { + repos.deleteIpList(app.db, id) + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + } else if (!isManualListType(type)) { + await refreshIpList(app.db, id) + } + auditMutation(app, config, req, { + action: 'list.create', + targetType: 'app_resource', + targetId: list!.id, + summary: `Создан список: ${list!.name}`, + details: { list_id: list!.id, type: list!.type }, + }) + return { + id: list!.id, + name: list!.name, + type: list!.type, + config_json: list!.configJson, + created_at: list!.createdAt, + updated_at: list!.updatedAt, + } + }) + + app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { + const detail = mapListDetail(app.db, req.params.id) + if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) + return detail + }) + + app.post<{ Params: { id: string } }>( + '/lists/:id/entries', + async (req) => { + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + const body = listEntriesBodySchema.parse(req.body) + try { + const result = await addListEntries(app.db, l.id, { + values: body.values, + items: body.items, + }) + auditMutation(app, config, req, { + action: 'list.entries.add', + targetType: 'app_resource', + targetId: l.id, + summary: `Добавлены записи в список: ${l.name}`, + details: { + list_id: l.id, + entry_count: result.entries.length, + }, + }) + return mapListDetail(app.db, l.id) ?? result + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + }, + ) + + app.delete<{ Params: { id: string } }>( + '/lists/:id/entries', + async (req) => { + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + const body = deleteListEntryBodySchema.parse(req.body) + try { + await deleteListEntry(app.db, l.id, body.value) + auditMutation(app, config, req, { + action: 'list.entries.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: l.id, + summary: `Удалена запись из списка: ${l.name}`, + details: { list_id: l.id, value: body.value }, + }) + return mapListDetail(app.db, l.id) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + }, + ) + + app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { + const l = repos.getIpList(app.db, req.params.id) + await refreshIpList(app.db, req.params.id) + const detail = mapListDetail(app.db, req.params.id) + if (!detail) throw new AppError('NOT_FOUND', 'List not found', 404) + auditMutation(app, config, req, { + action: 'list.refresh', + targetType: 'app_resource', + targetId: req.params.id, + summary: `Обновлён список: ${l?.name ?? req.params.id}`, + details: { list_id: req.params.id }, + }) + return detail + }) + + app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { + const l = repos.getIpList(app.db, req.params.id) + repos.deleteIpList(app.db, req.params.id) + if (l) { + auditMutation(app, config, req, { + action: 'list.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: l.id, + summary: `Список удалён: ${l.name}`, + details: { list_id: l.id }, + }) + } + return { ok: true } + }) +} diff --git a/apps/api/src/routes/policy-sets.ts b/apps/api/src/routes/policy-sets.ts new file mode 100644 index 0000000..838e5de --- /dev/null +++ b/apps/api/src/routes/policy-sets.ts @@ -0,0 +1,103 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { + createPolicySetBodySchema, + patchPolicySetBodySchema, +} from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' +import { mapPolicySet, mapPolicySets } from '../services/row-mappers.js' + +export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/policy-sets', async () => ({ + items: mapPolicySets(app.db, repos.listPolicySets(app.db)), + })) + + app.get<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + return { + ...mapPolicySet(s, app.db), + agent_ids: repos.listAgentIdsForSet(app.db, s.id), + } + }) + + app.post('/policy-sets', async (req) => { + const body = createPolicySetBodySchema.parse(req.body) + const row = repos.insertPolicySet(app.db, { + id: crypto.randomUUID(), + name: body.name.trim(), + description: body.description ?? null, + enabled: body.enabled === false ? 0 : 1, + policyMode: 'blacklist', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + auditMutation(app, config, req, { + action: 'policy_set.create', + targetType: 'app_resource', + targetId: row!.id, + summary: `Создан набор политик: ${row!.name}`, + details: { set_id: row!.id }, + }) + return mapPolicySet(row!, app.db) + }) + + app.patch<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + const body = patchPolicySetBodySchema.parse(req.body) + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + const updated = repos.updatePolicySet(app.db, s.id, { + name: body.name?.trim(), + description: body.description, + enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, + }) + if (body.enabled !== undefined) { + repos.bumpAgentsForSet(app.db, s.id) + } + auditMutation(app, config, req, { + action: 'policy_set.update', + targetType: 'app_resource', + targetId: s.id, + summary: `Обновлён набор политик: ${updated!.name}`, + details: { + set_id: s.id, + enabled: body.enabled, + name: body.name, + }, + }) + return mapPolicySet(updated!, app.db) + }) + + app.delete<{ Params: { id: string } }>('/policy-sets/:id', async (req) => { + const s = repos.getPolicySet(app.db, req.params.id) + try { + const agentIds = repos.listAgentIdsForSet(app.db, req.params.id) + repos.deletePolicySet(app.db, req.params.id) + for (const id of agentIds) repos.bumpAgentGeneration(app.db, id) + if (s) { + auditMutation(app, config, req, { + action: 'policy_set.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: s.id, + summary: `Набор политик удалён: ${s.name}`, + details: { set_id: s.id, agents_affected: agentIds.length }, + }) + } + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + return { ok: true } + }) +} diff --git a/apps/api/src/routes/rules.ts b/apps/api/src/routes/rules.ts new file mode 100644 index 0000000..57e8b50 --- /dev/null +++ b/apps/api/src/routes/rules.ts @@ -0,0 +1,199 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { + createPolicyRuleBodySchema, + patchPolicyRuleBodySchema, + reorderPolicyRulesBodySchema, +} from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import { + resolveAndStoreHostnameRule, + resolveHostnameToCidrs, +} from '../services/policy/resolve-hostname.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' +import { mapPolicyRule } from '../services/row-mappers.js' + +export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get<{ Params: { id: string } }>( + '/policy-sets/:id/rules', + async (req) => { + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + return { + items: repos + .listPolicyRules(app.db, s.id) + .map((r) => mapPolicyRule(r, app.db)), + } + }, + ) + + app.get<{ Querystring: { set_id?: string; agent_id?: string } }>( + '/rules', + async (req) => { + if (req.query.agent_id) { + return { + items: repos + .listPolicyRulesForAgent(app.db, req.query.agent_id) + .map((r) => mapPolicyRule(r, app.db)), + } + } + const items = repos + .listPolicyRules(app.db, req.query.set_id) + .map((r) => mapPolicyRule(r, app.db)) + return { items } + }, + ) + + app.post('/rules', async (req) => { + const body = createPolicyRuleBodySchema.parse(req.body) + const set = repos.getPolicySet(app.db, body.set_id) + if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + + const hostname = body.hostname?.trim() || null + const cidr = body.cidr?.trim() || null + const listId = body.list_id?.trim() || null + + if (hostname) { + try { + await resolveHostnameToCidrs(hostname) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + } + + if (listId && !repos.getIpList(app.db, listId)) { + throw new AppError('NOT_FOUND', 'IP list not found', 404) + } + + const priority = + body.priority ?? repos.nextRulePriority(app.db, body.set_id) + + const id = crypto.randomUUID() + const row = repos.insertPolicyRule(app.db, { + id, + setId: body.set_id, + priority, + action: body.action, + enabled: body.enabled === false ? 0 : 1, + listId, + cidr, + hostname, + comment: body.comment ?? null, + createdByUserId: req.authUser?.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + + if (hostname) { + try { + await resolveAndStoreHostnameRule(app.db, id, hostname) + } catch (err) { + repos.deletePolicyRule(app.db, id) + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + } + + repos.bumpAgentsForSet(app.db, body.set_id) + auditMutation(app, config, req, { + action: 'rule.create', + targetType: 'app_resource', + targetId: row!.id, + summary: `Создано правило ${body.action} в наборе ${set.name}`, + details: { + rule_id: row!.id, + set_id: body.set_id, + action: body.action, + priority, + }, + }) + return mapPolicyRule(row!, app.db) + }) + + app.patch<{ Params: { id: string } }>('/rules/:id', async (req) => { + const body = patchPolicyRuleBodySchema.parse(req.body) + const rule = repos.getPolicyRule(app.db, req.params.id) + if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) + const updated = repos.updatePolicyRule(app.db, rule.id, { + enabled: body.enabled === undefined ? undefined : body.enabled ? 1 : 0, + action: body.action, + comment: body.comment, + priority: body.priority, + }) + repos.bumpAgentsForSet(app.db, rule.setId) + auditMutation(app, config, req, { + action: 'rule.update', + targetType: 'app_resource', + targetId: rule.id, + summary: `Обновлено правило ${rule.id}`, + details: { + rule_id: rule.id, + set_id: rule.setId, + enabled: body.enabled, + action: body.action, + priority: body.priority, + }, + }) + return mapPolicyRule(updated!, app.db) + }) + + app.put<{ Params: { id: string } }>( + '/policy-sets/:id/rules/reorder', + async (req) => { + const body = reorderPolicyRulesBodySchema.parse(req.body) + const s = repos.getPolicySet(app.db, req.params.id) + if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) + try { + repos.reorderPolicyRules(app.db, s.id, body.ordered_ids) + } catch (err) { + throw new AppError( + 'VALIDATION_ERROR', + err instanceof Error ? err.message : String(err), + 400, + ) + } + repos.bumpAgentsForSet(app.db, s.id) + auditMutation(app, config, req, { + action: 'rule.reorder', + targetType: 'app_resource', + targetId: s.id, + summary: `Порядок правил изменён в наборе ${s.name}`, + details: { set_id: s.id, ordered_ids: body.ordered_ids }, + }) + return { + items: repos + .listPolicyRules(app.db, s.id) + .map((r) => mapPolicyRule(r, app.db)), + } + }, + ) + + app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => { + const rule = repos.getPolicyRule(app.db, req.params.id) + if (!rule) throw new AppError('NOT_FOUND', 'Rule not found', 404) + repos.deletePolicyRule(app.db, req.params.id) + repos.bumpAgentsForSet(app.db, rule.setId) + auditMutation(app, config, req, { + action: 'rule.delete', + severity: 'warning', + targetType: 'app_resource', + targetId: rule.id, + summary: `Правило удалено из набора ${rule.setId}`, + details: { rule_id: rule.id, set_id: rule.setId }, + }) + return { ok: true } + }) +} diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..a72cc5a --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -0,0 +1,34 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { putSettingsBodySchema } from '@evofw/shared' +import type { AppConfig } from '../config.js' + +export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/settings', async () => { + const rows = repos.listSettings(app.db) + const map: Record = {} + for (const r of rows) { + if (r.key === 'evobgp_api_token' && r.value) { + map[r.key] = '********' + } else { + map[r.key] = r.value + } + } + if (!map.enroll_seed) map.enroll_seed = config.enrollSeed + return map + }) + + app.put('/settings', async (req) => { + const body = putSettingsBodySchema.parse(req.body) + for (const [k, v] of Object.entries(body)) { + if (k === 'evobgp_api_token' && v === '********') continue + repos.setSetting(app.db, k, v) + } + return { ok: true } + }) +} diff --git a/apps/api/src/routes/stats.ts b/apps/api/src/routes/stats.ts new file mode 100644 index 0000000..cea8751 --- /dev/null +++ b/apps/api/src/routes/stats.ts @@ -0,0 +1,61 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { AppError } from '../plugins/error-handler.js' +import type { AppConfig } from '../config.js' +import { auditMutation } from '../services/audit.js' +import { mapAgent } from '../services/row-mappers.js' + +export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({ + items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({ + id: s.id, + agent_id: s.agentId, + packets_dropped: s.packetsDropped, + packets_accepted: s.packetsAccepted, + prefix_count: s.prefixCount, + kernel_method: s.kernelMethod, + recorded_at: s.recordedAt, + })), + })) + + app.post<{ Params: { id: string } }>( + '/agents/:id/stats/reset', + async (req) => { + const agent = repos.getAgent(app.db, req.params.id) + if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const updated = repos.updateAgent(app.db, agent.id, { + lastApplyPacketsDropped: 0, + lastApplyPacketsAccepted: 0, + totalPacketsDropped: 0, + totalPacketsAccepted: 0, + }) + repos.deleteStatsSamplesForAgent(app.db, agent.id) + auditMutation(app, config, req, { + action: 'agent.stats_reset', + severity: 'info', + targetType: 'app_resource', + targetId: agent.id, + summary: `Сброшена статистика counters агента ${agent.name}`, + details: { agent_id: agent.id }, + }) + return mapAgent(updated!) + }, + ) + + app.get('/stats/recent', async () => ({ + items: repos.listRecentStats(app.db).map((s) => ({ + id: s.id, + agent_id: s.agentId, + packets_dropped: s.packetsDropped, + packets_accepted: s.packetsAccepted, + prefix_count: s.prefixCount, + kernel_method: s.kernelMethod, + recorded_at: s.recordedAt, + })), + })) +} diff --git a/apps/api/src/services/agents-policy.test.ts b/apps/api/src/services/agents-policy.test.ts new file mode 100644 index 0000000..a83fcbf --- /dev/null +++ b/apps/api/src/services/agents-policy.test.ts @@ -0,0 +1,106 @@ +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', +} + +describe('agents CRUD critical paths', () => { + const appPromise = buildApp({ memory: true, config: testConfig }) + + afterAll(async () => { + const app = await appPromise + await app.close() + }) + + it('creates invite, approves, lists with install curl', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'ops-01', platform: 'linux' }, + }) + expect(created.statusCode).toBe(201) + const link = created.json() as { agent_id: string } + + const approve = await app.inject({ + method: 'POST', + url: `/api/v1/agents/${link.agent_id}/approve`, + }) + expect(approve.statusCode).toBe(200) + + const list = await app.inject({ method: 'GET', url: '/api/v1/agents' }) + expect(list.statusCode).toBe(200) + const items = (list.json() as { items: { id: string; status: string }[] }) + .items + const agent = items.find((a) => a.id === link.agent_id) + expect(agent?.status).toBe('approved') + }) + + it('creates policy set + rule and reorders', async () => { + const app = await appPromise + await app.ready() + + const setRes = await app.inject({ + method: 'POST', + url: '/api/v1/policy-sets', + payload: { name: 'test-set' }, + }) + expect(setRes.statusCode).toBe(200) + const set = setRes.json() as { id: string } + + const r1 = await app.inject({ + method: 'POST', + url: '/api/v1/rules', + payload: { + set_id: set.id, + action: 'deny', + cidr: '1.1.1.1/32', + }, + }) + expect(r1.statusCode).toBe(200) + const rule1 = r1.json() as { id: string } + + const r2 = await app.inject({ + method: 'POST', + url: '/api/v1/rules', + payload: { + set_id: set.id, + action: 'allow', + cidr: '8.8.8.8/32', + }, + }) + expect(r2.statusCode).toBe(200) + const rule2 = r2.json() as { id: string } + + const reorder = await app.inject({ + method: 'PUT', + url: `/api/v1/policy-sets/${set.id}/rules/reorder`, + payload: { + ordered_ids: [rule2.id, rule1.id], + }, + }) + expect(reorder.statusCode).toBe(200) + + const rules = await app.inject({ + method: 'GET', + url: `/api/v1/policy-sets/${set.id}/rules`, + }) + expect(rules.statusCode).toBe(200) + const items = (rules.json() as { items: { id: string }[] }).items + expect(items[0]?.id).toBe(rule2.id) + }) +}) diff --git a/apps/api/src/services/lists/entries.ts b/apps/api/src/services/lists/entries.ts index cd4c499..a37cf47 100644 --- a/apps/api/src/services/lists/entries.ts +++ b/apps/api/src/services/lists/entries.ts @@ -11,10 +11,7 @@ import { type ListEntryInput, } from '@evofw/shared' import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' - -function uniq(cidrs: string[]): string[] { - return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() -} +import { uniqCidrs } from '../uniq.js' export function getListConfig(list: { configJson: string @@ -129,7 +126,7 @@ export async function rebuildManualListEntries( delete config.domains repos.updateIpList(db, listId, { configJson: JSON.stringify(config) }) - const cidrs = uniq(all) + const cidrs = uniqCidrs(all) repos.replaceIpListEntries(db, listId, cidrs) return cidrs } finally { diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts index e1527b3..3bba48b 100644 --- a/apps/api/src/services/lists/refresh.ts +++ b/apps/api/src/services/lists/refresh.ts @@ -7,10 +7,7 @@ import { rebuildListCascade, rebuildManualListEntries, } from './entries.js' - -function uniq(cidrs: string[]): string[] { - return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() -} +import { uniqCidrs } from '../uniq.js' function hashCidrs(cidrs: string[]): string { return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` @@ -48,7 +45,7 @@ async function fetchJsonUrl(url: string): Promise { } } } - return uniq(out) + return uniqCidrs(out) } const UUID_RE = @@ -113,9 +110,9 @@ async function fetchEvobgpCommunity( } let cidrs: string[] = [] if (Array.isArray(data.prefixes) && data.prefixes.length > 0) { - cidrs = uniq(data.prefixes) + cidrs = uniqCidrs(data.prefixes) } else if (Array.isArray(data.items)) { - cidrs = uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) + cidrs = uniqCidrs(data.items.map((i) => i.prefix ?? '').filter(Boolean)) } return { cidrs, resolvedId } } diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts index 0786f00..4e17e3f 100644 --- a/apps/api/src/services/policy/evaluate.ts +++ b/apps/api/src/services/policy/evaluate.ts @@ -6,6 +6,7 @@ import { legacyModeFromDefaultAction, type DefaultAction, } from '@evofw/shared' +import { uniqCidrs } from '../uniq.js' export const POLICY_APPLY_VERSION = 2 as const @@ -42,18 +43,6 @@ export type EvaluatedPolicy = { } } -function uniq(cidrs: string[]): string[] { - const seen = new Set() - const out: string[] = [] - for (const c of cidrs) { - const t = c.trim() - if (!t || seen.has(t)) continue - seen.add(t) - out.push(t) - } - return out.sort() -} - function expandList(db: Db, listId: string | null | undefined): string[] { if (!listId) return [] return repos.listIpListEntries(db, listId).map((e) => e.cidr) @@ -159,9 +148,9 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { }) } - const denyCidrs = uniq(deny) + const denyCidrs = uniqCidrs(deny) const denySet = new Set(denyCidrs) - const allowRaw = uniq(allow) + const allowRaw = uniqCidrs(allow) const allowCidrs = allowRaw.filter((c) => !denySet.has(c)) const conflictsDropped = allowRaw.length - allowCidrs.length const defaultAction = resolveDefaultAction(agent.defaultAction) diff --git a/apps/api/src/services/policy/resolve-hostname.ts b/apps/api/src/services/policy/resolve-hostname.ts index 50b76d1..cbf915f 100644 --- a/apps/api/src/services/policy/resolve-hostname.ts +++ b/apps/api/src/services/policy/resolve-hostname.ts @@ -2,10 +2,7 @@ import { resolve4, resolve6 } from 'node:dns/promises' import { createHash } from 'node:crypto' import type { Db } from '@evofw/db' import { repos } from '@evofw/db' - -function uniq(cidrs: string[]): string[] { - return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() -} +import { uniqCidrs } from '../uniq.js' function hashCidrs(cidrs: string[]): string { return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` @@ -30,7 +27,7 @@ export async function resolveHostnameToCidrs(hostname: string): Promise>, + opts?: { installCurl?: string | null; installLinkId?: string | null }, +) { + const defaultAction = + a.defaultAction === 'drop' ? ('drop' as const) : ('accept' as const) + return { + id: a.id, + name: a.name, + hostname: a.hostname, + platform: a.platform, + token_prefix: a.tokenPrefix, + status: a.status, + default_action: defaultAction, + policy_mode: defaultAction === 'drop' ? ('whitelist' as const) : ('blacklist' as const), + policy_generation: a.policyGeneration, + last_seen_at: a.lastSeenAt, + last_seen_ip: a.lastSeenIp, + last_apply_at: a.lastApplyAt, + last_apply_status: a.lastApplyStatus, + last_apply_error: a.lastApplyError, + last_apply_prefix_count: a.lastApplyPrefixCount, + last_apply_packets_dropped: a.lastApplyPacketsDropped, + last_apply_packets_accepted: a.lastApplyPacketsAccepted, + total_packets_dropped: a.totalPacketsDropped ?? 0, + total_packets_accepted: a.totalPacketsAccepted ?? 0, + last_apply_kernel_method: a.lastApplyKernelMethod, + client_version: a.clientVersion, + created_at: a.createdAt, + approved_at: a.approvedAt, + revoked_at: a.revokedAt, + install_curl: opts?.installCurl ?? null, + install_link_id: opts?.installLinkId ?? null, + } +} + +export function mapPolicySet( + s: NonNullable>, + db: Parameters[0], + counts?: { rules: number; agents: number }, +) { + return { + id: s.id, + name: s.name, + description: s.description, + enabled: s.enabled === 1, + rules_count: counts?.rules ?? repos.countRulesInSet(db, s.id), + agents_count: counts?.agents ?? repos.countAgentsForSet(db, s.id), + created_at: s.createdAt, + updated_at: s.updatedAt, + } +} + +export function mapPolicySets( + db: Parameters[0], + sets: NonNullable>[], +) { + const counts = repos.countRulesAndAgentsBySetIds( + db, + sets.map((s) => s.id), + ) + return sets.map((s) => mapPolicySet(s, db, counts.get(s.id))) +} + +export function mapPolicyRule( + r: NonNullable>, + db: Parameters[0], +) { + return { + id: r.id, + set_id: r.setId, + priority: r.priority, + action: r.action, + enabled: r.enabled !== 0, + list_id: r.listId, + cidr: r.cidr, + hostname: r.hostname, + resolved_count: r.hostname + ? repos.listResolvedForRule(db, r.id).length + : undefined, + comment: r.comment, + created_at: r.createdAt, + updated_at: r.updatedAt, + } +} diff --git a/apps/api/src/services/settings-bump.test.ts b/apps/api/src/services/settings-bump.test.ts new file mode 100644 index 0000000..2cc9063 --- /dev/null +++ b/apps/api/src/services/settings-bump.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, afterAll } from 'vitest' +import { buildApp } from '../app.js' +import type { AppConfig } from '../config.js' +import { repos } from '@evofw/db' + +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', +} + +describe('settings + bumpAgentsForList', () => { + const appPromise = buildApp({ memory: true, config: testConfig }) + + afterAll(async () => { + const app = await appPromise + await app.close() + }) + + it('rejects unknown settings keys', async () => { + const app = await appPromise + await app.ready() + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings', + payload: { unknown_key: 'x' }, + }) + expect(res.statusCode).toBeGreaterThanOrEqual(400) + }) + + it('accepts show_quick_actions', async () => { + const app = await appPromise + await app.ready() + const res = await app.inject({ + method: 'PUT', + url: '/api/v1/settings', + payload: { show_quick_actions: 'false' }, + }) + expect(res.statusCode).toBe(200) + const get = await app.inject({ method: 'GET', url: '/api/v1/settings' }) + expect(get.json().show_quick_actions).toBe('false') + }) + + it('bumpAgentsForList no-ops when list has no rules', async () => { + const app = await appPromise + await app.ready() + const db = app.db + + const agent = repos.insertAgent(db, { + id: 'ag-bump-1', + name: 'bump-test', + platform: 'linux', + tokenPrefix: 'tok', + tokenHash: 'hash-bump-1', + status: 'approved', + defaultAction: 'accept', + policyGeneration: 1, + settingsJson: '{}', + }) + expect(agent?.policyGeneration).toBe(1) + + const list = repos.insertIpList(db, { + id: 'list-unused', + name: 'unused', + type: 'static', + configJson: '{}', + }) + expect(list).toBeTruthy() + + repos.bumpAgentsForList(db, 'list-unused') + const after = repos.getAgent(db, 'ag-bump-1') + expect(after?.policyGeneration).toBe(1) + }) +}) diff --git a/apps/api/src/services/uniq.ts b/apps/api/src/services/uniq.ts new file mode 100644 index 0000000..b2f878e --- /dev/null +++ b/apps/api/src/services/uniq.ts @@ -0,0 +1,17 @@ +/** Trim, drop empty, dedupe (order not guaranteed — sorted for stable hashes). */ +export function uniqCidrs(cidrs: readonly string[]): string[] { + return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() +} + +/** Trim, drop empty, dedupe preserving first-seen order. */ +export function uniqCidrsPreserveOrder(cidrs: readonly string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const c of cidrs) { + const t = c.trim() + if (!t || seen.has(t)) continue + seen.add(t) + out.push(t) + } + return out +} diff --git a/apps/web/src/components/agents/add-agent-sheet.tsx b/apps/web/src/components/agents/add-agent-sheet.tsx index 74cb36c..da3aa44 100644 --- a/apps/web/src/components/agents/add-agent-sheet.tsx +++ b/apps/web/src/components/agents/add-agent-sheet.tsx @@ -1,11 +1,16 @@ import { useEffect, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' import { toast } from 'sonner' import { Copy } from 'lucide-react' +import { FormSheet } from '@/components/form-sheet' +import { LoadingButton } from '@/components/loading-button' import { apiFetch } from '@/lib/api' import type { InstallLink } from '@evofw/shared' import { Button } from '@evofw/ui/components/button' -import { Field, FieldLabel } from '@evofw/ui/components/field' +import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' import { ScrollArea } from '@evofw/ui/components/scroll-area' import { @@ -24,20 +29,26 @@ import { SheetTitle, } from '@evofw/ui/components/sheet' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' +import { Controller } from 'react-hook-form' /** - * Create agent install invite — Sheet (single form, no wizard). - * Preview: https://reui.io/preview/base/sheet-8 · sheet-1 + * Create agent install invite — FormSheet (create) + Sheet (install curl). + * Preview: https://reui.io/preview/base/sheet-8 · form-7 * Docs: https://ui.shadcn.com/docs/components/base/sheet */ -type Platform = 'linux' | 'mikrotik' - const PLATFORM_ITEMS = [ { value: 'linux', label: 'Linux' }, { value: 'mikrotik', label: 'MikroTik' }, ] as const +const createAgentSchema = z.object({ + name: z.string().trim().min(1, 'Укажите имя'), + platform: z.enum(['linux', 'mikrotik']), +}) + +type CreateAgentValues = z.infer + interface AddAgentSheetProps { open: boolean onOpenChange: (open: boolean) => void @@ -46,23 +57,28 @@ interface AddAgentSheetProps { export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { const qc = useQueryClient() const { copyToClipboard } = useCopyToClipboard() - const [name, setName] = useState('web-01') - const [platform, setPlatform] = useState('linux') const [created, setCreated] = useState(null) + const form = useForm({ + resolver: zodResolver(createAgentSchema), + defaultValues: { name: 'web-01', platform: 'linux' }, + }) + useEffect(() => { if (!open) { setCreated(null) - setName('web-01') - setPlatform('linux') + form.reset({ name: 'web-01', platform: 'linux' }) } - }, [open]) + }, [open, form]) const create = useMutation({ - mutationFn: () => + mutationFn: (values: CreateAgentValues) => apiFetch('/api/v1/install-links', { method: 'POST', - body: JSON.stringify({ name: name.trim(), platform }), + body: JSON.stringify({ + name: values.name.trim(), + platform: values.platform, + }), }), onSuccess: (link) => { setCreated(link) @@ -72,126 +88,140 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { onError: (e: Error) => toast.error(e.message), }) - const canCreate = Boolean(name.trim()) && !create.isPending - function handleCopy(text: string) { if (!text) return copyToClipboard(text) toast.success('Скопировано') } - return ( - - - - - {created ? 'Команда установки' : 'Добавить агента'} - - - {created - ? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.' - : 'Создайте агента и короткую install-ссылку.'} - - - - -
- {!created ? ( - <> - - Имя клиента - setName(e.target.value)} - placeholder="web-01" - /> - - - Платформа - - - - ) : ( - <> - - По id -
-
-                      {created.curl?.by_id}
-                    
- -
-
- - Короткий slug -
-
-                      {created.curl?.by_slug}
-                    
- -
-
- - )} -
-
+ + Копировать + + + + + Короткий slug +
+
+                    {created.curl?.by_slug}
+                  
+ +
+
+ + + + + + +
+
+ ) + } - - {created ? ( - <> - - - - ) : ( - <> - - - + return ( + create.mutateAsync(values)} + footer={ + <> + + + Создать + + + } + > + + Имя клиента + + + + + Платформа + ( + )} - - - + /> + + ) } diff --git a/apps/web/src/components/agents/agent-card.tsx b/apps/web/src/components/agents/agent-card.tsx index 921bbd6..fdc28d3 100644 --- a/apps/web/src/components/agents/agent-card.tsx +++ b/apps/web/src/components/agents/agent-card.tsx @@ -25,7 +25,6 @@ import { cn } from '@evofw/ui/lib/utils' /** * Agent catalog card — hybrid card-3 header + stats strip + stats-12 values. * Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12 - * Reference: apps/web/src/components/blocks/card-3/components/investor-card.tsx */ const packetFmt = new Intl.NumberFormat('ru-RU', { diff --git a/apps/web/src/components/agents/agents-fleet-chrome.tsx b/apps/web/src/components/agents/agents-fleet-chrome.tsx new file mode 100644 index 0000000..eea4046 --- /dev/null +++ b/apps/web/src/components/agents/agents-fleet-chrome.tsx @@ -0,0 +1,119 @@ +import type { ReactNode } from 'react' +import { FilterIcon, SearchIcon } from 'lucide-react' +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { + Filters, + type Filter, + type FilterFieldConfig, +} from '@/components/reui/filters' +import { Frame, FramePanel } from '@/components/reui/frame' +import { Button } from '@evofw/ui/components/button' +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from '@evofw/ui/components/input-group' +import { Separator } from '@evofw/ui/components/separator' + +/** + * Shared agents fleet chrome (tabs + filters + search) — cards view. + * Table view uses the same DNA via ResourcePage. + * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * · https://reui.io/preview/base/solution-agents-1 + */ + +export type AgentsFleetTab = { + id: string + label: string + count?: number +} + +export interface AgentsFleetChromeProps { + tabs: AgentsFleetTab[] + activeTab: string + onTabChange: (tabId: string) => void + filterFields: FilterFieldConfig[] + filters: Filter[] + onFiltersChange: (filters: Filter[]) => void + onClearFilters?: () => void + searchQuery: string + onSearchChange: (query: string) => void + searchPlaceholder?: string + toolbarExtra?: ReactNode + children: ReactNode +} + +export function AgentsFleetChrome({ + tabs, + activeTab, + onTabChange, + filterFields, + filters, + onFiltersChange, + onClearFilters, + searchQuery, + onSearchChange, + searchPlaceholder = 'Поиск агентов…', + toolbarExtra, + children, +}: AgentsFleetChromeProps) { + const hasActiveFilters = filters.length > 0 || searchQuery.trim().length > 0 + + return ( + + +
+ +
+ +
+
+ +
+
+ {toolbarExtra} + {hasActiveFilters && onClearFilters ? ( + + ) : null} +
+
+
+ {children} +
+
+ + ) +} diff --git a/apps/web/src/components/blocks/card-3/components/data.tsx b/apps/web/src/components/blocks/card-3/components/data.tsx deleted file mode 100644 index 5d4fb16..0000000 --- a/apps/web/src/components/blocks/card-3/components/data.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { type ReactNode } from "react" -import { BarChart3Icon, WalletIcon, CircleDollarSignIcon } from "lucide-react" - -export interface StatItem { - value: string - label: string -} - -export interface FundingSource { - name: string - amount: string - icon: ReactNode - tileClassName: string -} - -export const PROFILE = { - name: "Mara Alves", - status: "Open to Proposals", - email: "malves@reui-capital.io", - avatarSrc: - "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=160&h=160&dpr=2&q=80", -} - -export const STATS: StatItem[] = [ - { - value: "87", - label: "Deals", - }, - { - value: "$7.2M", - label: "Avg. Ticket", - }, - { - value: "$415M", - label: "Total Fund", - }, -] - -export const FUNDING_SOURCES: FundingSource[] = [ - { - name: "Northline Ventures", - amount: "$7,840,000", - tileClassName: "bg-invert text-invert-foreground", - icon: ( -