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: ( -