Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e7437210 | ||
|
|
e0364d45df | ||
|
|
bbff586da2 | ||
|
|
fbd1be8952 | ||
|
|
0bf5d6065c | ||
|
|
67c3a4fd73 | ||
|
|
9beb24273b | ||
|
|
5cc6128086 | ||
|
|
569b6be2b4 | ||
|
|
752256f12e | ||
|
|
a3502b9755 | ||
|
|
0e1524c600 | ||
|
|
11ae7593f4 | ||
|
|
3eb75ea0b8 | ||
|
|
7365d8d8fb | ||
|
|
7755d77340 | ||
|
|
564aae21f0 | ||
|
|
0fdd2fc1e1 | ||
|
|
f15a7348db | ||
|
|
f3c846201c | ||
|
|
ee9804f1bb | ||
|
|
4ce6169d14 | ||
|
|
60a0970d73 | ||
|
|
fc29dcede7 | ||
|
|
5f774ce26e | ||
|
|
25b82997b6 | ||
|
|
b4a3c3a925 | ||
|
|
9c0ee7940e | ||
|
|
5750590b68 | ||
|
|
3c42c114f5 | ||
|
|
5aef419582 | ||
|
|
0c0dfa1df7 | ||
|
|
c162a41bc0 | ||
|
|
97e43b2335 | ||
|
|
db21e1217c | ||
|
|
5bb9066be8 | ||
|
|
b1fd259f10 | ||
|
|
3687bb8fa2 | ||
|
|
a80caf5676 | ||
|
|
d39e3454aa | ||
|
|
a8f2055c77 | ||
|
|
cdeb97d841 | ||
|
|
36c5305db7 | ||
|
|
5f2b4e2d40 | ||
|
|
cff26813b9 | ||
|
|
39c8ec4a02 | ||
|
|
c6c859a495 | ||
|
|
bf5cfff12c | ||
|
|
30a8ec4420 | ||
|
|
ec43591a99 | ||
|
|
0e5fb065e2 | ||
|
|
5188b2aff2 | ||
|
|
cd1fd2c9d3 | ||
|
|
77425cca32 | ||
|
|
29d245cde3 | ||
|
|
7a491a325d | ||
|
|
db64621122 | ||
|
|
6332d83a12 | ||
|
|
3834c40aa8 | ||
|
|
90c8c393e5 | ||
|
|
cb799da13a | ||
|
|
e0ddb17539 | ||
|
|
2820683cba | ||
|
|
37167f78e3 | ||
|
|
cf68b59b3f | ||
|
|
5e512407e5 | ||
|
|
13889005f8 | ||
|
|
f0dc5acfd3 | ||
|
|
63bed28251 | ||
|
|
95dcd3df58 | ||
|
|
1e9312acbd | ||
|
|
5884bd8873 | ||
|
|
fc161506e7 | ||
|
|
b3e50a1f5f | ||
|
|
fe32c9313a | ||
|
|
6123660346 | ||
|
|
b680f882cc | ||
|
|
5e0c16e808 | ||
|
|
66509b26bd | ||
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 | ||
|
|
9df3971f6c |
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
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
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
|
||||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
||||||
"WebFetch(domain:git.shts.su)",
|
"WebFetch(domain:git.shx.one)",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
|
||||||
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
|
||||||
"Bash(node -e ' *)",
|
"Bash(node -e ' *)",
|
||||||
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
|
||||||
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
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
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
description: Use the ReUI registry (blocks, primitives, icons) correctly
|
||||||
|
globs: ["**/*.tsx","**/*.ts"]
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
name: reui
|
||||||
|
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 `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 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
|
||||||
|
|
||||||
|
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
|
||||||
|
|
||||||
|
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
|
||||||
|
|
||||||
|
## The core loop (MCP-native)
|
||||||
|
|
||||||
|
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
|
||||||
|
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
|
||||||
|
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
|
||||||
|
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
|
||||||
|
|
||||||
|
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
|
||||||
|
|
||||||
|
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
|
||||||
|
|
||||||
|
| Command | Invoke | Does |
|
||||||
|
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
|
||||||
|
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
|
||||||
|
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
|
||||||
|
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
|
||||||
|
|
||||||
|
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
|
||||||
|
|
||||||
|
## When to reach for ReUI vs plain shadcn
|
||||||
|
|
||||||
|
| Need | Reach for |
|
||||||
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
|
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
|
||||||
|
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
|
||||||
|
| A drag-and-drop board | the **kanban** component |
|
||||||
|
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
|
||||||
|
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
|
||||||
|
|
||||||
|
## Detailed references
|
||||||
|
|
||||||
|
- [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 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)
|
||||||
|
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
|
||||||
|
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
|
||||||
|
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
|
||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
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
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,58 @@ jobs:
|
|||||||
node .ci/scripts/compute-release.mjs
|
node .ci/scripts/compute-release.mjs
|
||||||
test -f .ci/release/release-manifest.json
|
test -f .ci/release/release-manifest.json
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node 22
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Start PostgreSQL 18
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
|
||||||
|
docker rm -f "$NAME" mm-pg 2>/dev/null || true
|
||||||
|
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
|
||||||
|
docker run -d --name "$NAME" --rm \
|
||||||
|
-e POSTGRES_USER=mmapp \
|
||||||
|
-e POSTGRES_PASSWORD=mmapp \
|
||||||
|
-e POSTGRES_DB=mmapp \
|
||||||
|
-p "127.0.0.1:${HOST_PORT}:5432" \
|
||||||
|
postgres:18-alpine
|
||||||
|
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
|
||||||
|
echo "DATABASE_URL=postgres://mmapp:[email protected]:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "PostgreSQL не поднялся"
|
||||||
|
docker logs "$NAME" || true
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Install and test backend
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
npm ci
|
||||||
|
npm run test --prefix backend
|
||||||
|
|
||||||
|
- name: Stop PostgreSQL
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
|
||||||
|
|
||||||
backend-image:
|
backend-image:
|
||||||
needs: prepare-release
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- backend-test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -61,7 +111,16 @@ jobs:
|
|||||||
STAGING=".ci/docker/backend"
|
STAGING=".ci/docker/backend"
|
||||||
rm -rf "$STAGING"
|
rm -rf "$STAGING"
|
||||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||||
cp package.json package-lock.json "$STAGING/"
|
cp package-lock.json "$STAGING/"
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||||
|
pkg.workspaces = ["packages/*", "backend"]
|
||||||
|
pkg.dependencies = {}
|
||||||
|
pkg.devDependencies = {}
|
||||||
|
delete pkg.scripts
|
||||||
|
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||||
|
NODE
|
||||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||||
@@ -70,6 +129,7 @@ jobs:
|
|||||||
cp -R backend/drizzle "$STAGING/backend/"
|
cp -R backend/drizzle "$STAGING/backend/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
@@ -177,6 +237,7 @@ jobs:
|
|||||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||||
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
|
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
|
||||||
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||||
tags: |
|
tags: |
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
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
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# 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.
|
**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
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
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
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Локальные GeoLite2-базы (Country + ASN) для потоков по странам и ASN
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Сейчас страна и ASN для netflow-потоков резолвятся через внешний RIPEstat API (`backend/src/services/traffic-flow-ripe.ts`): лимит 30 новых префиксов/мин, очередь на 90, кэш в PG `flow_ip_meta`. Новые IP «дозревают» с задержкой, IPv6 не покрывается (кэш индексируется только по IPv4). Локальные mmdb-базы дают мгновенный синхронный lookup всех IP без внешних вызовов.
|
||||||
|
|
||||||
|
Решения (подтверждены):
|
||||||
|
- Источник — **MaxMind GeoLite2 через P3TERX-зеркало**: `https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-Country.mmdb` и `.../GeoLite2-ASN.mmdb`. Без регистрации, ключей и tar-распаковки. Точность по стране у GeoLite2 и IPinfo паритетная (<1% ошибок у обоих, arXiv 2026); выбран P3TERX за надёжность зеркала (5.2k звёзд) и преемственность: текущий RIPE-путь и так читает GeoLite (`maxmind-geo-lite`), история в кэше остаётся консистентной.
|
||||||
|
- **RIPEstat остаётся fallback** (до первой загрузки баз / если lookup не дал результата).
|
||||||
|
- City-базу не качаем (lat/lng фронтенд не использует).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Зависимость
|
||||||
|
- `npm install -w mikrotik-manager-backend maxmind` — sync-чтение mmdb, встроенные TS-типы, без транзитивных зависимостей, Node 22 ок.
|
||||||
|
|
||||||
|
### 2. Новый сервис `backend/src/services/traffic-flow-geoip.ts`
|
||||||
|
(по конвенциям окружения traffic-flow-*: контракт → маршрут → сервис, без БД-логики в маршрутах)
|
||||||
|
- Каталог: `backend/storage/geoip/` (конвенция `storage/backups`), файлы `GeoLite2-Country.mmdb`, `GeoLite2-ASN.mmdb`.
|
||||||
|
- `initGeoip()` — открыть ридеры best-effort при старте (из `index.ts` рядом с `startTrafficFlowListener`), независимо от настроек автообновления: файлы есть — работают.
|
||||||
|
- `lookupGeoip(ip): FlowIpMeta | null` — синхронно: `country.iso_code` (fallback `registered_country.iso_code`) с валидацией `isIsoCountry`, ASN = `autonomous_system_number`, holder = `autonomous_system_organization`; приватные IP → negative-запись как в RIPE (`isNonPublicIp`); в PG не пишем (lookup и так быстрый). IPv6 поддержан ридером.
|
||||||
|
- `resolveFlowIp(ip)` — фасад: `lookupGeoip(ip) ?? lookupRipeCached(ip)`; главный экспорт для потребителей.
|
||||||
|
- `geoipStatus()` — loaded, даты сборки баз (метаданные mmdb). Тест-хук `setGeoipReadersForTests`. Смена ридеров после обновления — атомарная замена ссылок.
|
||||||
|
|
||||||
|
### 3. Коллектор `backend/src/services/geoip-update-collector.ts`
|
||||||
|
`collectGeoipUpdateOnce()` по образцу `certificate-renew-collector.ts`:
|
||||||
|
1. Conditional GET с ETag/If-None-Match из настроек → 304 = skip (фолбэк-сравнение: размер/содержимое).
|
||||||
|
2. Скачивание в `*.tmp` через глобальный `fetch` + AbortController с таймаутом (внешний HTTP из service-слоя — по правилу fastify-backend-drizzle).
|
||||||
|
3. Валидация: открыть ридер из tmp-файла, пробой 8.8.8.8 (страна US, ASN 15169).
|
||||||
|
4. `fs.rename` атомарная подмена, старый файл → `*.prev` (откат, если новый ридер не открылся).
|
||||||
|
5. Перезагрузка ридеров, статус в настройках; snapshot для `scheduler_runs` (checked/downloaded/skipped/bytes/error).
|
||||||
|
|
||||||
|
### 4. Планировщик (`backend/src/services/scheduler.ts`)
|
||||||
|
- `JOB_KEYS` += `geoip_update`; case в `runSchedulerJobBody`; блок в `refreshScheduler()` по образцу `certificates_renew`: интервал `Math.max(6ч, updateIntervalSec*1000)`, по умолчанию 7 дней (upstream обновляется еженедельно) + немедленный первый запуск при включённой настройке.
|
||||||
|
|
||||||
|
### 5. Схема и миграция
|
||||||
|
- `backend/src/db/schema.ts`: singleton `geoip_settings` — `enabled` (default true), `updateIntervalSec` (default 604800), `lastCheckAt`, `lastSuccessAt`, `lastError`, `countryBuildAt`, `asnBuildAt`, `etagsJson` (jsonb), `createdAt/updatedAt`.
|
||||||
|
- Миграция: `npm run db:generate` → файл в `backend/drizzle/`.
|
||||||
|
|
||||||
|
### 6. API + контракты
|
||||||
|
- `packages/contracts/src/geoip.ts`: zod-схемы настроек/статуса (все входы — Zod, по правилам проекта).
|
||||||
|
- Новый `backend/src/routes/geoip.ts`, регистрация в `index.ts` с prefix `/api`:
|
||||||
|
- `GET /api/geoip` — настройки + статус (ready, даты сборки, последняя проверка/ошибка);
|
||||||
|
- `PUT /api/geoip` — сохранить настройки, затем `refreshScheduler()`;
|
||||||
|
- `POST /api/geoip/update` — запустить загрузку сейчас (409, если уже идёт; флаг-гард как в коллекторах).
|
||||||
|
|
||||||
|
### 7. Интеграция в пайплайн (geoip-first, RIPE-fallback)
|
||||||
|
- `traffic-flow-engine.ts` (`queueParsedFlows`, ~строка 336): `lookupRipeCached` → `resolveFlowIp`. Логика misses не меняется: при готовом mmdb публичные IP (v4+v6) резолвятся сразу, очередь RIPE пустеет; до скачивания баз — прежнее поведение.
|
||||||
|
- Остальные вызовы `lookupRipeCached` → `resolveFlowIp` (grep: как минимум `traffic-flow-analytics.ts` ~258–271).
|
||||||
|
- `classifyFlowDst`/бренды не трогаем: holder из mmdb (org name) встаёт в существующие `HOLDER_BRANDS`-регулярки как есть.
|
||||||
|
|
||||||
|
### 8. Frontend (по next-shadcn-production / ui-guardian: только переиспользование)
|
||||||
|
- Секция «GeoIP-базы (GeoLite2)» внутри существующей `components/traffic/netflow-settings-panel.tsx`: статус (готово/не скачано, даты сборки Country/ASN, последняя проверка, ошибка), тумблер автообновления, интервал, кнопка «Обновить сейчас» с индикатором. Только уже используемые в панели примитивы (Switch/Button/поля) — никаких новых визуальных паттернов и Card-shell. API-клиент через существующие http-хелперы.
|
||||||
|
|
||||||
|
### 9. Хаускипинг, тесты, проверки
|
||||||
|
- `backend/.gitignore`: `storage/geoip/`.
|
||||||
|
- Тесты `backend/src/services/traffic-flow-geoip.test.ts` + скрипт `test:geoip` (по образцу `test:traffic-flow`): приоритет фасада (geoip hit → RIPE не зовётся; miss → fallback), negative на приватных IP, фильтрация EU/ZZ через `isIsoCountry`, коллектор с мокнутым fetch (304-skip, битый файл → подмены нет, `.prev` сохранён), dims по стране/ASN с засеянным ридером.
|
||||||
|
- Проверки после реализации (обязательно по правилам): типы/сборка бэка (`npm run build -w mikrotik-manager-backend`), типы фронта при правке UI (`npx tsc --noEmit`), `npm run test:geoip` и `test:traffic-flow`; предупреждения не игнорировать.
|
||||||
|
- Коммит: `feat(netflow): <subject по-русски>` — новая пользовательская фича (мгновенные страна/ASN в потоках), по commit-messages-ru.
|
||||||
|
- README: короткий раздел о GeoIP; примечание, что в Docker `storage/geoip` ephemeral без тома — базы перекачаются после пересоздания контейнера (~17 МБ); при желании смонтировать volume.
|
||||||
|
|
||||||
|
## Что это даёт
|
||||||
|
Страна и ASN появляются у потока мгновенно при ingest (включая IPv6), без ограничения скорости RIPE; dims `country`/`asn` в `flow_daily_dims`, аналитика (карта, топы, monthly) становятся полными сразу. Внешняя зависимость от stat.ripe.net остаётся только как fallback до первой загрузки баз.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# IKEv2/IPsec VPN: клиенты, IP-адреса и сертификаты — по образцу WireGuard
|
||||||
|
|
||||||
|
## Архитектура (как у WireGuard)
|
||||||
|
|
||||||
|
Роутер — источник истины: live-чтение через существующий REST-клиент (`mikrotik.ts`), мутации прямыми REST-вызовами, история через `config_revisions` (новая секция `"ipsec"`; `section` — TEXT без CHECK → **SQL-миграция не нужна**). Новых таблиц нет. Объекты, созданные мастером/при создании клиентов, помечаются managed-комментарием `mm-ipsec` (по образцу managed-markers).
|
||||||
|
|
||||||
|
## Модель RouterOS (что создаём/читаем)
|
||||||
|
|
||||||
|
Мастер инициализации сервера (per router): CA-сертификат (`/certificate add` + `sign`, key-usage=key-cert-sign,crl-sign), серверный сертификат (CN=адрес/домен, SAN, tls-server), `/ip/ipsec/peer` (passive, exchange-mode=ike2, certificate=серверный cert), `/ip/ipsec/profile`+`proposal` (aes-256/sha256/modp2048, pfs), `/ip pool` + `/ip/ipsec/mode-config` «VPN» (address-pool, dns), policy-template, и опциональное (default on, чекбокс) managed srcnat masquerade-правило «интернет клиентам».
|
||||||
|
|
||||||
|
Клиент = `/ip/ipsec/identity`: **Сертификат** (`/certificate add` CN=<имя> → `sign` ca=<CA> → `export-certificate type=pkcs12` → файл `.p12` скачивается с роутера → identity match-by=certificate) или **PSK** (auth-method=pre-shared-key, remote-id+secret). IP: «из пула» (общий mode-config) или статический (персональный `mc-<user>` mode-config с `address=x.x.x.x/32`). Онлайн — `/ip/ipsec/active-peers`.
|
||||||
|
|
||||||
|
Клиентские загрузки: `.p12` (cert+key+CA, passphrase) + strongSwan `.sswan` (генерация на бэкенде) + текстовая инструкция. Перекачка `.p12` — повторный export с новой passphrase (ключ остаётся на роутере).
|
||||||
|
|
||||||
|
## Бэкенд
|
||||||
|
|
||||||
|
1. **`mikrotik.ts`**: `downloadFile(name): Promise<Buffer>` — бинарно-безопасный GET `/rest/file/<name>` (с fallback `flash/<name>`, как у `uploadTextFile`; текущий конвейер парсит JSON как utf8 — нужен Buffer-режим); хелперы `signCertificate` (с поллингом готовности, sign небыстрый), `exportCertificatePkcs12(name, passphrase)`.
|
||||||
|
2. **`services/ipsec-config.ts`** (чистые, тестируемые): генератор `.sswan` и инструкции, поиск свободного IP в пуле, naming-конвенции (`ipsec-ca`, `ipsec-server`, `mc-<user>`, `ipsec-user-<name>`).
|
||||||
|
3. **`services/ipsec-ca.ts`**: `ensureCa/ensureServerCert/issueClientCert/exportClientP12` — add → sign (poll) → export → download.
|
||||||
|
4. **`services/ipsec-ros.ts`**: put/patch/delete для `/ip/ipsec/{peer,identity,mode-config,profile,proposal,policy}`, `/ip/pool`, NAT managed-правило.
|
||||||
|
5. **`services/ipsec-live.ts`**: `fetchIpsecState(server)` — параллельные GET peer/identity/mode-config/pool/active-peers/certificate (+какие из них наши по маркеру/имени) → DTO; `listIpsec()` fan-out с failures; `countIpsecClients()` для сайдбара.
|
||||||
|
6. **`entity-snapshots.ts`**: `canonicalIpsecSnapshot/parseIpsecSnapshot/planIpsecRestore` (секреты через `isHiddenSecret`); `CONFIG_SECTIONS` += `"ipsec"`; enum в `schema.ts` (миграции нет).
|
||||||
|
7. **`routes/ipsec.ts`**: `GET /ipsec` (+observed revision), `POST /ipsec/server/init`, `DELETE /ipsec/server/:serverId` (только managed-объекты), `POST /ipsec/users` (ответ включает одноразовый p12 base64), `PATCH /ipsec/users/:serverId/:rosId` (имя/статический IP/psk), `DELETE /ipsec/users/...` (identity + mc + опционально клиентский cert), `POST /ipsec/users/:serverId/:rosId/cert` (перекачка p12), `GET /ipsec/revisions` + `POST /ipsec/revisions/:id/restore` — всё по образцу `routes/wireguard.ts` (`captureAndAppendRevision`). Регистрация в `index.ts`; `/api/ipsec` в обе группы network-правил `permissions.ts`.
|
||||||
|
8. **Модуль Пользователи**: `InterfaceType` += `"ipsec"` (iface-type.ts, schema.ts enum — TEXT, без миграции); каталог интерфейсов дополняется IPsec-клиентами (identity name + CN в peerPublicKey/peerName) — привязка app-пользователя к VPN-клиенту.
|
||||||
|
|
||||||
|
## Контракт
|
||||||
|
|
||||||
|
`packages/contracts/src/ipsec.ts` (+`package.json` exports, `index.ts`): `ipsecPeerDto/identityDto/modeConfigDto/poolDto/activePeerDto/certInfoDto/serverSummaryDto`, list-response с failures, `initRequest` (CN/SAN, пул, DNS, NAT-чекбокс), `userCreateRequest` (имя, auth: certificate|psk, psk?, ip: static|pool, passphrase, daysValid), `certDownloadResponse {filename, contentB64, mime}`.
|
||||||
|
|
||||||
|
## Фронт
|
||||||
|
|
||||||
|
`app/(main)/ipsec/page.tsx` — «одно окно» по образцу WG-страницы: ServerRail + KPI (серверы IKEv2 / клиенты / онлайн / CA) + Tabs **«Клиенты»** (grid: имя, сервер, аутентификация, IP, онлайн, действия — скачать .p12/.sswan, изменить, удалить; Sheet создания клиента), **«Сервер»** (peer/pool/mode-config/certs + мастер инициализации Stepper + статус NAT-правила), **«CLI»** (шпаргалка). История/restore — `ConfigHistorySheet`. Компоненты `components/ipsec/*` на существующих DataGridShell/form-kit/Sheet; скачивание бинарного p12 — Blob из base64 (по образцу `downloadText`), `.sswan`/инструкция — через `CodeExportSheet`. Сайдбар «IPsec / IKEv2» в «Управление» + бейдж (`sidebar-counts.ts` + mock в `sidebar-badges.ts`) + command-palette. Mock-данные в `lib/data.ts` для демо-режима.
|
||||||
|
|
||||||
|
## Тесты и проверка
|
||||||
|
|
||||||
|
- `ipsec-config.test.ts` (чистые функции: sswan, свободный IP, naming); `entity-snapshots.test.ts` — `planIpsecRestore` + `opsTouchOnly(["/ip/ipsec", ...])`.
|
||||||
|
- `npm run build -w @mmapp/contracts`; `tsc` backend/root; `npm --prefix backend run test:config-sync && test:wireguard` + новые; eslint без новых ошибок.
|
||||||
|
- Демо-режим: визуальная проверка страницы в браузере (как в прошлый раз).
|
||||||
|
|
||||||
|
## Риски (проверить на живом роутере при внедрении)
|
||||||
|
|
||||||
|
- REST-скачивание файла `GET /rest/file/<name>` — еслиRouterOS отдаёт только метаданные, fallback: чтение `contents` маленьких PEM-файлов или сборка p12 из PEM-частей.
|
||||||
|
- Точные имена полей mode-config (`address` vs `address-pool`) сверить с живым GET и адаптировать.
|
||||||
|
- `/certificate/sign` — поллинг готовности с таймаутом ~30–60 с.
|
||||||
|
|
||||||
|
Объём большой; коммит/пуш — по готовности, отдельным `feat(ipsec): …`.
|
||||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
|||||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||||
|
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||||
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
|
||||||
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
|
||||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||||
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
|
||||||
|
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
|
||||||
COPY packages/contracts packages/contracts
|
COPY packages/contracts packages/contracts
|
||||||
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||||
COPY app app
|
COPY app app
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
| Компонент | Путь | Стек | Порт (runtime) | Docker-образ |
|
||||||
|-----------|------|------|----------------|--------------|
|
|-----------|------|------|----------------|--------------|
|
||||||
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
| Frontend | корень (`app/`, `components/`, `lib/`, …) | Next.js 16.2.4 (App Router), React 19 | 3000 | `…-frontend` |
|
||||||
| Backend | `backend/` | Fastify 5, TypeScript ESM, SQLite, Drizzle | 8000 | `…-backend` |
|
| Backend | `backend/` | Fastify 5, TypeScript ESM, PostgreSQL 18, Drizzle | 8000 | `…-backend` |
|
||||||
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
| Контракты | `packages/contracts/` | Zod 4, `@mmapp/contracts` | — | встраиваются в frontend/backend |
|
||||||
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
| Updater | `deploy/updater/` | bash, `docker:27-cli`, curl, jq | — | `…-updater` |
|
||||||
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
| Деплой | `deploy/docker-compose.yml` | Docker Compose | — | — |
|
||||||
@@ -43,11 +43,11 @@ flowchart TB
|
|||||||
Browser["Браузер"]
|
Browser["Браузер"]
|
||||||
NextDev["Next.js :3000"]
|
NextDev["Next.js :3000"]
|
||||||
FastifyDev["Fastify :8000"]
|
FastifyDev["Fastify :8000"]
|
||||||
SqliteDev["SQLite файл"]
|
PostgresDev["PostgreSQL 18"]
|
||||||
Contracts["@mmapp/contracts"]
|
Contracts["@mmapp/contracts"]
|
||||||
Browser --> NextDev
|
Browser --> NextDev
|
||||||
Browser -->|"fetch /api, CORS"| FastifyDev
|
Browser -->|"fetch /api, CORS"| FastifyDev
|
||||||
FastifyDev --> SqliteDev
|
FastifyDev --> PostgresDev
|
||||||
Contracts --> NextDev
|
Contracts --> NextDev
|
||||||
Contracts --> FastifyDev
|
Contracts --> FastifyDev
|
||||||
end
|
end
|
||||||
@@ -60,7 +60,8 @@ flowchart TB
|
|||||||
subgraph prod [Прод-сервер]
|
subgraph prod [Прод-сервер]
|
||||||
FE["mmapp-frontend :3000"]
|
FE["mmapp-frontend :3000"]
|
||||||
BE["mmapp-backend :8000"]
|
BE["mmapp-backend :8000"]
|
||||||
DBVol["volume backend-data /app/data"]
|
PG["mmapp-postgres"]
|
||||||
|
DBVol["volume sqlite ETL /app/data"]
|
||||||
UPD["mmapp-updater"]
|
UPD["mmapp-updater"]
|
||||||
Sock["/var/run/docker.sock"]
|
Sock["/var/run/docker.sock"]
|
||||||
Reg --> FE
|
Reg --> FE
|
||||||
@@ -69,6 +70,7 @@ flowchart TB
|
|||||||
UPD --> Sock
|
UPD --> Sock
|
||||||
UPD --> FE
|
UPD --> FE
|
||||||
UPD --> BE
|
UPD --> BE
|
||||||
|
BE --> PG
|
||||||
BE --> DBVol
|
BE --> DBVol
|
||||||
BrowserProd["Клиент"] --> FE
|
BrowserProd["Клиент"] --> FE
|
||||||
BrowserProd -->|"live + backendUrl"| BE
|
BrowserProd -->|"live + backendUrl"| BE
|
||||||
@@ -117,7 +119,7 @@ sequenceDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
- Триггер workflow: `push` в ветку `main`, ручной `workflow_dispatch`.
|
||||||
- Параллельные jobs: `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
- Параллельные jobs: `backend-test` (PostgreSQL 18), `backend-image`, `frontend-image`, `updater-image`; опционально `notify-webhook` после backend и frontend, если задан секрет `DEPLOY_WEBHOOK_URL`.
|
||||||
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
- Теги на каждый успешный push: **`:latest`** и **`:<commit-sha>`**; платформа **linux/amd64**.
|
||||||
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
- На сервере образы подтягиваются вручную (`docker compose pull`) и/или через **updater** (сравнение digest у тега из `targets.json`). Webhook CI **не заменяет** updater.
|
||||||
|
|
||||||
@@ -125,7 +127,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
| Зависимость | Реализация |
|
| Зависимость | Реализация |
|
||||||
|-------------|------------|
|
|-------------|------------|
|
||||||
| SQLite | Не отдельный контейнер. Файл `mikrotik.db` в томе `backend-data` → `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db` в образе backend). |
|
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||||
|
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
|
||||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
@@ -134,7 +137,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся (нужен только для одноразового boot-ETL из `mikrotik.db`).
|
||||||
|
- Локально нужен PostgreSQL 18 (`deploy/docker-compose.postgres.yml` или `DATABASE_URL`).
|
||||||
|
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||||
|
|
||||||
### Запуск
|
### Запуск
|
||||||
|
|
||||||
@@ -160,7 +165,8 @@ Backend — скопировать `backend/.env.example` в `backend/.env`:
|
|||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
| Переменная | По умолчанию | Назначение |
|
||||||
|------------|--------------|------------|
|
|------------|--------------|------------|
|
||||||
| `DATABASE_PATH` | `./mikrotik.db` | путь к файлу SQLite |
|
| `DATABASE_URL` | `postgres://mmapp:[email protected]:5432/mmapp` | подключение к PostgreSQL |
|
||||||
|
| `DATABASE_PATH` | `./mikrotik.db` | SQLite только для boot-ETL, если PG пустой |
|
||||||
| `PORT` | `8000` | порт Fastify |
|
| `PORT` | `8000` | порт Fastify |
|
||||||
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
| `CORS_ORIGIN` | `http://localhost:3000` | origin фронтенда для CORS |
|
||||||
|
|
||||||
@@ -178,14 +184,20 @@ Frontend — в репозитории нет корневого `.env.example`.
|
|||||||
npm run build -w @mmapp/contracts
|
npm run build -w @mmapp/contracts
|
||||||
```
|
```
|
||||||
|
|
||||||
После изменения схем Drizzle:
|
Схема PostgreSQL накатывается при старте backend из [`backend/drizzle/0000_postgresql.sql`](backend/drizzle/0000_postgresql.sql). Опциональный ETL:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix backend run db:generate
|
npm --prefix backend run db:migrate-from-sqlite
|
||||||
npm --prefix backend run db:migrate
|
|
||||||
npm --prefix backend run db:studio
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GeoIP-базы GeoLite2 (страны и ASN для NetFlow)
|
||||||
|
|
||||||
|
Backend держит локальные mmdb-базы MaxMind GeoLite2 (Country + ASN) в `backend/storage/geoip/` и скачивает их с зеркала [P3TERX/GeoLite.mmdb](https://github.com/P3TERX/GeoLite.mmdb) — без регистрации и ключей. Lookup страны/ASN потока при ingest становится мгновенным (включая IPv6) и не упирается в лимиты RIPEstat; пока базы не скачаны или lookup промахнулся, работает прежний RIPE-fallback.
|
||||||
|
|
||||||
|
Управление — секция «GeoIP-базы (GeoLite2)» в настройках NetFlow (страница «Сбор данных»): автообновление (по умолчанию проверка раз в 7 дней, upstream обновляется еженедельно), статус сборки баз и кнопка «Обновить сейчас». Джоба планировщика — `geoip_update`. Атрибуция: данные MaxMind GeoLite2, CC BY-SA 4.0.
|
||||||
|
|
||||||
|
Примечание для Docker: каталог `storage/geoip` внутри контейнера ephemeral — без смонтированного volume базы (~17 МБ) перекачаются после пересоздания контейнера. Каталог переопределяется переменной `GEOIP_DIR`.
|
||||||
|
|
||||||
## CI/CD (Gitea Actions)
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
Файл: `.gitea/workflows/docker.yml` (имя workflow: **Docker images**).
|
||||||
@@ -219,13 +231,69 @@ npm --prefix backend run db:studio
|
|||||||
|
|
||||||
## Прод-развёртывание Docker
|
## Прод-развёртывание Docker
|
||||||
|
|
||||||
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
|
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
|
||||||
|
|
||||||
|
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
|
||||||
|
|
||||||
|
### CDN Manager + MikrotikManager (один Traefik)
|
||||||
|
|
||||||
|
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
|
||||||
|
|
||||||
|
| Файл | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
|
||||||
|
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||||
|
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||||
|
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
|
||||||
|
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||||
|
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
|
||||||
|
docker login git.shx.one
|
||||||
|
cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||||
|
curl -fsS https://cdn.shnt.top/health
|
||||||
|
curl -fsS https://mm.shnt.top/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
|
||||||
|
|
||||||
|
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
|
||||||
|
|
||||||
|
| Способ | Файл |
|
||||||
|
|--------|------|
|
||||||
|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
|
||||||
|
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
|
||||||
|
|
||||||
|
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compose (рекомендуется)
|
||||||
|
mkdir -p /opt/mmapp/{data,state,updater}
|
||||||
|
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||||
|
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
|
||||||
|
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||||
|
cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
|
||||||
|
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
|
||||||
|
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
|
||||||
|
chmod +x /tmp/run-beside-cdn-traefik.sh
|
||||||
|
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
|
||||||
|
|
||||||
|
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
|
||||||
|
|
||||||
|
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
|
||||||
|
|
||||||
### Прод-контейнеры
|
### Прод-контейнеры
|
||||||
|
|
||||||
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
| Сервис | `container_name` | Образ (пример) | Порты host:container | Тома | `restart` |
|
||||||
|--------|------------------|----------------|----------------------|------|-----------|
|
|--------|------------------|----------------|----------------------|------|-----------|
|
||||||
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | `backend-data` → `/app/data` | `unless-stopped` |
|
| postgres | `mmapp-postgres` | `postgres:18-alpine` | нет (внутренняя сеть) | `mmapp-pgdata` | `unless-stopped` |
|
||||||
|
| backend | `mmapp-backend` | `git.shx.one/denozord/mikrotikmanager-backend:latest` | `8000:8000` | sqlite ETL → `/app/data` | `unless-stopped` |
|
||||||
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
| frontend | `mmapp-frontend` | `git.shx.one/denozord/mikrotikmanager-frontend:latest` | `3000:3000` | — | `unless-stopped` |
|
||||||
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
| updater | `mmapp-updater` | `git.shx.one/denozord/mikrotikmanager-updater:latest` | не публикуются | docker.sock, `updater-state` → `/state`, `targets.json` → `/etc/updater/targets.json:ro` | `unless-stopped` |
|
||||||
|
|
||||||
@@ -241,11 +309,14 @@ npm --prefix backend run db:studio
|
|||||||
|
|
||||||
### Переменные окружения (прод)
|
### Переменные окружения (прод)
|
||||||
|
|
||||||
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`, `DATABASE_PATH=/app/data/mikrotik.db`; в compose обычно переопределяют только CORS):
|
**Backend** (в образе заданы `NODE_ENV=production`, `PORT=8000`; в compose задают `DATABASE_URL`, `DATABASE_PATH` для ETL и CORS):
|
||||||
|
|
||||||
| Переменная | Источник в compose | Назначение |
|
| Переменная | Источник в compose | Назначение |
|
||||||
|------------|-------------------|------------|
|
|------------|-------------------|------------|
|
||||||
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
| `CORS_ORIGIN` | `${CORS_ORIGIN:-http://localhost:3000}` | origin UI, с которого браузер вызывает API |
|
||||||
|
| `DATABASE_URL` | `postgres://mmapp:${POSTGRES_PASSWORD}@postgres:5432/mmapp` | PostgreSQL |
|
||||||
|
| `DATABASE_PATH` | `/app/data/mikrotik.db` | SQLite на томе для одноразового ETL |
|
||||||
|
| `POSTGRES_PASSWORD` | обязателен | пароль пользователя `mmapp` |
|
||||||
|
|
||||||
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
**Frontend** (в образе: `PORT=3000`, `HOSTNAME=0.0.0.0`). URL API в образ **не** зашит — задаётся в браузере (режим live + URL бэкенда) вместе с `CORS_ORIGIN` на backend.
|
||||||
|
|
||||||
@@ -256,7 +327,7 @@ npm --prefix backend run db:studio
|
|||||||
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
| `TARGETS_FILE` | `/etc/updater/targets.json` | список целей |
|
||||||
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
| `STATE_FILE` | `/state/updater-state.json` | digest и ошибки |
|
||||||
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
| `POLL_INTERVAL_SECONDS` | `300` | пауза между циклами опроса |
|
||||||
| `HEALTH_TIMEOUT_SECONDS` | `120` | ожидание HTTP health |
|
| `HEALTH_TIMEOUT_SECONDS` | `600` | ожидание HTTP health (первый boot с ETL может занять минуты) |
|
||||||
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
| `STOP_TIMEOUT_SECONDS` | `30` | `docker stop -t` |
|
||||||
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
| `REGISTRY` | `git.shx.one` | registry для `docker login` |
|
||||||
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
| `REGISTRY_USERNAME` | пусто | логин (если заданы оба с паролем) |
|
||||||
@@ -460,18 +531,20 @@ docker compose up -d --force-recreate
|
|||||||
### Откат
|
### Откат
|
||||||
|
|
||||||
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
- **Автоматически:** updater при failed health после обновления — контейнер на `previous_digest`.
|
||||||
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы SQLite** updater не выполняет — нужен отдельный backup тома `backend-data` / файла `mikrotik.db`.
|
- **Вручную:** остановить контейнер, запустить образ с нужным тегом или digest из registry, сохранив те же volume и labels. Откат **схемы PostgreSQL** updater не выполняет — нужен `pg_dump` / restore или том `mmapp-pgdata`.
|
||||||
|
|
||||||
### Резервное копирование SQLite
|
### Резервное копирование PostgreSQL
|
||||||
|
|
||||||
Том `backend-data` (или `mmapp-backend-data` при явном `docker volume create`). Пример остановки backend для консистентной копии:
|
Том `mmapp-pgdata` и (до cleanup) sqlite `mikrotik.db` на `/app/data`. Из UI: Настройки → «Скачать бэкап» (`pg_dump -Fc`). С хоста:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker stop mmapp-backend
|
docker exec mmapp-postgres pg_dump -Fc -U mmapp mmapp > mikrotik-manager.dump
|
||||||
docker run --rm -v backend-data:/data -v $(pwd):/backup alpine tar czf /backup/mikrotik-db-backup.tar.gz -C /data .
|
|
||||||
docker start mmapp-backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Восстановление: UI «Восстановить из файла» или `pg_restore --clean --if-exists -d …`.
|
||||||
|
|
||||||
|
Том sqlite после успешного ETL и smoke **не удаляйте сразу** — оставьте как fallback, затем уберите `mikrotik.db`, когда маркер `data_migration.sqlite_imported_at` подтверждён.
|
||||||
|
|
||||||
### Отладка
|
### Отладка
|
||||||
|
|
||||||
| Действие | Команда |
|
| Действие | Команда |
|
||||||
|
|||||||
@@ -2846,7 +2846,7 @@ export default function AlertsPage() {
|
|||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
|
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
|
||||||
<p>
|
<p>
|
||||||
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
|
Движок читает последние сэмплы из PostgreSQL (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
|
||||||
срабатывает только если цепочка состояний совпадает с текстом правила.
|
срабатывает только если цепочка состояний совпадает с текстом правила.
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-4 space-y-1.5">
|
<ul className="list-disc pl-4 space-y-1.5">
|
||||||
|
|||||||
+342
-605
File diff suppressed because it is too large
Load Diff
+200
-79
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useState, useMemo, useEffect } from "react"
|
import { useState, useMemo, useEffect } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
import { BgpSessionsDataGrid } from "@/components/data-grids/bgp-sessions-data-grid"
|
||||||
@@ -10,6 +10,7 @@ import { BGP_FILTER_FIELDS } from "@/lib/data-filters/bgp-filter-fields"
|
|||||||
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
import type { BgpSessionRow, BgpState, BgpType } from "@/lib/bgp/types"
|
||||||
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
import { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -20,9 +21,13 @@ import {
|
|||||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||||
XIcon, AlertCircleIcon,
|
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -231,6 +236,37 @@ interface BackendBgpSession {
|
|||||||
capabilities: string[]; lastError: string | null
|
capabilities: string[]; lastError: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "online",
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
function backendToFrontend(b: BackendBgpSession): BgpSession {
|
||||||
return {
|
return {
|
||||||
id: `${b.serverId}-${b.id}`,
|
id: `${b.serverId}-${b.id}`,
|
||||||
@@ -462,22 +498,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* summary row */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
aria-label="Сводка префиксов BGP"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
{
|
||||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
id: "rx",
|
||||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
label: "Всего префиксов",
|
||||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
value: fmtNum(totalRx),
|
||||||
].map(s => (
|
icon: <DownloadIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-success",
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
},
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
{
|
||||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
id: "active",
|
||||||
</FramePanel>
|
label: "Активных маршрутов",
|
||||||
</Frame>
|
value: fmtNum(totalActive),
|
||||||
))}
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
</div>
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ebgp",
|
||||||
|
label: "eBGP сессий",
|
||||||
|
value: ebgpSessions,
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ibgp",
|
||||||
|
label: "iBGP сессий",
|
||||||
|
value: ibgpSessions,
|
||||||
|
icon: <ServerIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
|
||||||
{/* prefixes by peer — horizontal bar chart */}
|
{/* prefixes by peer — horizontal bar chart */}
|
||||||
@@ -604,31 +657,40 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
|||||||
|
|
||||||
export default function BgpPage() {
|
export default function BgpPage() {
|
||||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
|
||||||
const [liveError, setLiveError] = useState<string | null>(null)
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
const [fetchTick, setFetchTick] = useState(0)
|
const [fetchTick, setFetchTick] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveSessions([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
void Promise.all([
|
||||||
.then(r => {
|
requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions"),
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
return r.json() as Promise<BackendBgpSession[]>
|
])
|
||||||
})
|
.then(([data, servers]) => {
|
||||||
.then(data => {
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveSessions(data.map(backendToFrontend))
|
setLiveSessions(data.map(backendToFrontend))
|
||||||
|
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
setFetchedAt(new Date())
|
setFetchedAt(new Date())
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
@@ -641,50 +703,87 @@ export default function BgpPage() {
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, backendUrl, fetchTick])
|
}, [isLive, backendUrl, fetchTick])
|
||||||
|
|
||||||
// Use live or mock data for all tabs and KPI
|
const allSessions = isLive ? liveSessions : SESSIONS
|
||||||
const sessions = isLive ? liveSessions : SESSIONS
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
|
const sessions = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return allSessions
|
||||||
|
return allSessions.filter((s) => s.serverId === effectiveServerId)
|
||||||
|
}, [allSessions, effectiveServerId])
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const s of allSessions) {
|
||||||
|
counts.set(s.serverId, (counts.get(s.serverId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
count: counts.get(s.id) ?? 0,
|
||||||
|
enabled: s.enabled,
|
||||||
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [displayServers, allSessions])
|
||||||
|
|
||||||
const established = sessions.filter(s => s.state === "Established").length
|
const established = sessions.filter(s => s.state === "Established").length
|
||||||
const notEstab = sessions.length - established
|
const notEstab = sessions.length - established
|
||||||
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
|
||||||
const serverCount = useMemo(
|
const serverCount = useMemo(
|
||||||
() => new Set(liveSessions.map(s => s.serverId)).size,
|
() => new Set(sessions.map(s => s.serverId)).size,
|
||||||
[liveSessions],
|
[sessions],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={railItems}
|
||||||
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
selectedId={effectiveServerId}
|
||||||
actions={
|
onSelect={setSelectedServerId}
|
||||||
<>
|
showAll
|
||||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
allCount={displayServers.length}
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
Обновить
|
header={
|
||||||
</Button>
|
<PageHeader
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
|
||||||
</>
|
actions={
|
||||||
}
|
<>
|
||||||
/>
|
<ServerRailMobileButton />
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||||
{/* tab bar */}
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
<div className="border-b bg-background shrink-0">
|
Обновить
|
||||||
<div className="flex items-center px-6">
|
</Button>
|
||||||
{TABS.map(t => (
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
</>
|
||||||
className={cn(
|
}
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
/>
|
||||||
activeTab === t.id
|
}
|
||||||
? "border-primary text-foreground"
|
banner={
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
<div className="border-b bg-background shrink-0">
|
||||||
)}>
|
<div className="flex items-center px-6">
|
||||||
{t.icon}{t.label}
|
{TABS.map(t => (
|
||||||
</button>
|
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||||
))}
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
|
activeTab === t.id
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
|
)}>
|
||||||
|
{t.icon}{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* data source banner */}
|
{/* data source banner */}
|
||||||
@@ -714,33 +813,56 @@ export default function BgpPage() {
|
|||||||
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
|
{isLive && !loading && allSessions.length === 0 && !liveError && fetchedAt && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
BGP не настроен ни на одном сервере
|
BGP не настроен ни на одном сервере
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isLive && !loading && allSessions.length > 0 && sessions.length === 0 && !liveError && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
На выбранном сервере нет BGP-сессий
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "mock" && (
|
{mode === "mock" && (
|
||||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
Моковые данные
|
Моковые данные
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI strip */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
aria-label="Сводка BGP"
|
||||||
{[
|
items={[
|
||||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
{
|
||||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
id: "sessions",
|
||||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
label: "Сессий всего",
|
||||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
value: sessions.length,
|
||||||
].map(s => (
|
icon: <GitMergeIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
},
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
{
|
||||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
id: "established",
|
||||||
</FramePanel>
|
label: "Established",
|
||||||
</Frame>
|
value: established,
|
||||||
))}
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
</div>
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "not-estab",
|
||||||
|
label: "Не установлено",
|
||||||
|
value: notEstab,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: notEstab > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: notEstab > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "prefixes",
|
||||||
|
label: "Получено префиксов",
|
||||||
|
value: fmtNum(totalRx),
|
||||||
|
icon: <DownloadIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* alert: not-established sessions */}
|
{/* alert: not-established sessions */}
|
||||||
{notEstab > 0 && (
|
{notEstab > 0 && (
|
||||||
@@ -761,7 +883,6 @@ export default function BgpPage() {
|
|||||||
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
|||||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||||
import type { CertStatus, Server } from "@/lib/data"
|
import type { CertStatus, Server } from "@/lib/data"
|
||||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||||
|
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -119,42 +121,41 @@ function CertPartKpi({
|
|||||||
expired: CertificateDto[]
|
expired: CertificateDto[]
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка сертификатов"
|
||||||
|
items={[
|
||||||
{
|
{
|
||||||
|
id: "all",
|
||||||
label: "Всего",
|
label: "Всего",
|
||||||
value: displayCerts.length,
|
value: displayCerts.length,
|
||||||
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "valid",
|
||||||
label: "Действующих",
|
label: "Действующих",
|
||||||
value: displayCerts.filter((c) => c.status === "valid").length,
|
value: displayCerts.filter((c) => c.status === "valid").length,
|
||||||
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
|
icon: <BadgeCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "expiring",
|
||||||
label: "Истекают",
|
label: "Истекают",
|
||||||
value: expiring.length,
|
value: expiring.length,
|
||||||
icon: <AlertTriangleIcon className="size-4 text-amber-500" />,
|
icon: <AlertTriangleIcon className="size-4" />,
|
||||||
|
iconClassName: expiring.length > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: expiring.length > 0 ? "warning" : "default",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "expired",
|
||||||
label: "Истёкших",
|
label: "Истёкших",
|
||||||
value: expired.length,
|
value: expired.length,
|
||||||
icon: <AlertCircleIcon className="size-4 text-red-500" />,
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: expired.length > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: expired.length > 0 ? "destructive" : "default",
|
||||||
},
|
},
|
||||||
].map((s) => (
|
]}
|
||||||
<Frame key={s.label} className="h-full">
|
/>
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
|
||||||
{s.icon}
|
|
||||||
</IconTile>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ function CertPartReference() {
|
|||||||
return (
|
return (
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="RouterOS 7 · /certificate — справка CLI"
|
title="RouterOS 7 · /certificate — справка CLI"
|
||||||
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
|
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
|
||||||
contentClassName="px-5 py-4"
|
contentClassName="px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
@@ -437,6 +438,8 @@ export default function CertificatesPage() {
|
|||||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||||
"https://acme-v02.api.letsencrypt.org/directory",
|
"https://acme-v02.api.letsencrypt.org/directory",
|
||||||
)
|
)
|
||||||
@@ -451,6 +454,27 @@ export default function CertificatesPage() {
|
|||||||
return routerCertificates.map(mockToDto)
|
return routerCertificates.map(mockToDto)
|
||||||
}, [prefsHydrated, isLive, certificates])
|
}, [prefsHydrated, isLive, certificates])
|
||||||
|
|
||||||
|
const displayServers = isLive ? serverList : mockServers
|
||||||
|
|
||||||
|
const scopedCerts = useMemo(() => {
|
||||||
|
if (selectedServerId === ALL_SERVERS_ID) return displayCerts
|
||||||
|
return displayCerts.filter((c) => c.serverId === selectedServerId)
|
||||||
|
}, [displayCerts, selectedServerId])
|
||||||
|
|
||||||
|
const certRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: String(displayCerts.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayCerts])
|
||||||
|
|
||||||
const serverById = useMemo(() => {
|
const serverById = useMemo(() => {
|
||||||
const map = new Map<string, Server>()
|
const map = new Map<string, Server>()
|
||||||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||||
@@ -515,13 +539,13 @@ export default function CertificatesPage() {
|
|||||||
}, [isLive, loadLive, loadAcmeSettings])
|
}, [isLive, loadLive, loadAcmeSettings])
|
||||||
|
|
||||||
const expiring = useMemo(
|
const expiring = useMemo(
|
||||||
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||||
[displayCerts],
|
[scopedCerts],
|
||||||
)
|
)
|
||||||
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
|
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return displayCerts.filter((c) => {
|
return scopedCerts.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
@@ -532,7 +556,7 @@ export default function CertificatesPage() {
|
|||||||
c.sans.some((s) => s.includes(q))
|
c.sans.some((s) => s.includes(q))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [displayCerts, search, statusFilter])
|
}, [scopedCerts, search, statusFilter])
|
||||||
|
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
if (!liveReady) return
|
if (!liveReady) return
|
||||||
@@ -625,35 +649,52 @@ export default function CertificatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
items={certRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button
|
showAll
|
||||||
variant="outline"
|
allCount={displayServers.length}
|
||||||
size="sm"
|
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||||
disabled={!liveReady || loadState === "loading"}
|
header={
|
||||||
onClick={() => {
|
<PageHeader
|
||||||
void handleRefresh()
|
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||||
}}
|
actions={
|
||||||
>
|
<>
|
||||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
<ServerRailMobileButton />
|
||||||
Обновить
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
size="sm"
|
||||||
<UploadIcon className="size-4" />
|
disabled={!liveReady || loadState === "loading"}
|
||||||
Импорт
|
onClick={() => {
|
||||||
</Button>
|
void handleRefresh()
|
||||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
}}
|
||||||
<PlusIcon className="size-4" />
|
>
|
||||||
Выпустить сертификат
|
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||||
</Button>
|
Обновить
|
||||||
</>
|
</Button>
|
||||||
}
|
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||||
/>
|
<UploadIcon className="size-4" />
|
||||||
|
Импорт
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!liveReady || issueBusy}
|
||||||
|
onClick={() => {
|
||||||
|
setIssueStep(1)
|
||||||
|
if (selectedServerId !== ALL_SERVERS_ID) setIssueServerId(selectedServerId)
|
||||||
|
setIssueOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Выпустить сертификат
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{isLive && backendStatus === false && (
|
{isLive && backendStatus === false && (
|
||||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
|
||||||
@@ -673,7 +714,9 @@ export default function CertificatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
|
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||||
|
|
||||||
|
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
|
||||||
|
|
||||||
{liveReady && (
|
{liveReady && (
|
||||||
<CertPartAcmeSettings
|
<CertPartAcmeSettings
|
||||||
@@ -720,7 +763,7 @@ export default function CertificatesPage() {
|
|||||||
|
|
||||||
<CertPartReference />
|
<CertPartReference />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
<Sheet open={issueOpen} onOpenChange={(v) => { setIssueOpen(v); if (!v) setIssueStep(1) }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
@@ -753,7 +796,7 @@ export default function CertificatesPage() {
|
|||||||
<StepperContent key={s} value={s}>
|
<StepperContent key={s} value={s}>
|
||||||
<CertPartIssueForm
|
<CertPartIssueForm
|
||||||
step={s as 1 | 2 | 3 | 4}
|
step={s as 1 | 2 | 3 | 4}
|
||||||
serverList={serverList}
|
serverList={displayServers}
|
||||||
issueServerId={issueServerId}
|
issueServerId={issueServerId}
|
||||||
setIssueServerId={setIssueServerId}
|
setIssueServerId={setIssueServerId}
|
||||||
issueCertName={issueCertName}
|
issueCertName={issueCertName}
|
||||||
@@ -812,6 +855,6 @@ export default function CertificatesPage() {
|
|||||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ACTION_COLOR,
|
ACTION_COLOR,
|
||||||
} from "@/components/data-grids/communities-data-grid"
|
} from "@/components/data-grids/communities-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── summary ── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка communities"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего communities", value: String(listData.length) },
|
{
|
||||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
id: "all",
|
||||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
label: "Всего communities",
|
||||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
value: String(listData.length),
|
||||||
].map(({ label, value }) => (
|
icon: <TagIcon className="size-4" />,
|
||||||
<Frame key={label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
},
|
||||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
{
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{value}</p>
|
id: "enabled",
|
||||||
</FramePanel>
|
label: "Активных",
|
||||||
</Frame>
|
value: String(listData.filter((c) => c.enabled).length),
|
||||||
))}
|
icon: <CheckIcon className="size-4" />,
|
||||||
</div>
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "standard",
|
||||||
|
label: "Стандартных",
|
||||||
|
value: String(listData.filter((c) => c.type === "standard").length),
|
||||||
|
icon: <TagIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
label: "Использует фильтры",
|
||||||
|
value: String(new Set(listData.flatMap((c) => c.filterIds)).size),
|
||||||
|
icon: <FilterIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-[1fr_320px] gap-5">
|
<div className="grid grid-cols-[1fr_320px] gap-5">
|
||||||
{/* ── main table ── */}
|
{/* ── main table ── */}
|
||||||
|
|||||||
+303
-140
@@ -1,15 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { routerContainers, servers } from "@/lib/data"
|
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||||
import type { RouterContainer } from "@/lib/data"
|
import type { RouterContainer, Server } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
DropdownMenuItem, DropdownMenuSeparator,
|
DropdownMenuItem, DropdownMenuSeparator,
|
||||||
@@ -17,18 +19,48 @@ import {
|
|||||||
import {
|
import {
|
||||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||||
TerminalIcon, AlertCircleIcon,
|
TerminalIcon, AlertCircleIcon, RefreshCwIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
import { useDataSource } from "@/lib/data-source"
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
} from "@/components/ui/sheet"
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
function serverFor(id: string) {
|
interface ContainersApiResponse {
|
||||||
return servers.find((s) => s.id === id)
|
containers: RouterContainer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "online",
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusConfig(status: RouterContainer["status"]) {
|
function statusConfig(status: RouterContainer["status"]) {
|
||||||
@@ -51,10 +83,8 @@ function statusConfig(status: RouterContainer["status"]) {
|
|||||||
}[status]
|
}[status]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSC generator ────────────────────────────────────────────────────────────
|
function generateContainerRsc(c: RouterContainer, serverById: Record<string, Server>): string {
|
||||||
|
const srv = serverById[c.serverId]
|
||||||
function generateContainerRsc(c: RouterContainer): string {
|
|
||||||
const srv = serverFor(c.serverId)
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# RouterOS Container — ${c.name}`)
|
lines.push(`# RouterOS Container — ${c.name}`)
|
||||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||||
@@ -62,13 +92,11 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
lines.push(`# RouterOS 7.4+ · /container`)
|
lines.push(`# RouterOS 7.4+ · /container`)
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
// interface
|
|
||||||
for (const iface of c.interfaces) {
|
for (const iface of c.interfaces) {
|
||||||
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
|
||||||
}
|
}
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
// envs
|
|
||||||
if (c.envs.length > 0) {
|
if (c.envs.length > 0) {
|
||||||
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
lines.push(`/container/envs/add name=${c.name}-envs \\`)
|
||||||
for (const { key, value } of c.envs) {
|
for (const { key, value } of c.envs) {
|
||||||
@@ -77,7 +105,6 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mounts
|
|
||||||
for (const m of c.mounts) {
|
for (const m of c.mounts) {
|
||||||
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
|
||||||
if (m.src) lines.push(` src=${m.src} \\`)
|
if (m.src) lines.push(` src=${m.src} \\`)
|
||||||
@@ -85,7 +112,6 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
// container
|
|
||||||
lines.push(`/container/add \\`)
|
lines.push(`/container/add \\`)
|
||||||
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
lines.push(` remote-image=${c.image}:${c.tag} \\`)
|
||||||
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
|
||||||
@@ -99,76 +125,60 @@ function generateContainerRsc(c: RouterContainer): string {
|
|||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
function ExportSheet({
|
||||||
|
open, container, onClose, serverById,
|
||||||
function ExportSheet({ open, container, onClose }: {
|
}: {
|
||||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
open: boolean
|
||||||
|
container: RouterContainer | null
|
||||||
|
onClose: () => void
|
||||||
|
serverById: Record<string, Server>
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
const code = useMemo(
|
||||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
() => (container ? generateContainerRsc(container, serverById) : ""),
|
||||||
|
[container, serverById],
|
||||||
function handleCopy() {
|
)
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт Container"
|
||||||
<div>
|
description="RouterOS 7.4+ · /container · /interface/veth"
|
||||||
<SheetTitle>Экспорт Container</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${container?.name ?? "container"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Container card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ContainerCard({
|
function ContainerCard({
|
||||||
container,
|
container,
|
||||||
|
server,
|
||||||
|
live,
|
||||||
|
busy,
|
||||||
onExport,
|
onExport,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onRestart,
|
||||||
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
container: RouterContainer
|
container: RouterContainer
|
||||||
|
server?: Server
|
||||||
|
live: boolean
|
||||||
|
busy: boolean
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
|
onStart: () => void
|
||||||
|
onStop: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const srv = serverFor(container.serverId)
|
|
||||||
const cfg = statusConfig(container.status)
|
const cfg = statusConfig(container.status)
|
||||||
|
const canMutate = live && Boolean(container.rosId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense className="w-full overflow-hidden">
|
<Frame dense className="w-full overflow-hidden">
|
||||||
@@ -183,29 +193,36 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger render={
|
<DropdownMenuTrigger render={
|
||||||
<Button variant="ghost" size="icon" className="size-7 shrink-0">
|
<Button variant="ghost" size="icon" className="size-7 shrink-0" disabled={busy}>
|
||||||
<MoreHorizontalIcon className="size-4" />
|
<MoreHorizontalIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
} />
|
} />
|
||||||
<DropdownMenuContent side="bottom" align="end">
|
<DropdownMenuContent side="bottom" align="end">
|
||||||
{container.status === "running" ? (
|
{container.status === "running" ? (
|
||||||
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStop}>
|
||||||
|
<StopCircleIcon className="size-4 text-amber-500" />Остановить
|
||||||
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate} onClick={onStart}>
|
||||||
|
<PlayIcon className="size-4 text-emerald-500" />Запустить
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
<DropdownMenuItem disabled><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
|
||||||
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
<DropdownMenuItem disabled><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
|
<DropdownMenuItem disabled={!canMutate} onClick={onRestart}>
|
||||||
|
<PowerIcon className="size-4" />Перезапустить
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
|
<DropdownMenuItem variant="destructive" disabled={!canMutate} onClick={onRemove}>
|
||||||
|
<Trash2Icon className="size-4" />Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 flex flex-col gap-3">
|
<div className="px-4 py-3 flex flex-col gap-3">
|
||||||
{/* image */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
<span className="font-mono text-xs text-foreground/80">
|
<span className="font-mono text-xs text-foreground/80">
|
||||||
@@ -213,17 +230,15 @@ function ContainerCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* server */}
|
{server && (
|
||||||
{srv && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<ServerIcon className="size-3.5 shrink-0" />
|
<ServerIcon className="size-3.5 shrink-0" />
|
||||||
<Flag code={srv.country} size={12} />
|
<Flag code={server.country} size={12} />
|
||||||
<span className="font-mono">{srv.name}</span>
|
<span className="font-mono">{server.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* uptime + stats */}
|
{container.status === "running" && (container.uptime || container.cpu !== undefined || container.memMb !== undefined) && (
|
||||||
{container.status === "running" && (
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
|
||||||
{container.uptime && (
|
{container.uptime && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -249,7 +264,6 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* interfaces */}
|
|
||||||
{container.interfaces.length > 0 && (
|
{container.interfaces.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{container.interfaces.map((i) => (
|
{container.interfaces.map((i) => (
|
||||||
@@ -260,7 +274,6 @@ function ContainerCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* mounts */}
|
|
||||||
{container.mounts.length > 0 && (
|
{container.mounts.length > 0 && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{container.mounts.map((m, idx) => (
|
{container.mounts.map((m, idx) => (
|
||||||
@@ -282,66 +295,211 @@ function ContainerCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
export default function ContainersPage() {
|
export default function ContainersPage() {
|
||||||
const [search, setSearch] = useState("")
|
const { mode, backendUrl } = useDataSource()
|
||||||
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
|
||||||
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
|
const [liveContainers, setLiveContainers] = useState<RouterContainer[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
setLoading(true)
|
||||||
|
setLiveError(null)
|
||||||
|
try {
|
||||||
|
const [cRes, sRes] = await Promise.all([
|
||||||
|
requestJson<ContainersApiResponse>(backendUrl, "/api/containers"),
|
||||||
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
setLiveContainers(cRes.containers ?? [])
|
||||||
|
setLiveServers(sRes.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
} catch (e) {
|
||||||
|
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
|
setLiveContainers([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveContainers([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayContainers = isLive ? liveContainers : mockContainers
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
|
const scoped = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||||
|
return displayContainers.filter((c) => c.serverId === effectiveServerId)
|
||||||
|
}, [displayContainers, effectiveServerId])
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: String(displayContainers.filter((c) => c.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayContainers])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return routerContainers.filter((c) => {
|
return scoped.filter((c) => {
|
||||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return (
|
return (
|
||||||
c.name.toLowerCase().includes(q) ||
|
c.name.toLowerCase().includes(q) ||
|
||||||
c.image.toLowerCase().includes(q) ||
|
c.image.toLowerCase().includes(q) ||
|
||||||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
|
(serverById[c.serverId]?.name.toLowerCase().includes(q) ?? false)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [search, statusFilter])
|
}, [search, statusFilter, scoped, serverById])
|
||||||
|
|
||||||
const running = routerContainers.filter((c) => c.status === "running").length
|
const running = scoped.filter((c) => c.status === "running").length
|
||||||
const stopped = routerContainers.filter((c) => c.status === "stopped").length
|
const stopped = scoped.filter((c) => c.status === "stopped").length
|
||||||
const errors = routerContainers.filter((c) => c.status === "error").length
|
const errors = scoped.filter((c) => c.status === "error").length
|
||||||
|
|
||||||
|
async function mutate(c: RouterContainer, action: "start" | "stop" | "restart" | "remove") {
|
||||||
|
if (!isLive || !c.rosId) {
|
||||||
|
toast.info("Действие доступно только в live-режиме")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === "remove" && !window.confirm(`Удалить контейнер ${c.name}?`)) return
|
||||||
|
setBusyId(c.id)
|
||||||
|
try {
|
||||||
|
await requestJson(backendUrl, `/api/servers/${c.serverId}/containers/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rosId: c.rosId }),
|
||||||
|
})
|
||||||
|
const labels = { start: "запущен", stop: "остановлен", restart: "перезапущен", remove: "удалён" }
|
||||||
|
toast.success(`${c.name}: ${labels[action]}`)
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Ошибка RouterOS")
|
||||||
|
} finally {
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<Button size="sm">
|
onSelect={setSelectedServerId}
|
||||||
<BoxIcon className="size-4" />Новый контейнер
|
showAll
|
||||||
</Button>
|
allCount={displayServers.length}
|
||||||
}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
/>
|
header={
|
||||||
|
<PageHeader
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm">
|
||||||
|
<BoxIcon className="size-4" />Новый контейнер
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* KPI */}
|
{isLive && liveError && (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<Alert variant="warning" className="py-2">
|
||||||
{[
|
<AlertCircleIcon />
|
||||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
</Alert>
|
||||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
)}
|
||||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
{isLive && !loading && displayContainers.length === 0 && !liveError && (
|
||||||
].map((s) => (
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
<Frame key={s.label} className="h-full">
|
Контейнеры не найдены. Нужен пакет container (RouterOS 7.4+).
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
</div>
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
)}
|
||||||
{s.icon}
|
{mode === "mock" && (
|
||||||
</IconTile>
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
Моковые данные
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
</span>
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
)}
|
||||||
</div>
|
|
||||||
</FramePanel>
|
<KpiStatGrid
|
||||||
</Frame>
|
aria-label="Сводка контейнеров"
|
||||||
))}
|
items={[
|
||||||
</div>
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Всего",
|
||||||
|
value: scoped.length,
|
||||||
|
icon: <BoxIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "running",
|
||||||
|
label: "Running",
|
||||||
|
value: running,
|
||||||
|
icon: <PlayIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stopped",
|
||||||
|
label: "Stopped",
|
||||||
|
value: stopped,
|
||||||
|
icon: <StopCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "errors",
|
||||||
|
label: "Ошибок",
|
||||||
|
value: errors,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-destructive",
|
||||||
|
variant: errors > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Info banner */}
|
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
|
||||||
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -353,7 +511,6 @@ export default function ContainersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
|
||||||
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
@@ -383,7 +540,6 @@ export default function ContainersPage() {
|
|||||||
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
|
||||||
<BoxIcon className="size-10 mb-3 opacity-20" />
|
<BoxIcon className="size-10 mb-3 opacity-20" />
|
||||||
@@ -396,13 +552,19 @@ export default function ContainersPage() {
|
|||||||
<ContainerCard
|
<ContainerCard
|
||||||
key={c.id}
|
key={c.id}
|
||||||
container={c}
|
container={c}
|
||||||
|
server={serverById[c.serverId]}
|
||||||
|
live={isLive}
|
||||||
|
busy={busyId === c.id}
|
||||||
onExport={() => setExportContainer(c)}
|
onExport={() => setExportContainer(c)}
|
||||||
|
onStart={() => { void mutate(c, "start") }}
|
||||||
|
onStop={() => { void mutate(c, "stop") }}
|
||||||
|
onRestart={() => { void mutate(c, "restart") }}
|
||||||
|
onRemove={() => { void mutate(c, "remove") }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RouterOS reference */}
|
|
||||||
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
<OpsPanel title="RouterOS 7.4+ · /container — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
@@ -462,13 +624,14 @@ export default function ContainersPage() {
|
|||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
<ExportSheet
|
||||||
open={!!exportContainer}
|
open={!!exportContainer}
|
||||||
container={exportContainer}
|
container={exportContainer}
|
||||||
onClose={() => setExportContainer(null)}
|
onClose={() => setExportContainer(null)}
|
||||||
|
serverById={serverById}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+278
-910
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,9 @@ import Link from "next/link"
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/reui/badge"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
type InternetPathRunSnapshot,
|
type InternetPathRunSnapshot,
|
||||||
type CertificatesRenewRunSnapshot,
|
type CertificatesRenewRunSnapshot,
|
||||||
type BackupsRunSnapshot,
|
type BackupsRunSnapshot,
|
||||||
|
type GeoipUpdateRunSnapshot,
|
||||||
type PingRunSnapshot,
|
type PingRunSnapshot,
|
||||||
type ResourcesRunSnapshot,
|
type ResourcesRunSnapshot,
|
||||||
type SchedulerRunSnapshot,
|
type SchedulerRunSnapshot,
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
type SchedulerJobGridRow,
|
type SchedulerJobGridRow,
|
||||||
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
} from "@/components/data-grids/data-collection-scheduler-data-grid"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
AlertCircleIcon,
|
AlertCircleIcon,
|
||||||
@@ -104,7 +105,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "traffic") {
|
if (snap.job === "traffic") {
|
||||||
const t = snap as TrafficRunSnapshot
|
const t = snap as TrafficRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{t.skipped ? (
|
{t.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -126,7 +127,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_resources") {
|
if (snap.job === "uptime_resources") {
|
||||||
const u = snap as ResourcesRunSnapshot
|
const u = snap as ResourcesRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{u.skipped ? (
|
{u.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -148,7 +149,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "servers_rest_ping") {
|
if (snap.job === "servers_rest_ping") {
|
||||||
const s = snap as ServersRestPingRunSnapshot
|
const s = snap as ServersRestPingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{s.skipped ? (
|
{s.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -171,7 +172,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_ping") {
|
if (snap.job === "uptime_ping") {
|
||||||
const p = snap as PingRunSnapshot
|
const p = snap as PingRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{p.skipped ? (
|
{p.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -196,7 +197,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "uptime_speed") {
|
if (snap.job === "uptime_speed") {
|
||||||
const s = snap as SpeedScheduledRunSnapshot
|
const s = snap as SpeedScheduledRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||||
</p>
|
</p>
|
||||||
@@ -209,7 +210,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "gre_bgp") {
|
if (snap.job === "gre_bgp") {
|
||||||
const g = snap as GreBgpSnapshotRunSnapshot
|
const g = snap as GreBgpSnapshotRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{g.skipped ? (
|
{g.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -220,7 +221,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Запись в SQLite на{" "}
|
Запись в PostgreSQL на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
{" "}— строки GRE и BGP для движка оповещений.
|
{" "}— строки GRE и BGP для движка оповещений.
|
||||||
</p>
|
</p>
|
||||||
@@ -235,7 +236,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{g.errors?.length ? (
|
{g.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{g.errors.map((e, i) => (
|
{g.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -249,7 +250,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "internet_path") {
|
if (snap.job === "internet_path") {
|
||||||
const p = snap as InternetPathRunSnapshot
|
const p = snap as InternetPathRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок internet-path на{" "}
|
Снимок internet-path на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
@@ -276,7 +277,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "certificates_renew") {
|
if (snap.job === "certificates_renew") {
|
||||||
const c = snap as CertificatesRenewRunSnapshot
|
const c = snap as CertificatesRenewRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
{c.skipped ? (
|
{c.skipped ? (
|
||||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -299,7 +300,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{c.errors.length ? (
|
{c.errors.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{c.errors.map((e, i) => (
|
{c.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -311,7 +312,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
if (snap.job === "backups") {
|
if (snap.job === "backups") {
|
||||||
const b = snap as BackupsRunSnapshot
|
const b = snap as BackupsRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||||
@@ -331,7 +332,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{b.errors?.length ? (
|
{b.errors?.length ? (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
{b.errors.map((e, i) => (
|
{b.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">{e}</p>
|
<p key={i} className="break-words">{e}</p>
|
||||||
))}
|
))}
|
||||||
@@ -340,14 +341,49 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (snap.job === "geoip_update") {
|
||||||
|
const g = snap as GeoipUpdateRunSnapshot
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{g.skipped ? (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: обновление уже выполнялось или задача отключена.</p>
|
||||||
|
) : null}
|
||||||
|
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Баз проверено</dt>
|
||||||
|
<dd className="font-mono font-medium">{g.checked}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Скачано</dt>
|
||||||
|
<dd className="font-mono font-medium">{g.downloaded}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Без изменений</dt>
|
||||||
|
<dd className="font-mono font-medium">{g.skippedUnchanged}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-muted-foreground">Объём, МБ</dt>
|
||||||
|
<dd className="font-mono font-medium">{(g.bytes / 1024 / 1024).toFixed(1)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{g.errors.length ? (
|
||||||
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
|
||||||
|
{g.errors.map((e, i) => (
|
||||||
|
<p key={i} className="break-words">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (snap.job === "alert_engine") {
|
if (snap.job === "alert_engine") {
|
||||||
const a = snap as AlertEngineRunSnapshot
|
const a = snap as AlertEngineRunSnapshot
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Снимок на{" "}
|
Снимок на{" "}
|
||||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||||
. Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
|
. Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
|
||||||
</p>
|
</p>
|
||||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||||
<div>
|
<div>
|
||||||
@@ -370,7 +406,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
{a.errors?.length ? (
|
{a.errors?.length ? (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
<AlertCircleIcon />
|
<AlertCircleIcon />
|
||||||
<AlertDescription className="space-y-1 text-xs">
|
<AlertDescription className="flex flex-col gap-1 text-xs">
|
||||||
{a.errors.map((e, i) => (
|
{a.errors.map((e, i) => (
|
||||||
<p key={i} className="break-words">
|
<p key={i} className="break-words">
|
||||||
{e}
|
{e}
|
||||||
@@ -380,7 +416,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
{a.ruleDiag && a.ruleDiag.length > 0 ? (
|
||||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 space-y-2">
|
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
|
||||||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||||
@@ -398,39 +434,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
|
||||||
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
||||||
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 space-y-1">
|
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span className="font-medium">{jobTitle}</span>
|
<span className="font-medium">{jobTitle}</span>
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
|
||||||
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
|
||||||
<dd className="tabular-nums">
|
<dd className="tabular-nums">
|
||||||
<span className="font-mono">{r.durationMs}</span> мс
|
<span className="font-mono">{r.durationMs}</span> мс
|
||||||
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -447,7 +483,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
{r.error ? (
|
{r.error ? (
|
||||||
<div className="space-y-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
|
||||||
<pre
|
<pre
|
||||||
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
|
||||||
@@ -461,7 +497,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||||||
{snapshot ? (
|
{snapshot ? (
|
||||||
<SnapshotTables snap={snapshot} />
|
<SnapshotTables snap={snapshot} />
|
||||||
@@ -967,13 +1003,13 @@ export default function DataCollectionPage() {
|
|||||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||||
? "По сохранённым задачам планировщика"
|
? "По сохранённым задачам планировщика"
|
||||||
: "По переключателям на этой странице",
|
: "По переключателям на этой странице",
|
||||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
icon: <CalendarClockIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Сейчас выполняется",
|
label: "Сейчас выполняется",
|
||||||
value: String(runningJobsCount),
|
value: String(runningJobsCount),
|
||||||
sub: "Фоновые прогоны планировщика",
|
sub: "Фоновые прогоны планировщика",
|
||||||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
icon: <LoaderCircleIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Трафик — последний сбор",
|
label: "Трафик — последний сбор",
|
||||||
@@ -982,16 +1018,16 @@ export default function DataCollectionPage() {
|
|||||||
: "—",
|
: "—",
|
||||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||||
icon: trafficCollector?.lastError ? (
|
icon: trafficCollector?.lastError ? (
|
||||||
<XCircleIcon className="size-4 text-destructive" />
|
<XCircleIcon className="size-4" />
|
||||||
) : (
|
) : (
|
||||||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
<CheckCircleIcon className="size-4" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Журнал (в списке)",
|
label: "Журнал (в списке)",
|
||||||
value: String(schedulerRuns.length),
|
value: String(schedulerRuns.length),
|
||||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||||||
icon: <DatabaseIcon className="size-4 text-sky-500" />,
|
icon: <DatabaseIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -1064,22 +1100,32 @@ export default function DataCollectionPage() {
|
|||||||
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<KpiStatGrid
|
||||||
{stats.map((s) => (
|
aria-label="Сводка сбора данных"
|
||||||
<Frame key={s.label} className="h-full">
|
items={stats.map((s, i) => ({
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
id: `dc-${i}`,
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
label: s.label,
|
||||||
{s.icon}
|
value: s.value,
|
||||||
</IconTile>
|
hint: s.sub,
|
||||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
|
icon: s.icon,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
iconClassName:
|
||||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
? "text-destructive"
|
||||||
</div>
|
: s.label === "Сейчас выполняется" && runningJobsCount > 0
|
||||||
</FramePanel>
|
? "text-warning"
|
||||||
</Frame>
|
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||||
))}
|
? "text-destructive"
|
||||||
</div>
|
: s.label === "Трафик — последний сбор"
|
||||||
|
? "text-success"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
variant:
|
||||||
|
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||||
|
? "destructive" as const
|
||||||
|
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||||
|
? "warning" as const
|
||||||
|
: "default" as const,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<div className="border-b border-border px-5 py-4">
|
<div className="border-b border-border px-5 py-4">
|
||||||
@@ -1090,7 +1136,7 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3 px-5 py-4">
|
<div className="flex flex-col gap-3 px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||||
@@ -1117,7 +1163,11 @@ export default function DataCollectionPage() {
|
|||||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
disabled={!draftCertRenewEnabled}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
|
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||||
@@ -1142,7 +1192,7 @@ export default function DataCollectionPage() {
|
|||||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||||
Сохранить настройки планировщика
|
Сохранить настройки планировщика
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60">
|
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
|
||||||
<p>
|
<p>
|
||||||
Трафик — последний сбор:{" "}
|
Трафик — последний сбор:{" "}
|
||||||
{trafficCollector?.lastCollectedAt
|
{trafficCollector?.lastCollectedAt
|
||||||
@@ -1201,9 +1251,11 @@ export default function DataCollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
|
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
|
||||||
|
|
||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Журнал прогонов"
|
title="Журнал прогонов"
|
||||||
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
description="Таблица `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
|
||||||
headerRight={
|
headerRight={
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
|
||||||
|
|||||||
+300
-339
@@ -27,7 +27,7 @@ import {
|
|||||||
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
|
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
|
||||||
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
|
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
|
||||||
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
|
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
|
||||||
CheckCircle2Icon, XCircleIcon, CircleDashedIcon, RefreshCwIcon,
|
RefreshCwIcon, HistoryIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||||
@@ -35,11 +35,14 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
|
||||||
|
|
||||||
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
||||||
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
||||||
|
|
||||||
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
tunnelsList: GreTunnel[]
|
tunnelsList: GreTunnel[]
|
||||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const singleRuleset = useMemo(
|
const singleRuleset = useMemo(
|
||||||
() => rulesets.filter(r => r.serverId === serverId),
|
() => rulesets.filter(r => r.serverId === serverId),
|
||||||
[rulesets, serverId],
|
[rulesets, serverId],
|
||||||
)
|
)
|
||||||
const server = serversList.find(s => s.id === serverId)
|
const server = serversList.find(s => s.id === serverId)
|
||||||
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
const totalRules = singleRuleset.reduce((s, r) => s + r.rules.length, 0)
|
||||||
|
|
||||||
const config = useMemo(
|
const config = useMemo(
|
||||||
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
|||||||
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
navigator.clipboard.writeText(config).catch(() => {})
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
if (!open) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<CodeExportSheet
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
open={open}
|
||||||
<div className="relative z-10 w-full max-w-2xl mx-4 bg-card rounded-xl border shadow-2xl flex flex-col max-h-[85vh]">
|
onClose={onClose}
|
||||||
|
title="RouterOS config"
|
||||||
{/* header */}
|
description={
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
|
server
|
||||||
<div className="flex items-center gap-2.5">
|
? `${server.name} · ${totalRules} правил`
|
||||||
<FileCodeIcon className="size-4 text-muted-foreground" />
|
: "Экспорт правил фильтрации"
|
||||||
<span className="text-sm font-semibold">RouterOS config</span>
|
}
|
||||||
{server && (
|
formats={[
|
||||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
{
|
||||||
<Flag code={server.country} size={11} />
|
id: "rsc",
|
||||||
<span className="font-mono">{server.name}</span>
|
label: "RouterOS",
|
||||||
<span>· {totalRules} правил</span>
|
filename: `filters-${server?.name ?? "server"}.rsc`,
|
||||||
</span>
|
code: config,
|
||||||
)}
|
},
|
||||||
</div>
|
]}
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onClose}><XIcon className="size-4" /></Button>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
|
||||||
<pre className="text-xs font-mono bg-[#0d1117] rounded-lg p-4 leading-[1.6] whitespace-pre overflow-x-auto">
|
|
||||||
{config.split("\n").map((line, i) => {
|
|
||||||
const trimmed = line.trimStart()
|
|
||||||
const cls =
|
|
||||||
// section dividers
|
|
||||||
trimmed.startsWith("# ═") || trimmed.startsWith("# ─")
|
|
||||||
? "text-[#444c56]" :
|
|
||||||
// inline comments inside rule body
|
|
||||||
trimmed.startsWith("# ")
|
|
||||||
? "text-[#8b949e]" :
|
|
||||||
// RouterOS scripting keywords
|
|
||||||
trimmed.startsWith(":local") || trimmed.startsWith(":log") || trimmed.startsWith(":foreach")
|
|
||||||
? "text-[#d2a8ff]" :
|
|
||||||
// :if identity branch / closing brace
|
|
||||||
trimmed.startsWith(":if") || (trimmed === "}" && line.length < 3)
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// filter rule add command
|
|
||||||
trimmed.startsWith("/routing filter rule")
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// rule body: if/else if branches
|
|
||||||
trimmed.startsWith("if (") || trimmed.startsWith("} else if")
|
|
||||||
? "text-[#ff7b72]" :
|
|
||||||
// rule body: blackhole action
|
|
||||||
trimmed.startsWith("set type blackhole")
|
|
||||||
? "text-[#ff7b72] font-semibold" :
|
|
||||||
// rule body: set actions
|
|
||||||
trimmed.startsWith("set gw") || trimmed.startsWith("set gateway") || trimmed.startsWith("set out-interface")
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
// rule body: accept / rule close
|
|
||||||
trimmed.startsWith("accept") || trimmed === `}"` || trimmed.startsWith(`rule="`)
|
|
||||||
? "text-[#79c0ff]" :
|
|
||||||
// named params
|
|
||||||
trimmed.match(/^(chain|comment|bgp-communities)=/)
|
|
||||||
? "text-[#a5d6ff]" :
|
|
||||||
"text-[#c9d1d9]"
|
|
||||||
return <span key={i} className={cn("block", cls)}>{line || " "}</span>
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* footer */}
|
|
||||||
<div className="px-5 py-4 border-t bg-muted/30 shrink-0 space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<p>1. Скопируйте скрипт в буфер обмена</p>
|
|
||||||
<p>3. Вставьте в терминал и нажмите Enter</p>
|
|
||||||
<p>2. Подключитесь к любому MikroTik (SSH / Winbox)</p>
|
|
||||||
<p>4. Скрипт сам определит свои правила по identity</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={onClose} className="flex-1">Закрыть</Button>
|
|
||||||
<Button onClick={handleCopy} className="flex-1">
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано!" : "Скопировать"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1306,6 +1239,9 @@ interface BackendServer {
|
|||||||
interface LiveFiltersResponse {
|
interface LiveFiltersResponse {
|
||||||
rulesets: ServerFilterRuleset[]
|
rulesets: ServerFilterRuleset[]
|
||||||
greTunnels: GreTunnel[]
|
greTunnels: GreTunnel[]
|
||||||
|
live?: boolean
|
||||||
|
stale?: boolean
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
|
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
|
||||||
@@ -1392,18 +1328,14 @@ export default function FiltersPage() {
|
|||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
const [copyOpen, setCopyOpen] = useState(false)
|
const [copyOpen, setCopyOpen] = useState(false)
|
||||||
const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null)
|
const [applyBusy, setApplyBusy] = useState(false)
|
||||||
const [routerCompare, setRouterCompare] = useState<{
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
serverId: string
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
byCommunity: Record<string, FilterRouterSyncStatus>
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
} | null>(null)
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [routerCompareLoading, setRouterCompareLoading] = useState(false)
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRouterCompare(null)
|
|
||||||
}, [selectedServerId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
@@ -1414,6 +1346,7 @@ export default function FiltersPage() {
|
|||||||
setRulesets(buildRulesets(servers, serverFilterRulesets))
|
setRulesets(buildRulesets(servers, serverFilterRulesets))
|
||||||
setSelectedServerId(servers[0]?.id ?? "")
|
setSelectedServerId(servers[0]?.id ?? "")
|
||||||
setLiveLoadState("idle")
|
setLiveLoadState("idle")
|
||||||
|
setLiveStale(false)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1457,39 +1390,68 @@ export default function FiltersPage() {
|
|||||||
})
|
})
|
||||||
}, [isLive, apiFetch])
|
}, [isLive, apiFetch])
|
||||||
|
|
||||||
|
const loadLiveRules = useCallback(async (serverId: string) => {
|
||||||
|
if (!isLive || !serverId) return
|
||||||
|
try {
|
||||||
|
const fresh = await apiFetch<LiveFiltersResponse>(
|
||||||
|
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
|
||||||
|
)
|
||||||
|
const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? []
|
||||||
|
setRulesets((prev) => {
|
||||||
|
const has = prev.some((rs) => rs.serverId === serverId)
|
||||||
|
if (!has) return [...prev, { serverId, rules: liveRules }]
|
||||||
|
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
|
||||||
|
})
|
||||||
|
setLiveStale(Boolean(fresh.stale))
|
||||||
|
if (fresh.greTunnels?.length) {
|
||||||
|
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setLiveStale(true)
|
||||||
|
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
|
||||||
|
}
|
||||||
|
}, [isLive, apiFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId || liveLoadState !== "idle") return
|
||||||
|
if (!liveServers.some((s) => s.id === selectedServerId)) return
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
|
loadLiveRules(selectedServerId),
|
||||||
ensureGreTunnels(selectedServerId),
|
ensureGreTunnels(selectedServerId),
|
||||||
ensureRecursiveRoutes(selectedServerId),
|
ensureRecursiveRoutes(selectedServerId),
|
||||||
])
|
])
|
||||||
}, [isLive, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes])
|
}, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
|
||||||
|
|
||||||
const allServers = isLive ? liveServers : servers
|
const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
|
||||||
const allTunnels = isLive ? (greByServer[selectedServerId] ?? []) : greTunnels
|
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
|
||||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||||
|
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error")
|
||||||
|
|
||||||
|
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
allServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: String(rulesets.find((r) => r.serverId === s.id)?.rules.length ?? 0),
|
||||||
|
}))
|
||||||
|
), [allServers, rulesets])
|
||||||
|
|
||||||
|
const handleSelectServer = useCallback((id: string) => {
|
||||||
|
setSelectedServerId(id)
|
||||||
|
setSearch("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
const currentRules = useMemo(
|
const currentRules = useMemo(
|
||||||
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
||||||
[rulesets, selectedServerId],
|
[rulesets, selectedServerId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchRouterCompare = useCallback(async () => {
|
|
||||||
if (!isLive || !selectedServerId) return
|
|
||||||
setRouterCompareLoading(true)
|
|
||||||
try {
|
|
||||||
const d = await apiFetch<{ byCommunity: Record<string, FilterRouterSyncStatus> }>(
|
|
||||||
`/api/filters/router-compare?serverId=${encodeURIComponent(selectedServerId)}`,
|
|
||||||
)
|
|
||||||
setRouterCompare({ serverId: selectedServerId, byCommunity: d.byCommunity })
|
|
||||||
} catch {
|
|
||||||
setRouterCompare(null)
|
|
||||||
} finally {
|
|
||||||
setRouterCompareLoading(false)
|
|
||||||
}
|
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
|
||||||
|
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
if (!q) return currentRules
|
if (!q) return currentRules
|
||||||
@@ -1501,68 +1463,94 @@ export default function FiltersPage() {
|
|||||||
)
|
)
|
||||||
}, [currentRules, search, communityNameMap])
|
}, [currentRules, search, communityNameMap])
|
||||||
|
|
||||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
const applyRules = useCallback(async (
|
||||||
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
|
serverId: string,
|
||||||
setRulesets(prev => {
|
nextRules: FilterRule[],
|
||||||
const next = prev.map(rs =>
|
source: "apply" | "copy" = "apply",
|
||||||
rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs
|
) => {
|
||||||
)
|
const prev = rulesets
|
||||||
if (isLive) {
|
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
|
||||||
void apiFetch<{ ok: boolean }>("/api/filters/rules", {
|
if (!isLive) return
|
||||||
method: "PUT",
|
if (liveStale) {
|
||||||
body: JSON.stringify({ rulesets: next }),
|
setRulesets(prev)
|
||||||
}).catch(() => {})
|
toast.error("Роутер недоступен — изменения заблокированы")
|
||||||
}
|
return
|
||||||
return next
|
}
|
||||||
})
|
setApplyBusy(true)
|
||||||
}, [isLive, apiFetch])
|
|
||||||
|
|
||||||
const syncFromRouter = useCallback(async () => {
|
|
||||||
if (!isLive || syncBusy) return
|
|
||||||
setSyncBusy("from")
|
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
|
||||||
method: "POST",
|
method: "PUT",
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
body: JSON.stringify({ serverId, rules: nextRules, source }),
|
||||||
})
|
})
|
||||||
const fresh = await apiFetch<LiveFiltersResponse>("/api/filters/rules")
|
if (res.rules) {
|
||||||
setRulesets(buildRulesets(allServers, fresh.rulesets))
|
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
|
||||||
|
}
|
||||||
|
toast.success("Правила применены на роутер")
|
||||||
|
} catch (err) {
|
||||||
|
setRulesets(prev)
|
||||||
|
toast.error("Не удалось применить правила на роутер", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setApplyBusy(false)
|
||||||
|
}
|
||||||
|
}, [isLive, apiFetch, rulesets, liveStale])
|
||||||
|
|
||||||
|
const refreshFromRouter = useCallback(async () => {
|
||||||
|
if (!isLive || !selectedServerId || applyBusy) return
|
||||||
|
setApplyBusy(true)
|
||||||
|
try {
|
||||||
|
await loadLiveRules(selectedServerId)
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
ensureGreTunnels(selectedServerId),
|
ensureGreTunnels(selectedServerId),
|
||||||
ensureRecursiveRoutes(selectedServerId),
|
ensureRecursiveRoutes(selectedServerId),
|
||||||
])
|
])
|
||||||
await fetchRouterCompare()
|
|
||||||
} finally {
|
} finally {
|
||||||
setSyncBusy(null)
|
setApplyBusy(false)
|
||||||
}
|
}
|
||||||
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
|
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
|
||||||
|
|
||||||
const syncToRouter = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || syncBusy || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
setSyncBusy("to")
|
setHistoryLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{
|
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||||
ok: boolean
|
`/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
updatedServers: number
|
)
|
||||||
pushedRules: number
|
setRevisions(res.revisions)
|
||||||
errors?: Array<{ serverId: number; error: string }>
|
|
||||||
}>("/api/filters/sync/to-router", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
toast.success(`Загружено правил на роутер: ${res.pushedRules}`)
|
|
||||||
} else {
|
|
||||||
const detail = res.errors?.[0]?.error ?? "неизвестная ошибка"
|
|
||||||
toast.error("Не удалось загрузить правила на роутер", { description: detail })
|
|
||||||
}
|
|
||||||
await fetchRouterCompare()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Не удалось загрузить правила на роутер", { description: String(err) })
|
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||||
|
setRevisions([])
|
||||||
} finally {
|
} finally {
|
||||||
setSyncBusy(null)
|
setHistoryLoading(false)
|
||||||
}
|
}
|
||||||
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !selectedServerId) return
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
|
||||||
|
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||||
|
)
|
||||||
|
if (res.rules) {
|
||||||
|
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
|
||||||
|
} else {
|
||||||
|
await loadLiveRules(selectedServerId)
|
||||||
|
}
|
||||||
|
setLiveStale(false)
|
||||||
|
toast.success("Версия применена на роутер")
|
||||||
|
await loadRevisions()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось откатить", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setHistoryRestoring(false)
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions])
|
||||||
|
|
||||||
|
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||||
|
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
|
||||||
|
void applyRules(serverId, updater(current))
|
||||||
|
}, [rulesets, applyRules])
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
|
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
|
||||||
@@ -1580,6 +1568,7 @@ export default function FiltersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = (form: RuleForm) => {
|
const handleSave = (form: RuleForm) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
const { gatewayKind: _gk, ...payload } = form
|
const { gatewayKind: _gk, ...payload } = form
|
||||||
if (sheetMode === "create") {
|
if (sheetMode === "create") {
|
||||||
updateRules(selectedServerId, rules => [
|
updateRules(selectedServerId, rules => [
|
||||||
@@ -1597,34 +1586,30 @@ export default function FiltersPage() {
|
|||||||
setSheetOpen(false)
|
setSheetOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: string) => updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
|
const handleDelete = (id: string) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
|
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
|
||||||
updateRules(targetServerId, existing =>
|
const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
|
||||||
mode === "replace" ? rules : [...existing, ...rules]
|
const next = mode === "replace" ? rules : [...existing, ...rules]
|
||||||
)
|
void applyRules(targetServerId, next, "copy")
|
||||||
}, [updateRules])
|
}, [rulesets, applyRules])
|
||||||
const handleMoveUp = (index: number) => {
|
const handleMoveUp = (index: number) => {
|
||||||
if (index === 0) return
|
if (mutationsLocked || index === 0) return
|
||||||
updateRules(selectedServerId, rules => {
|
updateRules(selectedServerId, rules => {
|
||||||
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
|
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleMoveDown = (index: number) => {
|
const handleMoveDown = (index: number) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
updateRules(selectedServerId, rules => {
|
updateRules(selectedServerId, rules => {
|
||||||
if (index >= rules.length - 1) return rules
|
if (index >= rules.length - 1) return rules
|
||||||
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
|
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedServer) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
||||||
Нет доступных серверов
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLive && liveLoadState === "loading") {
|
if (isLive && liveLoadState === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||||
@@ -1634,143 +1619,122 @@ export default function FiltersPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedServer) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Нет доступных серверов
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
items={filterRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={handleSelectServer}
|
||||||
{isLive && (
|
showAll={false}
|
||||||
<>
|
header={
|
||||||
<Button
|
<PageHeader
|
||||||
variant="outline"
|
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||||
size="sm"
|
actions={
|
||||||
onClick={syncFromRouter}
|
<>
|
||||||
disabled={syncBusy !== null}
|
<ServerRailMobileButton />
|
||||||
title="Синхронизация Router → БД"
|
{isLive && (
|
||||||
>
|
<>
|
||||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
variant="outline"
|
onClick={() => void refreshFromRouter()}
|
||||||
size="sm"
|
disabled={applyBusy}
|
||||||
onClick={syncToRouter}
|
title="Прочитать актуальные правила с роутера"
|
||||||
disabled={syncBusy !== null}
|
>
|
||||||
title="Синхронизация БД → Router"
|
<RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
|
||||||
>
|
Обновить
|
||||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => {
|
||||||
onClick={() => void fetchRouterCompare()}
|
setHistoryOpen(true)
|
||||||
disabled={syncBusy !== null || routerCompareLoading}
|
void loadRevisions()
|
||||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
}}
|
||||||
className="gap-1.5"
|
disabled={applyBusy}
|
||||||
>
|
title="История версий и откат на CHR"
|
||||||
{routerCompareLoading ? (
|
>
|
||||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
<HistoryIcon className="size-4" />
|
||||||
) : (
|
История
|
||||||
<RefreshCwIcon className="size-4" />
|
</Button>
|
||||||
)}
|
</>
|
||||||
Сверить
|
)}
|
||||||
</Button>
|
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||||
</>
|
<FileCodeIcon className="size-4" />RouterOS
|
||||||
)}
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
<Button
|
||||||
<FileCodeIcon className="size-4" />RouterOS
|
variant="outline" size="sm"
|
||||||
</Button>
|
onClick={() => setCopyOpen(true)}
|
||||||
<Button
|
disabled={currentRules.length === 0 || mutationsLocked}
|
||||||
variant="outline" size="sm"
|
title="Копировать правила на другой сервер"
|
||||||
onClick={() => setCopyOpen(true)}
|
>
|
||||||
disabled={currentRules.length === 0}
|
<CopyIcon className="size-4" />Копировать
|
||||||
title="Копировать правила на другой сервер"
|
</Button>
|
||||||
>
|
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||||
<CopyIcon className="size-4" />Копировать
|
<PlusIcon className="size-4" />Новое правило
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={openCreate}>
|
</>
|
||||||
<PlusIcon className="size-4" />Новое правило
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isLive && liveLoadState === "error" && (
|
|
||||||
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
|
||||||
<AlertCircleIcon className="size-3.5 shrink-0" />
|
|
||||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── summary bar + server chips (same pattern as monitoring) ── */}
|
|
||||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
|
||||||
<span className="text-muted-foreground">Всего правил</span>
|
|
||||||
<span className="font-semibold tabular-nums">{totalRules}</span>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
|
||||||
if (bhTotal === 0) return null
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
|
||||||
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
|
||||||
⊘ {bhTotal} blackhole
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
|
||||||
|
|
||||||
{allServers.map(s => {
|
|
||||||
const count = rulesets.find(r => r.serverId === s.id)?.rules.length ?? 0
|
|
||||||
const active = selectedServerId === s.id
|
|
||||||
return (
|
|
||||||
<button key={s.id}
|
|
||||||
onClick={() => { setSelectedServerId(s.id); setSearch("") }}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
|
||||||
active
|
|
||||||
? "bg-foreground text-background border-foreground"
|
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
|
||||||
!s.enabled && !active && "opacity-40",
|
|
||||||
)}>
|
|
||||||
<StatusDot status={s.status} />
|
|
||||||
<Flag code={s.country} size={12} />
|
|
||||||
<span className="font-mono">{s.name}</span>
|
|
||||||
<TypeChip type={s.type} />
|
|
||||||
<span className={cn(
|
|
||||||
"tabular-nums font-semibold",
|
|
||||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
|
||||||
)}>{count}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── toolbar ── */}
|
|
||||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
|
||||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
|
||||||
value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
{search && (
|
|
||||||
<button onClick={() => setSearch("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
|
||||||
<XIcon className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground ml-auto">
|
|
||||||
{filteredRules.length !== currentRules.length
|
|
||||||
? `${filteredRules.length} из ${currentRules.length} правил`
|
|
||||||
: `${currentRules.length} правил`
|
|
||||||
}
|
}
|
||||||
</p>
|
/>
|
||||||
</div>
|
}
|
||||||
|
banner={
|
||||||
{/* ── main content ── */}
|
<>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
{isLive && liveLoadState === "error" && (
|
||||||
|
<div className="shrink-0 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-xs text-destructive flex items-center gap-2">
|
||||||
|
<AlertCircleIcon className="size-3.5 shrink-0" />
|
||||||
|
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLive && liveStale && liveLoadState !== "error" && (
|
||||||
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||||
|
Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||||
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input className="pl-8 h-8 text-sm" placeholder="Community, gateway, описание…"
|
||||||
|
value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-muted-foreground">Всего правил</span>
|
||||||
|
<span className="font-semibold tabular-nums">{totalRules}</span>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const bhTotal = rulesets.reduce((s, r) => s + r.rules.filter(x => x.action === "blackhole").length, 0)
|
||||||
|
if (bhTotal === 0) return null
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded border
|
||||||
|
bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20">
|
||||||
|
⊘ {bhTotal} blackhole
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
|
{filteredRules.length !== currentRules.length
|
||||||
|
? `${filteredRules.length} из ${currentRules.length} правил`
|
||||||
|
: `${currentRules.length} правил`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
||||||
@@ -1818,15 +1782,7 @@ export default function FiltersPage() {
|
|||||||
)}>{selectedServer.latency}мс</span>
|
)}>{selectedServer.latency}мс</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end">
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end">
|
||||||
{isLive && routerCompare?.serverId === selectedServerId && currentRules.length > 0 && (
|
{applyBusy && <span>Применение на роутер…</span>}
|
||||||
<span className="font-mono tabular-nums">
|
|
||||||
роутер:{" "}
|
|
||||||
<span className="text-emerald-600 dark:text-emerald-500">
|
|
||||||
{Object.values(routerCompare.byCommunity).filter(s => s === "synced").length}
|
|
||||||
</span>
|
|
||||||
/{currentRules.length} совпало
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{currentRules.length} правил</span>
|
<span>{currentRules.length} правил</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1863,12 +1819,6 @@ export default function FiltersPage() {
|
|||||||
serversList={allServers}
|
serversList={allServers}
|
||||||
communityNameMap={communityNameMap}
|
communityNameMap={communityNameMap}
|
||||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||||
routerSyncByCommunity={
|
|
||||||
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
|
|
||||||
? null
|
|
||||||
: routerCompare.byCommunity
|
|
||||||
}
|
|
||||||
isLive={isLive}
|
|
||||||
enableSorting={!!search}
|
enableSorting={!!search}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
@@ -1878,7 +1828,7 @@ export default function FiltersPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* add rule shortcut */}
|
{/* add rule shortcut */}
|
||||||
<button onClick={openCreate}
|
<button onClick={openCreate} disabled={mutationsLocked}
|
||||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить правило для {selectedServer.name}
|
Добавить правило для {selectedServer.name}
|
||||||
@@ -1886,7 +1836,7 @@ export default function FiltersPage() {
|
|||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RuleSheet
|
<RuleSheet
|
||||||
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
||||||
@@ -1927,6 +1877,17 @@ export default function FiltersPage() {
|
|||||||
recRoutesByServer={recRoutesByServer}
|
recRoutesByServer={recRoutesByServer}
|
||||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={setHistoryOpen}
|
||||||
|
title="История фильтров"
|
||||||
|
itemLabel="правил"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+988
-210
File diff suppressed because it is too large
Load Diff
+435
-198
@@ -13,11 +13,23 @@ import { useDataSource } from "@/lib/data-source"
|
|||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
import {
|
import {
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
SheetDescription, SheetFooter, SheetClose,
|
||||||
@@ -26,14 +38,16 @@ import {
|
|||||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Flag } from "@/components/flag"
|
|
||||||
import {
|
import {
|
||||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
|
||||||
DatabaseIcon,
|
DatabaseIcon, HistoryIcon, TriangleAlertIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── label maps ─────────────────────────────────────────────────────────────
|
// ─── label maps ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -54,6 +68,10 @@ const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
|
|||||||
down: { label: "Down", dot: "bg-red-500" },
|
down: { label: "Down", dot: "bg-red-500" },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function greStatusMeta(status: GreStatus | undefined) {
|
||||||
|
return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded
|
||||||
|
}
|
||||||
|
|
||||||
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
// ─── RouterOS code generator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>): string {
|
||||||
@@ -89,10 +107,10 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
|||||||
lines.push(` address=${t.localInnerIp} \\`)
|
lines.push(` address=${t.localInnerIp} \\`)
|
||||||
lines.push(` interface=${t.name}`)
|
lines.push(` interface=${t.name}`)
|
||||||
|
|
||||||
// IPsec manual equivalent
|
// IPsec: live CHR имеет только ipsec-secret; proposal — у моков/формы
|
||||||
if (t.ipsec) {
|
if (t.ipsec?.encAlg && t.ipsec.authAlg) {
|
||||||
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
|
const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2"
|
||||||
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
|
const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "none"
|
||||||
|
|
||||||
lines.push("")
|
lines.push("")
|
||||||
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
|
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
|
||||||
@@ -109,13 +127,16 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
|||||||
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
|
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
|
||||||
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
|
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
|
||||||
lines.push(` pfs-group=${pfsGroup} \\`)
|
lines.push(` pfs-group=${pfsGroup} \\`)
|
||||||
lines.push(` lifetime=${t.ipsec.lifetime}`)
|
lines.push(` lifetime=${t.ipsec.lifetime ?? "1d"}`)
|
||||||
lines.push("")
|
lines.push("")
|
||||||
lines.push(`/ip ipsec policy add \\`)
|
lines.push(`/ip ipsec policy add \\`)
|
||||||
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
|
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
|
||||||
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
|
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
|
||||||
lines.push(` proposal=${t.name} \\`)
|
lines.push(` proposal=${t.name} \\`)
|
||||||
lines.push(` tunnel=yes`)
|
lines.push(` tunnel=yes`)
|
||||||
|
} else if (t.ipsec) {
|
||||||
|
lines.push("")
|
||||||
|
lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
@@ -124,7 +145,7 @@ function generateRosCommands(t: GreTunnel, serverById: Record<string, Server>):
|
|||||||
// ─── small ui helpers ────────────────────────────────────────────────────────
|
// ─── small ui helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TunnelStatus({ status }: { status: GreStatus }) {
|
function TunnelStatus({ status }: { status: GreStatus }) {
|
||||||
const s = STATUS_MAP[status]
|
const s = greStatusMeta(status)
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||||
<span className={`size-1.5 rounded-full ${s.dot}`} />
|
<span className={`size-1.5 rounded-full ${s.dot}`} />
|
||||||
@@ -162,6 +183,7 @@ interface BackendServer {
|
|||||||
|
|
||||||
interface GreTunnelsApiResponse {
|
interface GreTunnelsApiResponse {
|
||||||
tunnels: GreTunnel[]
|
tunnels: GreTunnel[]
|
||||||
|
failures?: Array<{ serverId: string; serverName?: string; error: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApiFetch(backendUrl: string) {
|
function makeApiFetch(backendUrl: string) {
|
||||||
@@ -235,16 +257,24 @@ export default function GrePage() {
|
|||||||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||||
const [dataLoading, setDataLoading] = useState(false)
|
const [dataLoading, setDataLoading] = useState(false)
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
|
||||||
|
|
||||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||||
|
const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create")
|
||||||
|
const [editingTunnel, setEditingTunnel] = useState<GreTunnel | null>(null)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
|
||||||
|
const [mutateBusy, setMutateBusy] = useState(false)
|
||||||
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
const [poolOpen, setPoolOpen] = useState(false)
|
const [poolOpen, setPoolOpen] = useState(false)
|
||||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||||
@@ -259,14 +289,19 @@ export default function GrePage() {
|
|||||||
try {
|
try {
|
||||||
const [backendServers, greRes] = await Promise.all([
|
const [backendServers, greRes] = await Promise.all([
|
||||||
apiFetch<BackendServer[]>("/api/servers"),
|
apiFetch<BackendServer[]>("/api/servers"),
|
||||||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
apiFetch<GreTunnelsApiResponse>("/api/gre/tunnels"),
|
||||||
])
|
])
|
||||||
setLiveServers(backendServers.map(mapBackendToServer))
|
setLiveServers(backendServers.map(mapBackendToServer))
|
||||||
setLiveTunnels(greRes.tunnels)
|
setLiveTunnels(greRes.tunnels)
|
||||||
|
setLiveStale(false)
|
||||||
|
if (greRes.failures?.length) {
|
||||||
|
toast.warning(
|
||||||
|
`Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
setDataError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
setLiveServers([])
|
setLiveStale(true)
|
||||||
setLiveTunnels([])
|
|
||||||
} finally {
|
} finally {
|
||||||
setDataLoading(false)
|
setDataLoading(false)
|
||||||
}
|
}
|
||||||
@@ -278,6 +313,7 @@ export default function GrePage() {
|
|||||||
setLiveServers([])
|
setLiveServers([])
|
||||||
setLiveTunnels([])
|
setLiveTunnels([])
|
||||||
setDataError(null)
|
setDataError(null)
|
||||||
|
setLiveStale(false)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -293,6 +329,25 @@ export default function GrePage() {
|
|||||||
[isLive, displayTunnels],
|
[isLive, displayTunnels],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const greRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayTunnels])
|
||||||
|
|
||||||
|
const scopedTunnels = useMemo(() => {
|
||||||
|
if (selectedServerId === ALL_SERVERS_ID) return displayTunnels
|
||||||
|
return displayTunnels.filter((t) => t.serverId === selectedServerId)
|
||||||
|
}, [displayTunnels, selectedServerId])
|
||||||
|
|
||||||
const serverById = useMemo(
|
const serverById = useMemo(
|
||||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
[displayServers],
|
[displayServers],
|
||||||
@@ -302,46 +357,193 @@ export default function GrePage() {
|
|||||||
[displayPools],
|
[displayPools],
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncJhToDb = useCallback(async () => {
|
const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId
|
||||||
if (!isLive || syncJhBusy) return
|
const mutationsLocked = isLive && (mutateBusy || liveStale || historyRestoring)
|
||||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
|
||||||
if (jh.length === 0) {
|
const loadRevisions = useCallback(async () => {
|
||||||
toast.info("Нет включённых Jump Host в списке серверов")
|
if (!isLive || !historyServerId) return
|
||||||
|
setHistoryLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||||
|
`/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
|
)
|
||||||
|
setRevisions(res.revisions)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||||
|
setRevisions([])
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !historyServerId) return
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
await apiFetch(
|
||||||
|
`/api/gre/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ serverId: historyServerId }) },
|
||||||
|
)
|
||||||
|
toast.success("Версия применена на роутер")
|
||||||
|
await loadLive()
|
||||||
|
await loadRevisions()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось откатить", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setHistoryRestoring(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, apiFetch, loadLive, loadRevisions])
|
||||||
|
|
||||||
|
function tunnelWriteBody(form: typeof defaultTunnelForm) {
|
||||||
|
return {
|
||||||
|
serverId: form.serverId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
localAddress: form.localAddress.trim() || undefined,
|
||||||
|
remoteAddress: form.remoteAddress.trim(),
|
||||||
|
localInnerIp: form.localInnerIp.trim() || undefined,
|
||||||
|
remoteInnerIp: form.remoteInnerIp.trim() || undefined,
|
||||||
|
comment: form.comment || undefined,
|
||||||
|
enabled: form.enabled,
|
||||||
|
mtu: form.mtu,
|
||||||
|
keepaliveInterval: form.keepaliveInterval,
|
||||||
|
keepaliveRetries: form.keepaliveRetries,
|
||||||
|
dscp: form.dscp,
|
||||||
|
clampTcpMss: form.clampTcpMss,
|
||||||
|
allowFastPath: form.allowFastPath,
|
||||||
|
ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTunnel() {
|
||||||
|
if (!isLive) {
|
||||||
|
toast.info("Создание на роутер доступно только в live-режиме")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSyncJhBusy(true)
|
if (liveStale) {
|
||||||
const errors: string[] = []
|
toast.error("Роутер недоступен — изменения заблокированы")
|
||||||
try {
|
return
|
||||||
for (const s of jh) {
|
|
||||||
try {
|
|
||||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ serverId: s.id }),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
|
||||||
setLiveTunnels(fresh.tunnels)
|
|
||||||
if (errors.length) {
|
|
||||||
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
|
||||||
} else {
|
|
||||||
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
|
||||||
} finally {
|
|
||||||
setSyncJhBusy(false)
|
|
||||||
}
|
}
|
||||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) {
|
||||||
|
toast.error("Заполните имя, сервер и удалённый адрес")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) {
|
||||||
|
toast.error("Для IPsec нужен PSK не короче 8 символов")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMutateBusy(true)
|
||||||
|
try {
|
||||||
|
if (tunnelMode === "edit" && editingTunnel) {
|
||||||
|
await apiFetch("/api/gre/tunnels", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
...tunnelWriteBody(tForm),
|
||||||
|
rosId: editingTunnel.id,
|
||||||
|
name: editingTunnel.name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
toast.success(`Туннель ${tForm.name} обновлён`)
|
||||||
|
} else {
|
||||||
|
await apiFetch("/api/gre/tunnels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(tunnelWriteBody(tForm)),
|
||||||
|
})
|
||||||
|
toast.success(`Туннель ${tForm.name} создан`)
|
||||||
|
}
|
||||||
|
setTunnelOpen(false)
|
||||||
|
setEditingTunnel(null)
|
||||||
|
await loadLive()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось сохранить туннель", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setMutateBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTunnel(t: GreTunnel) {
|
||||||
|
if (!isLive || mutationsLocked) return
|
||||||
|
setMutateBusy(true)
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/gre/tunnels", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: t.serverId,
|
||||||
|
rosId: t.id,
|
||||||
|
name: t.name,
|
||||||
|
enabled: !t.enabled,
|
||||||
|
remoteAddress: t.remoteAddress,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
|
||||||
|
await loadLive()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось изменить туннель", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setMutateBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteTunnel() {
|
||||||
|
const t = pendingDelete
|
||||||
|
if (!t || !isLive) return
|
||||||
|
setMutateBusy(true)
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/gre/tunnels", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }),
|
||||||
|
})
|
||||||
|
toast.success(`Удалён ${t.name}`)
|
||||||
|
setPendingDelete(null)
|
||||||
|
await loadLive()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось удалить туннель", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setMutateBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateTunnel() {
|
||||||
|
setTunnelMode("create")
|
||||||
|
setEditingTunnel(null)
|
||||||
|
setTForm({
|
||||||
|
...defaultTunnelForm,
|
||||||
|
serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId,
|
||||||
|
})
|
||||||
|
setTunnelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditTunnel(t: GreTunnel) {
|
||||||
|
setTunnelMode("edit")
|
||||||
|
setEditingTunnel(t)
|
||||||
|
setTForm({
|
||||||
|
...defaultTunnelForm,
|
||||||
|
name: t.name,
|
||||||
|
serverId: t.serverId,
|
||||||
|
localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress,
|
||||||
|
remoteAddress: t.remoteAddress,
|
||||||
|
poolId: t.poolId === "live" ? "" : t.poolId,
|
||||||
|
localInnerIp: t.localInnerIp,
|
||||||
|
remoteInnerIp: t.remoteInnerIp,
|
||||||
|
comment: t.comment,
|
||||||
|
enabled: t.enabled,
|
||||||
|
ipsecEnabled: !!t.ipsec,
|
||||||
|
ipsecSecret: t.ipsec?.secret ?? "",
|
||||||
|
mtu: t.mtu,
|
||||||
|
keepaliveInterval: t.keepaliveInterval,
|
||||||
|
keepaliveRetries: t.keepaliveRetries,
|
||||||
|
dscp: String(t.dscp),
|
||||||
|
clampTcpMss: t.clampTcpMss,
|
||||||
|
allowFastPath: t.allowFastPath,
|
||||||
|
})
|
||||||
|
setTunnelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataError) toast.error(dataError)
|
if (dataError) toast.error(dataError)
|
||||||
}, [dataError])
|
}, [dataError])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return displayTunnels.filter((t) => {
|
return scopedTunnels.filter((t) => {
|
||||||
if (tabFilter === "up" && t.status !== "up") return false
|
if (tabFilter === "up" && t.status !== "up") return false
|
||||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||||
if (tabFilter === "plain" && t.ipsec) return false
|
if (tabFilter === "plain" && t.ipsec) return false
|
||||||
@@ -354,60 +556,79 @@ export default function GrePage() {
|
|||||||
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
serverById[t.serverId]?.name.toLowerCase().includes(q)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [tabFilter, search, displayTunnels, serverById])
|
}, [tabFilter, search, scopedTunnels, serverById])
|
||||||
|
|
||||||
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
const upCount = displayTunnels.filter((t) => t.status === "up").length
|
||||||
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
const ipsecCount = displayTunnels.filter((t) => t.ipsec).length
|
||||||
|
const scopedUpCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
|
const scopedIpsecCount = scopedTunnels.filter((t) => t.ipsec).length
|
||||||
|
|
||||||
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
|
||||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||||
{ value: "up", label: "Активные", count: upCount },
|
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||||
]
|
]
|
||||||
|
|
||||||
function handleCopy(code: string) {
|
const greExportCode = useMemo(
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||||
setCopied(true)
|
[codePreviewTunnel, serverById],
|
||||||
toast.success("Команды скопированы")
|
)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
items={greRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button
|
showAll
|
||||||
variant="outline"
|
allCount={displayServers.length}
|
||||||
size="sm"
|
loading={isLive && dataLoading && displayServers.length === 0}
|
||||||
onClick={() => { void loadLive() }}
|
banner={
|
||||||
disabled={!isLive || dataLoading}
|
isLive && liveStale ? (
|
||||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||||
>
|
<TriangleAlertIcon className="size-3.5 shrink-0" />
|
||||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
|
||||||
Обновить
|
</div>
|
||||||
</Button>
|
) : null
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
header={
|
||||||
size="sm"
|
<PageHeader
|
||||||
onClick={() => { void syncJhToDb() }}
|
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||||
disabled={!isLive || syncJhBusy || dataLoading}
|
actions={
|
||||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
<>
|
||||||
>
|
<ServerRailMobileButton />
|
||||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
<Button
|
||||||
JH → БД
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
onClick={() => { void loadLive() }}
|
||||||
<PlusIcon className="size-4" />Добавить туннель
|
disabled={!isLive || dataLoading}
|
||||||
</Button>
|
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||||
</>
|
>
|
||||||
}
|
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||||
/>
|
Обновить
|
||||||
|
</Button>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
disabled={!isLive || !historyServerId || dataLoading}
|
||||||
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
|
||||||
|
<PlusIcon className="size-4" />Добавить туннель
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* Legacy banner */}
|
{/* Legacy banner */}
|
||||||
@@ -424,27 +645,39 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-4 gap-4">
|
aria-label="Сводка GRE"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
{
|
||||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
id: "tunnels",
|
||||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
label: "Всего туннелей",
|
||||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
value: displayTunnels.length,
|
||||||
].map((s) => (
|
icon: <NetworkIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
},
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
{
|
||||||
{s.icon}
|
id: "up",
|
||||||
</IconTile>
|
label: "Активно",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: upCount,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "ipsec",
|
||||||
))}
|
label: "Защищены IPsec",
|
||||||
</div>
|
value: ipsecCount,
|
||||||
|
icon: <LockIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pools",
|
||||||
|
label: "IP-пулов",
|
||||||
|
value: displayPools.length,
|
||||||
|
icon: <DatabaseIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Page tabs */}
|
{/* Page tabs */}
|
||||||
<div className="flex items-center gap-1 border-b">
|
<div className="flex items-center gap-1 border-b">
|
||||||
@@ -480,6 +713,10 @@ export default function GrePage() {
|
|||||||
servers={displayServers}
|
servers={displayServers}
|
||||||
pools={displayPools}
|
pools={displayPools}
|
||||||
onCodePreview={setCodePreviewTunnel}
|
onCodePreview={setCodePreviewTunnel}
|
||||||
|
onEdit={openEditTunnel}
|
||||||
|
onToggle={(t) => { void toggleTunnel(t) }}
|
||||||
|
onDelete={setPendingDelete}
|
||||||
|
mutationsLocked={mutationsLocked}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
@@ -507,7 +744,7 @@ export default function GrePage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{poolTunnels.map((t, tunnelIndex) => (
|
{poolTunnels.map((t, tunnelIndex) => (
|
||||||
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
<div key={`${t.id}:${t.serverId}:${t.name}:${tunnelIndex}`} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
|
||||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
|
<span className={`size-1.5 rounded-full ${greStatusMeta(t.status).dot}`} />
|
||||||
<span className="font-mono font-medium">{t.name}</span>
|
<span className="font-mono font-medium">{t.name}</span>
|
||||||
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
<span className="text-muted-foreground">{t.localInnerIp} ↔ {t.remoteInnerIp}</span>
|
||||||
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
|
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
|
||||||
@@ -548,102 +785,67 @@ export default function GrePage() {
|
|||||||
</div>
|
</div>
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
<CodeExportSheet
|
||||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
open={!!codePreviewTunnel}
|
||||||
{codePreviewTunnel && (() => {
|
onClose={() => setCodePreviewTunnel(null)}
|
||||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
title={codePreviewTunnel?.name ?? "GRE"}
|
||||||
return (
|
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||||
<>
|
formats={[
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
{
|
||||||
<div className="flex items-start justify-between gap-4">
|
id: "rsc",
|
||||||
<div>
|
label: "RouterOS",
|
||||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
code: greExportCode,
|
||||||
</div>
|
},
|
||||||
<Button
|
]}
|
||||||
variant="outline" size="sm"
|
beforeCode={
|
||||||
className="shrink-0 gap-1.5"
|
codePreviewTunnel ? (
|
||||||
onClick={() => handleCopy(code)}
|
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||||
>
|
<span className="flex items-center gap-1.5">
|
||||||
{copied
|
<span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
|
||||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
{greStatusMeta(codePreviewTunnel.status).label}
|
||||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
</span>
|
||||||
</Button>
|
<span className="text-muted-foreground">·</span>
|
||||||
</div>
|
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||||
</SheetHeader>
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="font-mono">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||||
{/* meta strip */}
|
{" → "}
|
||||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
{codePreviewTunnel.remoteAddress}
|
||||||
<span className="flex items-center gap-1.5">
|
</span>
|
||||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
{codePreviewTunnel.ipsec ? (
|
||||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
<>
|
||||||
</span>
|
<span className="text-muted-foreground">·</span>
|
||||||
<span className="text-muted-foreground">·</span>
|
<span className="flex items-center gap-1 text-success">
|
||||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
<LockIcon className="size-3" />
|
||||||
<span className="text-muted-foreground">·</span>
|
IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"}
|
||||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
</span>
|
||||||
{codePreviewTunnel.ipsec && (
|
</>
|
||||||
<>
|
) : null}
|
||||||
<span className="text-muted-foreground">·</span>
|
</div>
|
||||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
) : null
|
||||||
</>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* code block */}
|
|
||||||
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isSection = isComment && line.includes("──")
|
|
||||||
const isKey = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isSection ? "text-muted-foreground/60"
|
|
||||||
: isComment ? "text-muted-foreground"
|
|
||||||
: isKey ? "text-sky-400/90"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}
|
|
||||||
{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать команды"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>Новый GRE-туннель</SheetTitle>
|
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
|
||||||
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
|
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Основные</SectionTitle>
|
<SectionTitle>Основные</SectionTitle>
|
||||||
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Сервер (MikroTik)" required>
|
<FormField label="Сервер (MikroTik)" required>
|
||||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)} disabled={tunnelMode === "edit"}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать сервер…</option>
|
<option value="" disabled>Выбрать сервер…</option>
|
||||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||||
@@ -670,7 +872,7 @@ export default function GrePage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SectionTitle>Внутренний IP</SectionTitle>
|
<SectionTitle>Внутренний IP</SectionTitle>
|
||||||
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
<FormField label="IP-пул" hint="Необязательно — внутренний IP можно указать вручную">
|
||||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||||
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
|
||||||
<option value="" disabled>Выбрать пул…</option>
|
<option value="" disabled>Выбрать пул…</option>
|
||||||
@@ -792,7 +994,9 @@ export default function GrePage() {
|
|||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||||
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
|
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy}>
|
||||||
|
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
|
||||||
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -839,6 +1043,39 @@ export default function GrePage() {
|
|||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</div>
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={setHistoryOpen}
|
||||||
|
title="История GRE"
|
||||||
|
itemLabel="туннелей"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingDelete
|
||||||
|
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
|
||||||
|
: null}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteTunnel()}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,896 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { servers as mockServers } from "@/lib/data"
|
||||||
|
import type { Server } from "@/lib/data"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { IpsecUsersGrid } from "@/components/ipsec/ipsec-users-grid"
|
||||||
|
import { IpsecServerGrid } from "@/components/ipsec/ipsec-server-grid"
|
||||||
|
import { IpsecUserSheet, type IpsecUserFormState } from "@/components/ipsec/ipsec-user-sheet"
|
||||||
|
import { IpsecInitSheet, type IpsecInitFormState } from "@/components/ipsec/ipsec-init-sheet"
|
||||||
|
import { IpsecCertSheet } from "@/components/ipsec/ipsec-cert-sheet"
|
||||||
|
import { IpsecPeerSheet, type IpsecPeerFormState } from "@/components/ipsec/ipsec-peer-sheet"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||||
|
import {
|
||||||
|
createIpsecUser,
|
||||||
|
deleteIpsecCert,
|
||||||
|
deleteIpsecPeer,
|
||||||
|
deleteIpsecServer,
|
||||||
|
deleteIpsecUser,
|
||||||
|
exportIpsecCertByName,
|
||||||
|
exportIpsecUserCert,
|
||||||
|
initIpsecServer,
|
||||||
|
listIpsec,
|
||||||
|
patchIpsecPeer,
|
||||||
|
patchIpsecUser,
|
||||||
|
restoreIpsecRevision,
|
||||||
|
} from "@/shared/api/ipsec"
|
||||||
|
import type {
|
||||||
|
IpsecCertBundle,
|
||||||
|
IpsecCertInfoDto,
|
||||||
|
IpsecClientDto,
|
||||||
|
IpsecPeerDto,
|
||||||
|
IpsecServerSummaryDto,
|
||||||
|
} from "@mmapp/contracts/ipsec"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import {
|
||||||
|
ALL_SERVERS_ID,
|
||||||
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { findFreePoolIp } from "@/lib/ipsec-client"
|
||||||
|
import {
|
||||||
|
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||||
|
UsersIcon, ActivityIcon, RefreshCwIcon, InfoIcon,
|
||||||
|
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
type IpsecWorkspaceTab = "clients" | "server" | "cli"
|
||||||
|
type IpsecFailure = { serverId: string; serverName?: string; error: string }
|
||||||
|
type PendingDelete =
|
||||||
|
| { kind: "user"; client: IpsecClientDto }
|
||||||
|
| { kind: "server"; summary: IpsecServerSummaryDto }
|
||||||
|
| { kind: "peer"; serverId: string; peer: IpsecPeerDto }
|
||||||
|
| { kind: "cert"; serverId: string; cert: IpsecCertInfoDto }
|
||||||
|
|
||||||
|
const MOCK_IPSEC_PEER: IpsecPeerDto = {
|
||||||
|
id: "srv2:pp1",
|
||||||
|
rosId: "*PP1",
|
||||||
|
serverId: "srv2",
|
||||||
|
serverName: "mt-spb-edge-01",
|
||||||
|
name: "ipsec-vpn",
|
||||||
|
address: "0.0.0.0/0",
|
||||||
|
exchangeMode: "ike2",
|
||||||
|
passive: true,
|
||||||
|
certificate: "ipsec-server",
|
||||||
|
profile: "ipsec-vpn",
|
||||||
|
disabled: false,
|
||||||
|
managed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_IPSEC_SERVERS: IpsecServerSummaryDto[] = [
|
||||||
|
{
|
||||||
|
serverId: "srv2",
|
||||||
|
serverName: "mt-spb-edge-01",
|
||||||
|
initialized: true,
|
||||||
|
ike2Ready: true,
|
||||||
|
serverEndpoint: "vpn.example.com",
|
||||||
|
pool: { id: "srv2:p1", rosId: "*P1", serverId: "srv2", name: "ipsec-vpn", ranges: "10.77.0.2-10.77.0.254", managed: true },
|
||||||
|
sharedModeConfig: { id: "srv2:m1", rosId: "*M1", serverId: "srv2", name: "ipsec-vpn", addressPool: "ipsec-vpn", staticDns: "10.77.0.1", managed: true },
|
||||||
|
peer: MOCK_IPSEC_PEER,
|
||||||
|
peers: [MOCK_IPSEC_PEER],
|
||||||
|
caCert: { name: "ipsec-ca", commonName: "MikrotikManager IPsec CA", keySize: "4096", expiresAt: "2036-09-01", trusted: true, hasPrivateKey: true, role: "ca", managed: true },
|
||||||
|
serverCert: { name: "ipsec-server", commonName: "vpn.example.com", keySize: "2048", expiresAt: "2031-09-01", trusted: true, hasPrivateKey: true, role: "server", managed: true },
|
||||||
|
natRuleManaged: true,
|
||||||
|
clientsTotal: 3,
|
||||||
|
clientsOnline: 1,
|
||||||
|
certs: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const MOCK_IPSEC_CLIENTS: IpsecClientDto[] = [
|
||||||
|
{
|
||||||
|
id: "srv2:*I1", rosId: "*I1", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
|
name: "alice", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-alice", certName: "ipsec-user-alice", signedBy: "MyCA", commonName: "alice",
|
||||||
|
staticIp: "10.77.0.10", modeConfigName: "mc-ipsec-alice", peerName: "ipsec-vpn",
|
||||||
|
online: true, activeAddress: "10.100.1.7", activeSince: "2h", disabled: false, managed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "srv2:*I2", rosId: "*I2", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
|
name: "bob", authMethod: "certificate", kind: "cert", certificateName: "ipsec-user-bob", certName: "ipsec-user-bob", signedBy: "MyCA", commonName: "bob",
|
||||||
|
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "srv2:*I3", rosId: "*I3", serverId: "srv2", serverName: "mt-spb-edge-01",
|
||||||
|
name: "tablet-psk", authMethod: "pre-shared-key", kind: "psk", remoteId: "tablet",
|
||||||
|
peerName: "ipsec-vpn", online: false, disabled: false, managed: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
host: string
|
||||||
|
country: string
|
||||||
|
type?: Server["type"]
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
|
asn?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "online",
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IpsecPage() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [summaries, setSummaries] = useState<IpsecServerSummaryDto[]>([])
|
||||||
|
const [clients, setClients] = useState<IpsecClientDto[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
|
const [failures, setFailures] = useState<IpsecFailure[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [certBusy, setCertBusy] = useState(false)
|
||||||
|
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
const [workspaceTab, setWorkspaceTab] = useState<IpsecWorkspaceTab>("clients")
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
const [initOpen, setInitOpen] = useState(false)
|
||||||
|
const [userOpen, setUserOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<IpsecClientDto | null>(null)
|
||||||
|
const [certOpen, setCertOpen] = useState(false)
|
||||||
|
const [certBundle, setCertBundle] = useState<IpsecCertBundle | null>(null)
|
||||||
|
const [certClient, setCertClient] = useState<IpsecClientDto | null>(null)
|
||||||
|
const [certByName, setCertByName] = useState<{ serverId: string; name: string } | null>(null)
|
||||||
|
const [peerOpen, setPeerOpen] = useState(false)
|
||||||
|
const [editingPeer, setEditingPeer] = useState<{ serverId: string; peer: IpsecPeerDto } | null>(null)
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [ipsec, servers] = await Promise.all([
|
||||||
|
listIpsec(backendUrl),
|
||||||
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
setSummaries(ipsec.servers)
|
||||||
|
setClients(ipsec.clients)
|
||||||
|
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
setFailures(ipsec.failures ?? [])
|
||||||
|
if (ipsec.failures?.length) {
|
||||||
|
toast.warning(
|
||||||
|
`Не удалось опросить: ${ipsec.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Ошибка загрузки IPsec")
|
||||||
|
setSummaries([])
|
||||||
|
setClients([])
|
||||||
|
setFailures([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setSummaries([])
|
||||||
|
setClients([])
|
||||||
|
setLiveServers([])
|
||||||
|
setFailures([])
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
const displaySummaries = isLive ? summaries : MOCK_IPSEC_SERVERS
|
||||||
|
const displayClients = isLive ? clients : MOCK_IPSEC_CLIENTS
|
||||||
|
|
||||||
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
|
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||||||
|
|
||||||
|
const loadRevisions = useCallback(async () => {
|
||||||
|
if (!isLive || !historyServerId) return
|
||||||
|
setHistoryLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
||||||
|
backendUrl,
|
||||||
|
`/api/ipsec/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||||
|
)
|
||||||
|
setRevisions(res.revisions)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||||
|
setRevisions([])
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, backendUrl])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !historyServerId) return
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
await restoreIpsecRevision(backendUrl, id, historyServerId)
|
||||||
|
toast.success("Версия применена на роутер")
|
||||||
|
await loadLive()
|
||||||
|
await loadRevisions()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось откатить", { description: String(err) })
|
||||||
|
} finally {
|
||||||
|
setHistoryRestoring(false)
|
||||||
|
}
|
||||||
|
}, [isLive, historyServerId, backendUrl, loadLive, loadRevisions])
|
||||||
|
|
||||||
|
const scopedClients = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayClients
|
||||||
|
return displayClients.filter((c) => c.serverId === effectiveServerId)
|
||||||
|
}, [displayClients, effectiveServerId])
|
||||||
|
|
||||||
|
const scopedSummaries = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displaySummaries
|
||||||
|
return displaySummaries.filter((s) => s.serverId === effectiveServerId)
|
||||||
|
}, [displaySummaries, effectiveServerId])
|
||||||
|
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
if (!search) return scopedClients
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return scopedClients.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.serverName.toLowerCase().includes(q) ||
|
||||||
|
(c.commonName ?? "").toLowerCase().includes(q) ||
|
||||||
|
(c.staticIp ?? "").includes(q),
|
||||||
|
)
|
||||||
|
}, [scopedClients, search])
|
||||||
|
|
||||||
|
const serversWithIpsec = scopedSummaries.filter((s) => s.peers.length > 0).length
|
||||||
|
const clientsOnline = scopedClients.filter((c) => c.online).length
|
||||||
|
const caOk = scopedSummaries.some((s) => s.caCert)
|
||||||
|
const compactServer = effectiveServerId !== ALL_SERVERS_ID
|
||||||
|
const sheetServerId = compactServer ? effectiveServerId : undefined
|
||||||
|
const certNames = useMemo(
|
||||||
|
() => Array.from(new Set(scopedSummaries.flatMap((s) => (s.certs ?? []).map((c) => c.name)))).sort(),
|
||||||
|
[scopedSummaries],
|
||||||
|
)
|
||||||
|
const peerOptions = useMemo(
|
||||||
|
() => scopedSummaries.flatMap((s) => s.peers.map((p) => ({ serverId: s.serverId, name: p.name, managed: p.managed }))),
|
||||||
|
[scopedSummaries],
|
||||||
|
)
|
||||||
|
|
||||||
|
const serverOptions = displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Подсказка свободного IP из пула выбранного сервера. */
|
||||||
|
const freeIpHint = useMemo(() => {
|
||||||
|
const summary = scopedSummaries.find((s) => s.ike2Ready && s.pool)
|
||||||
|
if (!summary?.pool) return undefined
|
||||||
|
const taken = scopedClients.map((c) => c.staticIp).filter(Boolean) as string[]
|
||||||
|
return findFreePoolIp(summary.pool.ranges, taken) ?? undefined
|
||||||
|
}, [scopedSummaries, scopedClients])
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const c of displayClients) {
|
||||||
|
counts.set(c.serverId, (counts.get(c.serverId) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
count: counts.get(s.id) ?? 0,
|
||||||
|
enabled: s.enabled,
|
||||||
|
title: [s.name, s.host, s.asn].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [displayServers, displayClients])
|
||||||
|
|
||||||
|
const handleInit = async (form: IpsecInitFormState) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await initIpsecServer(backendUrl, {
|
||||||
|
serverId: form.serverId,
|
||||||
|
serverEndpoint: form.serverEndpoint.trim(),
|
||||||
|
poolCidr: form.poolCidr.trim(),
|
||||||
|
dns: form.dns.trim() || undefined,
|
||||||
|
caDaysValid: 3650,
|
||||||
|
serverDaysValid: 3650,
|
||||||
|
clientDaysValid: 1825,
|
||||||
|
createNatRule: form.createNatRule,
|
||||||
|
})
|
||||||
|
toast.success("IKEv2-сервер инициализирован")
|
||||||
|
setInitOpen(false)
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка инициализации", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateUser = async (form: IpsecUserFormState) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await createIpsecUser(backendUrl, {
|
||||||
|
serverId: form.serverId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
peerName: form.peerName.trim() || undefined,
|
||||||
|
authMethod: form.authMethod,
|
||||||
|
psk: form.authMethod === "pre-shared-key" ? form.psk : undefined,
|
||||||
|
remoteId: form.authMethod === "pre-shared-key" ? form.remoteId.trim() || undefined : undefined,
|
||||||
|
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : undefined,
|
||||||
|
passphrase: form.passphrase.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success(`Клиент «${form.name.trim()}» создан`)
|
||||||
|
setUserOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
await loadLive()
|
||||||
|
if (res.bundle) {
|
||||||
|
setCertBundle(res.bundle)
|
||||||
|
setCertClient(res.client ?? null)
|
||||||
|
setCertByName(null)
|
||||||
|
setCertOpen(true)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка создания клиента", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUser = async (form: IpsecUserFormState) => {
|
||||||
|
if (!editing) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await patchIpsecUser(backendUrl, editing.serverId, editing.rosId, {
|
||||||
|
name: form.name.trim() !== editing.name ? form.name.trim() : undefined,
|
||||||
|
staticIp: form.useStaticIp && form.staticIp.trim() ? form.staticIp.trim() : null,
|
||||||
|
psk: form.authMethod === "pre-shared-key" && form.psk.trim() ? form.psk : undefined,
|
||||||
|
})
|
||||||
|
toast.success("Клиент обновлён")
|
||||||
|
setUserOpen(false)
|
||||||
|
setEditing(null)
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка обновления", { description: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCert = async (client: IpsecClientDto) => {
|
||||||
|
const byName = client.kind === "cert" && Boolean(client.certName)
|
||||||
|
setCertClient(byName ? null : client)
|
||||||
|
setCertByName(byName ? { serverId: client.serverId, name: client.certName! } : null)
|
||||||
|
setCertBundle(null)
|
||||||
|
setCertOpen(true)
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = byName
|
||||||
|
? await exportIpsecCertByName(backendUrl, client.serverId, client.certName!, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, client.serverId, client.rosId, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экспорт .p12 существующего клиентского серта по имени (client1/anakondra и т.п.). */
|
||||||
|
const openCertByName = async (serverId: string, cert: IpsecCertInfoDto) => {
|
||||||
|
setCertByName({ serverId, name: cert.name })
|
||||||
|
setCertClient(null)
|
||||||
|
setCertBundle(null)
|
||||||
|
setCertOpen(true)
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const passphrase = `mm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
const bundle = await exportIpsecCertByName(backendUrl, serverId, cert.name, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта сертификата", {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
})
|
||||||
|
setCertOpen(false)
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reexportCert = async (passphrase: string) => {
|
||||||
|
if (!certClient && !certByName) return
|
||||||
|
setCertBusy(true)
|
||||||
|
try {
|
||||||
|
const bundle = certByName
|
||||||
|
? await exportIpsecCertByName(backendUrl, certByName.serverId, certByName.name, passphrase)
|
||||||
|
: await exportIpsecUserCert(backendUrl, certClient!.serverId, certClient!.rosId, passphrase)
|
||||||
|
setCertBundle(bundle)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка экспорта", { description: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setCertBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!pendingDelete) return
|
||||||
|
const target = pendingDelete
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
if (target.kind === "user") {
|
||||||
|
await deleteIpsecUser(backendUrl, target.client.serverId, target.client.rosId)
|
||||||
|
toast.success(`Клиент «${target.client.name}» удалён`)
|
||||||
|
} else if (target.kind === "server") {
|
||||||
|
await deleteIpsecServer(backendUrl, target.summary.serverId)
|
||||||
|
toast.success(`IKEv2-сервер на ${target.summary.serverName} удалён`)
|
||||||
|
} else if (target.kind === "peer") {
|
||||||
|
await deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId)
|
||||||
|
toast.success(`Peer «${target.peer.name}» удалён`)
|
||||||
|
} else {
|
||||||
|
await deleteIpsecCert(backendUrl, target.serverId, target.cert.name)
|
||||||
|
toast.success(`Сертификат «${target.cert.name}» удалён`)
|
||||||
|
}
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
const forceDelete = async (fn: () => Promise<unknown>) => {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
toast.success("Удалено")
|
||||||
|
await loadLive()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Не удалось удалить", { description: err instanceof Error ? err.message : String(err) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e instanceof ApiClientError && e.status === 409 && target.kind === "peer") {
|
||||||
|
toast.error("На peer ссылаются identity", {
|
||||||
|
description: e.message,
|
||||||
|
action: {
|
||||||
|
label: "Удалить принудительно",
|
||||||
|
onClick: () => { void forceDelete(() => deleteIpsecPeer(backendUrl, target.serverId, target.peer.rosId, { force: true })) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (e instanceof ApiClientError && e.status === 409 && target.kind === "cert") {
|
||||||
|
toast.error("Сертификат используется", {
|
||||||
|
description: e.message,
|
||||||
|
action: {
|
||||||
|
label: "Удалить принудительно",
|
||||||
|
onClick: () => { void forceDelete(() => deleteIpsecCert(backendUrl, target.serverId, target.cert.name, { force: true })) },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error("Ошибка удаления", { description: e instanceof Error ? e.message : String(e) })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
setPendingDelete(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditPeer = (serverId: string, peer: IpsecPeerDto) => {
|
||||||
|
setEditingPeer({ serverId, peer })
|
||||||
|
setPeerOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditPeer = async (form: IpsecPeerFormState) => {
|
||||||
|
if (!editingPeer) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await patchIpsecPeer(backendUrl, editingPeer.serverId, editingPeer.peer.rosId, {
|
||||||
|
name: form.name.trim() !== editingPeer.peer.name ? form.name.trim() : undefined,
|
||||||
|
address: form.address.trim() || undefined,
|
||||||
|
exchangeMode: form.exchangeMode.trim() || undefined,
|
||||||
|
passive: form.passive,
|
||||||
|
certificate: form.certificate.trim() || undefined,
|
||||||
|
profile: form.profile.trim() || undefined,
|
||||||
|
disabled: form.disabled,
|
||||||
|
})
|
||||||
|
toast.success("Peer обновлён")
|
||||||
|
setPeerOpen(false)
|
||||||
|
setEditingPeer(null)
|
||||||
|
await loadLive()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Ошибка обновления peer", { description: e instanceof Error ? e.message : String(e) })
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDialogTitle = (() => {
|
||||||
|
if (!pendingDelete) return ""
|
||||||
|
switch (pendingDelete.kind) {
|
||||||
|
case "server": return `Удалить IKEv2-сервер на ${pendingDelete.summary.serverName}?`
|
||||||
|
case "user": return `Удалить клиента «${pendingDelete.client.name}»?`
|
||||||
|
case "peer": return `Удалить peer «${pendingDelete.peer.name}»?`
|
||||||
|
case "cert": return `Удалить сертификат «${pendingDelete.cert.name}»?`
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const deleteDialogDescription = (() => {
|
||||||
|
if (!pendingDelete) return ""
|
||||||
|
switch (pendingDelete.kind) {
|
||||||
|
case "server":
|
||||||
|
return "Удалит managed-объекты (identity, mode-config, peer, пул, NAT) и сертификаты IKEv2 на этом роутере. Действие можно откатить через «Историю» (кроме сертификатов)."
|
||||||
|
case "user":
|
||||||
|
return "Удалит identity, персональный mode-config и клиентский сертификат на роутере."
|
||||||
|
case "peer":
|
||||||
|
return "Удалит peer на роутере. Если на него ссылаются identity — потребуется принудительное удаление."
|
||||||
|
case "cert":
|
||||||
|
return "Удалит сертификат с роутера. Если он используется peer или identity — потребуется принудительное удаление."
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServerRailLayout
|
||||||
|
items={railItems}
|
||||||
|
selectedId={effectiveServerId}
|
||||||
|
onSelect={setSelectedServerId}
|
||||||
|
showAll
|
||||||
|
allCount={displayClients.length}
|
||||||
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "IPsec / IKEv2" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
{isLive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void loadLive()}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isLive && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loading || !historyServerId}
|
||||||
|
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат"}
|
||||||
|
onClick={() => {
|
||||||
|
setHistoryOpen(true)
|
||||||
|
void loadRevisions()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HistoryIcon className="size-4" />
|
||||||
|
История
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isLive ? (
|
||||||
|
<Button size="sm" onClick={() => setUserOpen(true)}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Новый клиент
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка IPsec"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверов IKEv2",
|
||||||
|
value: serversWithIpsec,
|
||||||
|
icon: <ShieldCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clients",
|
||||||
|
label: "Клиентов",
|
||||||
|
value: scopedClients.length,
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "online",
|
||||||
|
label: "Онлайн",
|
||||||
|
value: clientsOnline,
|
||||||
|
icon: <ActivityIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ca",
|
||||||
|
label: "CA",
|
||||||
|
value: caOk ? "ок" : "—",
|
||||||
|
icon: <KeyRoundIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{failures.length > 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Не удалось опросить часть роутеров</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{failures.map((f) => f.serverName ?? f.serverId).join(", ")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLive ? (
|
||||||
|
<Alert variant="info">
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>Mock-режим</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Переключитесь в live в настройках, чтобы управлять IKEv2 на MikroTik.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={workspaceTab}
|
||||||
|
onValueChange={(v) => setWorkspaceTab(v as IpsecWorkspaceTab)}
|
||||||
|
className="gap-3"
|
||||||
|
>
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="clients" className="gap-1.5">
|
||||||
|
<UsersIcon className="size-3.5" />
|
||||||
|
Клиенты
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="server" className="gap-1.5">
|
||||||
|
<ShieldCheckIcon className="size-3.5" />
|
||||||
|
Сервер
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="cli" className="gap-1.5">
|
||||||
|
<CodeXmlIcon className="size-3.5" />
|
||||||
|
CLI
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="clients" className="mt-0 outline-none">
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="Поиск по имени / CN / IP…"
|
||||||
|
actions={
|
||||||
|
isLive ? (
|
||||||
|
<Button size="sm" onClick={() => { setEditing(null); setUserOpen(true) }}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Клиент
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IpsecUsersGrid
|
||||||
|
clients={filteredClients}
|
||||||
|
compactServer={compactServer}
|
||||||
|
onDownloadCert={isLive ? (row) => void openCert(row) : undefined}
|
||||||
|
onEdit={isLive ? (row) => { setEditing(row); setUserOpen(true) } : undefined}
|
||||||
|
onDelete={isLive ? (row) => setPendingDelete({ kind: "user", client: row }) : undefined}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="server" className="mt-0 outline-none">
|
||||||
|
<IpsecServerGrid
|
||||||
|
servers={scopedSummaries}
|
||||||
|
onInit={isLive ? (s) => { setSelectedServerId(s.serverId); setInitOpen(true) } : undefined}
|
||||||
|
onRemove={isLive ? (s) => setPendingDelete({ kind: "server", summary: s }) : undefined}
|
||||||
|
onEditPeer={isLive ? openEditPeer : undefined}
|
||||||
|
onDeletePeer={isLive ? (serverId, peer) => setPendingDelete({ kind: "peer", serverId, peer }) : undefined}
|
||||||
|
onDeleteCert={isLive ? (serverId, cert) => setPendingDelete({ kind: "cert", serverId, cert }) : undefined}
|
||||||
|
onExportCert={isLive ? (serverId, cert) => void openCertByName(serverId, cert) : undefined}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cli" className="mt-0 outline-none">
|
||||||
|
<OpsPanel title="RouterOS 7 · /ip ipsec — быстрые команды" contentClassName="px-5 py-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 font-mono text-xs sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: "CA и серверный серт.",
|
||||||
|
lines: [
|
||||||
|
"/certificate add name=ipsec-ca \\",
|
||||||
|
" common-name=\"MM IPsec CA\" \\",
|
||||||
|
" key-size=4096 \\",
|
||||||
|
" key-usage=key-cert-sign,crl-sign",
|
||||||
|
"/certificate sign ipsec-ca",
|
||||||
|
"/certificate add name=ipsec-server \\",
|
||||||
|
" common-name=vpn.example.com \\",
|
||||||
|
" subject-alt-name=DNS:vpn.example.com",
|
||||||
|
"/certificate sign ipsec-server ca=ipsec-ca",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Peer + identity",
|
||||||
|
lines: [
|
||||||
|
"/ip ipsec peer add \\",
|
||||||
|
" name=ipsec-vpn address=0.0.0.0/0 \\",
|
||||||
|
" exchange-mode=ike2 passive=yes \\",
|
||||||
|
" certificate=ipsec-server send-cert=always",
|
||||||
|
"/ip ipsec identity add \\",
|
||||||
|
" peer=ipsec-vpn auth-method=rsa-key \\",
|
||||||
|
" certificate=ipsec-server \\",
|
||||||
|
" match-by=certificate \\",
|
||||||
|
" generate-policy=port-strict \\",
|
||||||
|
" mode-config=ipsec-vpn",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Клиент: серт и .p12",
|
||||||
|
lines: [
|
||||||
|
"/certificate add name=ipsec-user-alice \\",
|
||||||
|
" common-name=alice key-size=2048 \\",
|
||||||
|
" key-usage=digital-signature,\\",
|
||||||
|
" key-encipherment,tls-client",
|
||||||
|
"/certificate sign ipsec-user-alice \\",
|
||||||
|
" ca=ipsec-ca",
|
||||||
|
"/certificate export-certificate \\",
|
||||||
|
" ipsec-user-alice type=pkcs12 \\",
|
||||||
|
" export-passphrase=*****",
|
||||||
|
"",
|
||||||
|
"# Онлайн-клиенты:",
|
||||||
|
"/ip ipsec active-peers print",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
].map((b) => (
|
||||||
|
<div key={b.title}>
|
||||||
|
<p className="mb-1.5 font-sans text-[11px] font-semibold uppercase tracking-wide text-foreground/80">
|
||||||
|
{b.title}
|
||||||
|
</p>
|
||||||
|
<pre className="overflow-x-auto rounded-md bg-muted p-2.5 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{b.lines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</OpsPanel>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ServerRailLayout>
|
||||||
|
|
||||||
|
<IpsecUserSheet
|
||||||
|
open={userOpen}
|
||||||
|
onOpenChange={(v) => { setUserOpen(v); if (!v) setEditing(null) }}
|
||||||
|
servers={serverOptions}
|
||||||
|
peers={peerOptions}
|
||||||
|
busy={busy}
|
||||||
|
defaultServerId={sheetServerId}
|
||||||
|
editing={editing}
|
||||||
|
freeIpHint={freeIpHint}
|
||||||
|
onSubmit={editing ? handleEditUser : handleCreateUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IpsecPeerSheet
|
||||||
|
open={peerOpen}
|
||||||
|
onOpenChange={(v) => { setPeerOpen(v); if (!v) setEditingPeer(null) }}
|
||||||
|
busy={busy}
|
||||||
|
editing={editingPeer?.peer ?? null}
|
||||||
|
certificates={certNames}
|
||||||
|
onSubmit={handleEditPeer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IpsecInitSheet
|
||||||
|
open={initOpen}
|
||||||
|
onOpenChange={setInitOpen}
|
||||||
|
servers={serverOptions}
|
||||||
|
busy={busy}
|
||||||
|
defaultServerId={sheetServerId}
|
||||||
|
onSubmit={handleInit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IpsecCertSheet
|
||||||
|
open={certOpen}
|
||||||
|
onOpenChange={(v) => { setCertOpen(v); if (!v) setCertByName(null) }}
|
||||||
|
bundle={certBundle}
|
||||||
|
busy={certBusy}
|
||||||
|
onReexport={reexportCert}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={pendingDelete != null} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<Trash2Icon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>{deleteDialogTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{deleteDialogDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={busy}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => { e.preventDefault(); void confirmDelete() }}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={setHistoryOpen}
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={(id) => void restoreRevision(id)}
|
||||||
|
title="История конфигураций IPsec"
|
||||||
|
itemLabel="конфигурация"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+20
-17
@@ -1,5 +1,6 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react"
|
import type { CSSProperties, ReactNode } from "react"
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { AppSidebar } from "@/components/app-sidebar"
|
||||||
|
import { AuthGuard } from "@/components/auth-guard"
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||||
import { CommandPalette } from "@/components/command-palette"
|
import { CommandPalette } from "@/components/command-palette"
|
||||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||||
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
|
|||||||
|
|
||||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DataSourceProvider>
|
<AuthGuard>
|
||||||
<EvoBGPProvider>
|
<DataSourceProvider>
|
||||||
<SidebarProvider
|
<EvoBGPProvider>
|
||||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
<SidebarProvider
|
||||||
>
|
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
>
|
||||||
К содержимому
|
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||||
</a>
|
К содержимому
|
||||||
<AppSidebar />
|
</a>
|
||||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
<AppSidebar />
|
||||||
{children}
|
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||||
</SidebarInset>
|
{children}
|
||||||
<CommandPalette />
|
</SidebarInset>
|
||||||
<ReleaseNotesModal />
|
<CommandPalette />
|
||||||
</SidebarProvider>
|
<ReleaseNotesModal />
|
||||||
</EvoBGPProvider>
|
</SidebarProvider>
|
||||||
</DataSourceProvider>
|
</EvoBGPProvider>
|
||||||
|
</DataSourceProvider>
|
||||||
|
</AuthGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1232
-21
File diff suppressed because it is too large
Load Diff
+189
-145
@@ -7,16 +7,20 @@ import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-da
|
|||||||
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
|
||||||
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||||
|
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||||
|
|
||||||
// ─── types ────────────────────────────────────────────────────────────────────
|
// ─── types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -118,11 +122,17 @@ interface BackendBfdSession {
|
|||||||
packetsRx: number; packetsTx: number; stateChanges: number
|
packetsRx: number; packetsTx: number; stateChanges: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendOspfRoute {
|
||||||
|
id: string; serverId: number; serverName: string; serverSite: string
|
||||||
|
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
|
||||||
|
}
|
||||||
|
|
||||||
interface BackendOspfAll {
|
interface BackendOspfAll {
|
||||||
neighbors: BackendNeighbor[]
|
neighbors: BackendNeighbor[]
|
||||||
interfaces: BackendInterface[]
|
interfaces: BackendInterface[]
|
||||||
instances: BackendInstance[]
|
instances: BackendInstance[]
|
||||||
bfdSessions: BackendBfdSession[]
|
bfdSessions: BackendBfdSession[]
|
||||||
|
routes?: BackendOspfRoute[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRefInterfaceName(name: string): boolean {
|
function isRefInterfaceName(name: string): boolean {
|
||||||
@@ -209,6 +219,22 @@ function backendToBfdSession(b: BackendBfdSession): BfdSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backendToRoute(b: BackendOspfRoute): OspfRoute {
|
||||||
|
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
|
||||||
|
const type = allowed.includes(b.type) ? b.type : "O"
|
||||||
|
return {
|
||||||
|
id: `${b.serverId}-${b.id}`,
|
||||||
|
destination: b.destination,
|
||||||
|
type,
|
||||||
|
cost: b.cost,
|
||||||
|
nextHop: b.nextHop,
|
||||||
|
via: b.via,
|
||||||
|
serverId: String(b.serverId),
|
||||||
|
serverLabel: b.serverName,
|
||||||
|
area: b.area || "—",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const COST_STEP = 10
|
const COST_STEP = 10
|
||||||
@@ -706,7 +732,7 @@ function InterfacesTab({
|
|||||||
}, [grouped, isLive])
|
}, [grouped, isLive])
|
||||||
|
|
||||||
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
|
||||||
const canOptimizeLive = isLive && filterServerId !== "all"
|
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
|
||||||
const uniqueLiveFallbackOpt = useMemo(() => {
|
const uniqueLiveFallbackOpt = useMemo(() => {
|
||||||
const out: Record<string, number> = {}
|
const out: Record<string, number> = {}
|
||||||
const byRouter: Record<string, OspfItem[]> = {}
|
const byRouter: Record<string, OspfItem[]> = {}
|
||||||
@@ -733,13 +759,14 @@ function InterfacesTab({
|
|||||||
const ra = readStoredRouteOptimizerSettings()
|
const ra = readStoredRouteOptimizerSettings()
|
||||||
setOptimizing(true)
|
setOptimizing(true)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||||
method: "POST",
|
backendUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
{
|
||||||
})
|
method: "POST",
|
||||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||||
const data = await r.json() as BackendOspfOptimizeResponse
|
},
|
||||||
|
)
|
||||||
const byKey: Record<string, number> = {}
|
const byKey: Record<string, number> = {}
|
||||||
data.interfaces.forEach((row) => {
|
data.interfaces.forEach((row) => {
|
||||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||||
@@ -919,20 +946,33 @@ function NeighborsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка соседей OSPF"
|
||||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
items={[
|
||||||
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
|
{
|
||||||
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
id: "neighbors",
|
||||||
].map(s => (
|
label: "Всего соседей",
|
||||||
<Frame key={s.label} className="h-full">
|
value: neighbors.length,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
icon: <UsersIcon className="size-4" />,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
iconClassName: "text-muted-foreground",
|
||||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "full",
|
||||||
))}
|
label: "Full",
|
||||||
</div>
|
value: fullCount,
|
||||||
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "not-full",
|
||||||
|
label: "Не Full",
|
||||||
|
value: neighbors.length - fullCount,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{graphNodes.length > 0 && (
|
{graphNodes.length > 0 && (
|
||||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||||
@@ -1042,21 +1082,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<KpiStatGrid
|
||||||
{[
|
aria-label="Сводка BFD"
|
||||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
items={[
|
||||||
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
|
{
|
||||||
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
|
id: "sessions",
|
||||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
label: "Сессий BFD",
|
||||||
].map(s => (
|
value: sessions.length,
|
||||||
<Frame key={s.label} className="h-full">
|
icon: <ActivityIcon className="size-4" />,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
iconClassName: "text-muted-foreground",
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
},
|
||||||
<p className={cn("text-2xl leading-none font-bold tabular-nums", s.color)}>{s.value}</p>
|
{
|
||||||
</FramePanel>
|
id: "up",
|
||||||
</Frame>
|
label: "Up",
|
||||||
))}
|
value: upCount,
|
||||||
</div>
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "down",
|
||||||
|
label: "Down / Admin",
|
||||||
|
value: downCount,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: downCount > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "init",
|
||||||
|
label: "Init / другие",
|
||||||
|
value: initCount,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: initCount > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{sessions.length === 0 && (
|
{sessions.length === 0 && (
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
@@ -1094,7 +1154,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
|
|||||||
|
|
||||||
export default function OspfPage() {
|
export default function OspfPage() {
|
||||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
|
||||||
|
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
@@ -1120,8 +1180,7 @@ export default function OspfPage() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setLiveError(null)
|
setLiveError(null)
|
||||||
fetch(`${backendUrl}/api/ospf/all`)
|
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||||
@@ -1135,7 +1194,7 @@ export default function OspfPage() {
|
|||||||
}, [isLive, backendUrl, fetchTick])
|
}, [isLive, backendUrl, fetchTick])
|
||||||
|
|
||||||
// Derive frontend types from backend data or use mocks
|
// Derive frontend types from backend data or use mocks
|
||||||
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => {
|
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
|
||||||
if (isLive && liveData) {
|
if (isLive && liveData) {
|
||||||
// Build interface→cost map for neighbor cost lookup
|
// Build interface→cost map for neighbor cost lookup
|
||||||
const ifaceMap = new Map<string, number>()
|
const ifaceMap = new Map<string, number>()
|
||||||
@@ -1148,6 +1207,7 @@ export default function OspfPage() {
|
|||||||
.filter((item) => !isRefInterfaceName(item.interfaceName))
|
.filter((item) => !isRefInterfaceName(item.interfaceName))
|
||||||
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
|
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
|
||||||
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
|
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
|
||||||
|
const routes = (liveData.routes ?? []).map(backendToRoute)
|
||||||
|
|
||||||
// Build routerIds from instances
|
// Build routerIds from instances
|
||||||
const routerIds: Record<string, string> = {}
|
const routerIds: Record<string, string> = {}
|
||||||
@@ -1158,7 +1218,7 @@ export default function OspfPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
||||||
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes }
|
||||||
}
|
}
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
return {
|
return {
|
||||||
@@ -1168,6 +1228,7 @@ export default function OspfPage() {
|
|||||||
graphEdges: [],
|
graphEdges: [],
|
||||||
routerIds: {},
|
routerIds: {},
|
||||||
bfdSessions: [],
|
bfdSessions: [],
|
||||||
|
routes: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -1177,6 +1238,7 @@ export default function OspfPage() {
|
|||||||
graphEdges: MOCK_GRAPH_EDGES,
|
graphEdges: MOCK_GRAPH_EDGES,
|
||||||
routerIds: MOCK_ROUTER_IDS,
|
routerIds: MOCK_ROUTER_IDS,
|
||||||
bfdSessions: MOCK_BFD,
|
bfdSessions: MOCK_BFD,
|
||||||
|
routes: MOCK_ROUTES,
|
||||||
}
|
}
|
||||||
}, [isLive, liveData])
|
}, [isLive, liveData])
|
||||||
|
|
||||||
@@ -1217,9 +1279,27 @@ export default function OspfPage() {
|
|||||||
}, [items, neighbors, bfdSessions])
|
}, [items, neighbors, bfdSessions])
|
||||||
|
|
||||||
// ── filtered display data ─────────────────────────────────────────────────────
|
// ── filtered display data ─────────────────────────────────────────────────────
|
||||||
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
|
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
||||||
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||||
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||||
|
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
|
||||||
|
|
||||||
|
const ospfRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
ospfServers.map((s) => {
|
||||||
|
const counts = serverCounts[s.id]
|
||||||
|
const host = s.label.replace(/^mt-/, "")
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
name: host,
|
||||||
|
host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country || undefined,
|
||||||
|
meta: counts
|
||||||
|
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
), [ospfServers, serverCounts])
|
||||||
|
|
||||||
// Graph always shows full topology (highlight is handled by node click inside tab)
|
// Graph always shows full topology (highlight is handled by node click inside tab)
|
||||||
// KPIs reflect the current filter
|
// KPIs reflect the current filter
|
||||||
@@ -1228,91 +1308,45 @@ export default function OspfPage() {
|
|||||||
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={ospfRailItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
selectedId={filterServerId}
|
||||||
actions={
|
onSelect={setFilterServerId}
|
||||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
showAll
|
||||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
allCount={ospfServers.length}
|
||||||
Обновить
|
loading={isLive && loading && ospfServers.length === 0}
|
||||||
</Button>
|
header={
|
||||||
}
|
<PageHeader
|
||||||
/>
|
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||||
|
actions={
|
||||||
<div className="border-b bg-background shrink-0">
|
<>
|
||||||
<div className="flex items-center px-6">
|
<ServerRailMobileButton />
|
||||||
{TABS.map(t => (
|
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
className={cn(
|
Обновить
|
||||||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
</Button>
|
||||||
activeTab === t.id
|
</>
|
||||||
? "border-primary text-foreground"
|
}
|
||||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
/>
|
||||||
)}>
|
}
|
||||||
{t.icon}{t.label}
|
banner={
|
||||||
</button>
|
<div className="border-b bg-background shrink-0">
|
||||||
))}
|
<div className="flex items-center px-6">
|
||||||
</div>
|
{TABS.map(t => (
|
||||||
</div>
|
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||||
|
|
||||||
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
|
|
||||||
{ospfServers.length > 0 && (
|
|
||||||
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
|
|
||||||
{/* "All" chip */}
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterServerId("all")}
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
|
||||||
filterServerId === "all"
|
|
||||||
? "bg-foreground text-background border-foreground"
|
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
|
||||||
)}>
|
|
||||||
Все серверы
|
|
||||||
<span className={cn(
|
|
||||||
"tabular-nums font-semibold",
|
|
||||||
filterServerId === "all" ? "" : "text-foreground/60",
|
|
||||||
)}>{items.length}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-px h-4 bg-border shrink-0" />
|
|
||||||
|
|
||||||
{ospfServers.map(s => {
|
|
||||||
const counts = serverCounts[s.id]
|
|
||||||
const active = filterServerId === s.id
|
|
||||||
return (
|
|
||||||
<button key={s.id} onClick={() => setFilterServerId(s.id)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||||||
active
|
activeTab === t.id
|
||||||
? "bg-foreground text-background border-foreground"
|
? "border-primary text-foreground"
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||||||
)}>
|
)}>
|
||||||
{s.country ? <Flag code={s.country} size={12} /> : null}
|
{t.icon}{t.label}
|
||||||
{s.site && (
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
|
|
||||||
active
|
|
||||||
? "bg-white/20"
|
|
||||||
: "bg-muted-foreground/15 text-foreground/70",
|
|
||||||
)}>{s.site}</span>
|
|
||||||
)}
|
|
||||||
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
|
|
||||||
{counts && (
|
|
||||||
<span className={cn(
|
|
||||||
"tabular-nums text-[10px]",
|
|
||||||
active ? "opacity-80" : "text-foreground/50",
|
|
||||||
)}>
|
|
||||||
{counts.neighbors}n · {counts.ifaces}i
|
|
||||||
{counts.bfd > 0 && ` · ${counts.bfd}b`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
>
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* data source banner */}
|
{/* data source banner */}
|
||||||
@@ -1345,21 +1379,32 @@ export default function OspfPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI strip */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-3 gap-3">
|
aria-label="Сводка OSPF"
|
||||||
{[
|
items={[
|
||||||
{ label: "Роутеров", value: totalRouters },
|
{
|
||||||
{ label: "Интерфейсов", value: totalInterfaces },
|
id: "routers",
|
||||||
{ label: "Зон (Area)", value: totalAreas },
|
label: "Роутеров",
|
||||||
].map(s => (
|
value: totalRouters,
|
||||||
<Frame key={s.label} className="h-full">
|
icon: <RouterIcon className="size-4" />,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
iconClassName: "text-muted-foreground",
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
},
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
{
|
||||||
</FramePanel>
|
id: "ifaces",
|
||||||
</Frame>
|
label: "Интерфейсов",
|
||||||
))}
|
value: totalInterfaces,
|
||||||
</div>
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "areas",
|
||||||
|
label: "Зон (Area)",
|
||||||
|
value: totalAreas,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{activeTab === "interfaces" && (
|
{activeTab === "interfaces" && (
|
||||||
<InterfacesTab
|
<InterfacesTab
|
||||||
@@ -1379,11 +1424,10 @@ export default function OspfPage() {
|
|||||||
routerIds={routerIds}
|
routerIds={routerIds}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "routes" && <RoutesTab routes={isLive ? [] : MOCK_ROUTES} />}
|
{activeTab === "routes" && <RoutesTab routes={displayRoutes} />}
|
||||||
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-32
@@ -17,6 +17,8 @@ import {
|
|||||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import {
|
import {
|
||||||
@@ -480,19 +482,25 @@ function ScheduleTab({
|
|||||||
setRules,
|
setRules,
|
||||||
serverOptions,
|
serverOptions,
|
||||||
tunnelsForServer,
|
tunnelsForServer,
|
||||||
|
defaultSrc,
|
||||||
}: {
|
}: {
|
||||||
rules: SchedRule[]
|
rules: SchedRule[]
|
||||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||||
serverOptions: Server[]
|
serverOptions: Server[]
|
||||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||||
|
defaultSrc?: string
|
||||||
}) {
|
}) {
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [addSrc, setAddSrc] = useState(serverOptions[0]?.id ?? "srv1")
|
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
|
||||||
const [addTun, setAddTun] = useState("")
|
const [addTun, setAddTun] = useState("")
|
||||||
const [addType, setAddType] = useState<SchedType>("ping")
|
const [addType, setAddType] = useState<SchedType>("ping")
|
||||||
const [addMin, setAddMin] = useState(10)
|
const [addMin, setAddMin] = useState(10)
|
||||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultSrc) setAddSrc(defaultSrc)
|
||||||
|
}, [defaultSrc])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = tunnelsForServer(addSrc)
|
const list = tunnelsForServer(addSrc)
|
||||||
if (list.length && !list.some(t => t.id === addTun)) {
|
if (list.length && !list.some(t => t.id === addTun)) {
|
||||||
@@ -630,6 +638,20 @@ export default function ProbesPage() {
|
|||||||
return liveServers
|
return liveServers
|
||||||
}, [isLive, liveServers])
|
}, [isLive, liveServers])
|
||||||
|
|
||||||
|
const probeRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
allServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
selectable: s.enabled,
|
||||||
|
}))
|
||||||
|
), [allServers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
setRosSrcV4(undefined)
|
setRosSrcV4(undefined)
|
||||||
@@ -887,24 +909,33 @@ export default function ProbesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={probeRailItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
selectedId={srcId}
|
||||||
actions={
|
onSelect={setSrcId}
|
||||||
running.length > 0
|
showAll={false}
|
||||||
? <Button variant="outline" size="sm" onClick={() => {
|
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||||
liveProbeRunRef.current?.ctrl.abort()
|
header={
|
||||||
setTests(p => p.map(t => (t.status === "running"
|
<PageHeader
|
||||||
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||||
: t)))
|
actions={
|
||||||
}}>
|
<>
|
||||||
<SquareIcon className="size-4" />Остановить все
|
<ServerRailMobileButton />
|
||||||
</Button>
|
{running.length > 0
|
||||||
: undefined
|
? <Button variant="outline" size="sm" onClick={() => {
|
||||||
}
|
liveProbeRunRef.current?.ctrl.abort()
|
||||||
/>
|
setTests(p => p.map(t => (t.status === "running"
|
||||||
|
? { ...t, status: "done" as const, totalLines: t.lines.length }
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
: t)))
|
||||||
|
}}>
|
||||||
|
<SquareIcon className="size-4" />Остановить все
|
||||||
|
</Button>
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
{isLive && liveLoad === "error" && (
|
{isLive && liveLoad === "error" && (
|
||||||
@@ -916,7 +947,7 @@ export default function ProbesPage() {
|
|||||||
|
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для «Источника» поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
Режим «Живые»: ping/traceroute/route/mtu/bandwidth выполняются на выбранном MikroTik; для источника поле src-address — только IPv4 (FQDN резолвится на бекенде). Traceroute через REST: у MikroTik лимит сессии ~60 с (параметры команды это не продлевают); у нас timeout в формате HH:MM:SS, count=1, max-hops при необходимости уменьшается автоматически. «Стоп» прерывает HTTP к бекенду. DNS — резолвер приложения, не MikroTik.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -945,16 +976,6 @@ export default function ProbesPage() {
|
|||||||
{/* main config row */}
|
{/* main config row */}
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
|
||||||
{/* source server */}
|
|
||||||
<div>
|
|
||||||
<OptionLabel>Источник</OptionLabel>
|
|
||||||
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
|
|
||||||
{allServers.filter(s => s.enabled).map(s => (
|
|
||||||
<option key={s.id} value={s.id}>{s.name}</option>
|
|
||||||
))}
|
|
||||||
</NativeSelect>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* target — all tools except bandwidth */}
|
{/* target — all tools except bandwidth */}
|
||||||
{tool !== "bandwidth" && (
|
{tool !== "bandwidth" && (
|
||||||
<div className="flex-1 min-w-[140px]">
|
<div className="flex-1 min-w-[140px]">
|
||||||
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
|
|||||||
setRules={setRules}
|
setRules={setRules}
|
||||||
serverOptions={allServers}
|
serverOptions={allServers}
|
||||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||||
|
defaultSrc={srcId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ import { Flag } from "@/components/flag"
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { servers as mockServers, type Server } from "@/lib/data"
|
import { servers as mockServers, type Server } from "@/lib/data"
|
||||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||||
|
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||||
|
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
interface BackendServer {
|
interface BackendServer {
|
||||||
id: number
|
id: number
|
||||||
@@ -362,7 +367,7 @@ export default function RecursiveRoutesPage() {
|
|||||||
const [servers, setServers] = useState<Server[]>([])
|
const [servers, setServers] = useState<Server[]>([])
|
||||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||||
@@ -371,6 +376,11 @@ export default function RecursiveRoutesPage() {
|
|||||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||||
const [opError, setOpError] = useState<string | null>(null)
|
const [opError, setOpError] = useState<string | null>(null)
|
||||||
|
const [liveStale, setLiveStale] = useState(false)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false)
|
||||||
|
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||||
|
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||||
|
|
||||||
@@ -405,10 +415,13 @@ export default function RecursiveRoutesPage() {
|
|||||||
setOpError(null)
|
setOpError(null)
|
||||||
setBusy("load")
|
setBusy("load")
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
|
const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
|
||||||
|
`/api/recursive-routes?serverId=${selectedServerId}`,
|
||||||
|
)
|
||||||
setRows(res.routes)
|
setRows(res.routes)
|
||||||
|
setLiveStale(Boolean(res.stale))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setRows([])
|
setLiveStale(true)
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
@@ -435,56 +448,65 @@ export default function RecursiveRoutesPage() {
|
|||||||
void loadGateways()
|
void loadGateways()
|
||||||
}, [loadGateways])
|
}, [loadGateways])
|
||||||
|
|
||||||
const saveToDb = useCallback(async () => {
|
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId || liveStale) return
|
||||||
|
const prev = rows
|
||||||
|
setRows(next)
|
||||||
setOpError(null)
|
setOpError(null)
|
||||||
setBusy("save")
|
setBusy("apply")
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
|
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
|
body: JSON.stringify({ serverId: selectedServerId, routes: next }),
|
||||||
})
|
})
|
||||||
await loadRoutes()
|
setRows(res.routes ?? next)
|
||||||
|
setLiveStale(false)
|
||||||
|
toast.success("Маршруты применены на роутер")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
|
setRows(prev)
|
||||||
|
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
|
||||||
|
setOpError(msg)
|
||||||
|
toast.error(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
|
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
||||||
|
|
||||||
const syncFromRouter = useCallback(async () => {
|
const loadRevisions = useCallback(async () => {
|
||||||
if (!isLive || !selectedServerId) return
|
if (!isLive || !selectedServerId) return
|
||||||
setOpError(null)
|
setHistoryLoading(true)
|
||||||
setBusy("from")
|
|
||||||
try {
|
try {
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
|
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||||
method: "POST",
|
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
)
|
||||||
})
|
setRevisions(res.revisions)
|
||||||
await loadRoutes()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
|
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
|
||||||
|
setRevisions([])
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setHistoryLoading(false)
|
||||||
}
|
|
||||||
}, [isLive, selectedServerId, apiFetch, loadRoutes])
|
|
||||||
|
|
||||||
const syncToRouter = useCallback(async () => {
|
|
||||||
if (!isLive || !selectedServerId) return
|
|
||||||
setOpError(null)
|
|
||||||
setBusy("to")
|
|
||||||
try {
|
|
||||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ serverId: selectedServerId }),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
|
|
||||||
} finally {
|
|
||||||
setBusy(null)
|
|
||||||
}
|
}
|
||||||
}, [isLive, selectedServerId, apiFetch])
|
}, [isLive, selectedServerId, apiFetch])
|
||||||
|
|
||||||
|
const restoreRevision = useCallback(async (id: string) => {
|
||||||
|
if (!isLive || !selectedServerId) return
|
||||||
|
setHistoryRestoring(true)
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
|
||||||
|
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||||
|
)
|
||||||
|
setRows(res.routes ?? [])
|
||||||
|
setLiveStale(false)
|
||||||
|
toast.success("Версия применена на роутер")
|
||||||
|
await loadRevisions()
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
|
||||||
|
} finally {
|
||||||
|
setHistoryRestoring(false)
|
||||||
|
}
|
||||||
|
}, [isLive, selectedServerId, apiFetch, loadRevisions])
|
||||||
|
|
||||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||||
return row.dstAddress.trim().toLowerCase()
|
return row.dstAddress.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
@@ -527,22 +549,38 @@ export default function RecursiveRoutesPage() {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
country: ep.country || inferCountry(ep.gateway) || "",
|
country: ep.country || inferCountry(ep.gateway) || "",
|
||||||
})
|
})
|
||||||
|
let next: RecursiveRouteRow[]
|
||||||
if (sheetMode === "create") {
|
if (sheetMode === "create") {
|
||||||
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
setRows(prev => [...prev, ...expanded])
|
next = [...rows, ...expanded]
|
||||||
} else if (editingGroupKey) {
|
} else if (editingGroupKey) {
|
||||||
setRows(prev => {
|
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||||
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
|
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
next = [...kept, ...expanded]
|
||||||
return [...kept, ...expanded]
|
} else {
|
||||||
})
|
setSheetOpen(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
setSheetOpen(false)
|
setSheetOpen(false)
|
||||||
|
void applyRoutes(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||||
|
const mutationsLocked = !isLive || busy !== null || liveStale
|
||||||
|
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
servers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
}))
|
||||||
|
), [servers])
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return rows
|
if (!q) return rows
|
||||||
@@ -574,82 +612,87 @@ export default function RecursiveRoutesPage() {
|
|||||||
}, [filteredRows])
|
}, [filteredRows])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
items={rrRailItems}
|
||||||
actions={
|
selectedId={selectedServerId}
|
||||||
<>
|
onSelect={setSelectedServerId}
|
||||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
showAll={false}
|
||||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
showCount={false}
|
||||||
</Button>
|
loading={isLive && !liveServerListReady}
|
||||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
header={
|
||||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
<PageHeader
|
||||||
</Button>
|
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
actions={
|
||||||
<SaveIcon className="size-4" />Сохранить в БД
|
<>
|
||||||
</Button>
|
<ServerRailMobileButton />
|
||||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
{isLive && (
|
||||||
<PlusIcon className="size-4" />Добавить
|
<>
|
||||||
</Button>
|
<Button
|
||||||
</>
|
variant="outline"
|
||||||
}
|
size="sm"
|
||||||
/>
|
onClick={() => void loadRoutes()}
|
||||||
|
disabled={busy !== null}
|
||||||
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
|
title="Прочитать маршруты с роутера"
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
>
|
||||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
Обновить
|
||||||
</div>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
<Button
|
||||||
{servers.map((s) => {
|
variant="outline"
|
||||||
const count = s.id === selectedServerId ? rows.length : 0
|
size="sm"
|
||||||
const active = selectedServerId === s.id
|
onClick={() => {
|
||||||
return (
|
setHistoryOpen(true)
|
||||||
<button key={s.id}
|
void loadRevisions()
|
||||||
onClick={() => setSelectedServerId(s.id)}
|
}}
|
||||||
className={cn(
|
disabled={busy !== null}
|
||||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
|
>
|
||||||
active
|
<HistoryIcon className="size-4" />
|
||||||
? "bg-foreground text-background border-foreground"
|
История
|
||||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
</Button>
|
||||||
!s.enabled && !active && "opacity-40",
|
</>
|
||||||
)}>
|
)}
|
||||||
<StatusDot status={s.status} />
|
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||||
<Flag code={s.country} size={12} />
|
<PlusIcon className="size-4" />Добавить
|
||||||
<span className="font-mono">{s.name}</span>
|
</Button>
|
||||||
<TypeChip type={s.type} />
|
</>
|
||||||
<span className={cn(
|
}
|
||||||
"tabular-nums font-semibold",
|
/>
|
||||||
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
|
}
|
||||||
)}>{count}</span>
|
banner={
|
||||||
</button>
|
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||||
)
|
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||||
})}
|
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||||
</div>
|
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||||
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
|
{search && (
|
||||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
<button onClick={() => setSearch("")}
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
<XIcon className="size-3.5" />
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
</button>
|
||||||
{search && (
|
)}
|
||||||
<button onClick={() => setSearch("")}
|
</div>
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<XIcon className="size-3.5" />
|
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||||
</button>
|
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground ml-auto">
|
||||||
|
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||||
|
</p>
|
||||||
|
{opError && (
|
||||||
|
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||||
|
{opError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{liveStale && (
|
||||||
|
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||||
|
Роутер недоступен — показан кэш. Изменения заблокированы.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground ml-auto">
|
}
|
||||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
>
|
||||||
</p>
|
|
||||||
{opError && (
|
|
||||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
|
||||||
{opError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
|
||||||
{!isLive ? (
|
{!isLive ? (
|
||||||
<Frame dense className="w-full">
|
<Frame dense className="w-full">
|
||||||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||||
@@ -683,16 +726,19 @@ export default function RecursiveRoutesPage() {
|
|||||||
expandedKey={expandedGroupKey}
|
expandedKey={expandedGroupKey}
|
||||||
onExpandedChange={setExpandedGroupKey}
|
onExpandedChange={setExpandedGroupKey}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
onDelete={(g) => {
|
||||||
|
if (mutationsLocked) return
|
||||||
|
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button onClick={openCreate}
|
<button onClick={openCreate} disabled={mutationsLocked}
|
||||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||||||
<PlusIcon className="size-3.5" />
|
<PlusIcon className="size-3.5" />
|
||||||
Добавить маршрут
|
Добавить маршрут
|
||||||
</button>
|
</button>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<RouteSheet
|
<RouteSheet
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
@@ -702,6 +748,17 @@ export default function RecursiveRoutesPage() {
|
|||||||
onClose={() => setSheetOpen(false)}
|
onClose={() => setSheetOpen(false)}
|
||||||
gateways={gatewayOptions}
|
gateways={gatewayOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
<ConfigHistorySheet
|
||||||
|
open={historyOpen}
|
||||||
|
onOpenChange={setHistoryOpen}
|
||||||
|
title="История маршрутов"
|
||||||
|
itemLabel="маршрутов"
|
||||||
|
revisions={revisions}
|
||||||
|
loading={historyLoading}
|
||||||
|
restoring={historyRestoring}
|
||||||
|
onRestore={restoreRevision}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-op
|
|||||||
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
|
||||||
import { FormToggle } from "@/components/form-kit"
|
import { FormToggle } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
|
|||||||
label: "Home роутеров",
|
label: "Home роутеров",
|
||||||
value: homeCount,
|
value: homeCount,
|
||||||
sub: `${wanCount} WAN-аплинков`,
|
sub: `${wanCount} WAN-аплинков`,
|
||||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
icon: <MonitorIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "JumpHost",
|
label: "JumpHost",
|
||||||
value: jh.length,
|
value: jh.length,
|
||||||
sub: jhSub,
|
sub: jhSub,
|
||||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
icon: <ServerIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Exit Node",
|
label: "Exit Node",
|
||||||
value: ex.length,
|
value: ex.length,
|
||||||
sub: exSub,
|
sub: exSub,
|
||||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
icon: <NetworkIcon className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Переключений",
|
label: "Переключений",
|
||||||
value: totalSwitches,
|
value: totalSwitches,
|
||||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||||
icon: (
|
icon: <ZapIcon className="size-4" />,
|
||||||
<ZapIcon
|
|
||||||
className={cn(
|
|
||||||
"size-4",
|
|
||||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||||
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats chips */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
aria-label="Сводка оптимизатора"
|
||||||
{statsChips.map((s) => (
|
items={statsChips.map((s, i) => ({
|
||||||
<Frame key={s.label} className="h-full">
|
id: `ro-${i}`,
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
label: s.label,
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
value: s.value,
|
||||||
{s.icon}
|
hint: s.sub,
|
||||||
</IconTile>
|
icon: s.icon,
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
iconClassName: s.label === "Переключений" && totalSwitches > 0
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
? "text-warning"
|
||||||
<p className="text-xl leading-none font-bold tabular-nums">{s.value}</p>
|
: s.label === "Exit Node"
|
||||||
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
? "text-success"
|
||||||
</div>
|
: s.label === "JumpHost"
|
||||||
</FramePanel>
|
? "text-primary"
|
||||||
</Frame>
|
: "text-muted-foreground",
|
||||||
))}
|
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
|
||||||
</div>
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||||
|
|||||||
+34
-23
@@ -27,8 +27,7 @@ import {
|
|||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -491,27 +490,39 @@ export default function ServersPage() {
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* Stats */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
aria-label="Сводка серверов"
|
||||||
{[
|
items={[
|
||||||
{ label: "Всего серверов", value: counts.all, icon: <ServerIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
{
|
||||||
{ label: "Онлайн", value: counts.online, icon: <CheckCircleIcon className="size-4" />, iconClass: "text-[var(--status-online-fg)]" },
|
id: "all",
|
||||||
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
label: "Всего серверов",
|
||||||
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
value: counts.all,
|
||||||
].map(s => (
|
icon: <ServerIcon className="size-4" />,
|
||||||
<Frame key={s.label} className="h-full">
|
iconClassName: "text-muted-foreground",
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
},
|
||||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", s.iconClass)}>
|
{
|
||||||
{s.icon}
|
id: "online",
|
||||||
</IconTile>
|
label: "Онлайн",
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
value: counts.online,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
icon: <CheckCircleIcon className="size-4" />,
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
iconClassName: "text-success",
|
||||||
</div>
|
},
|
||||||
</FramePanel>
|
{
|
||||||
</Frame>
|
id: "jh-en",
|
||||||
))}
|
label: "JH + Exit Node",
|
||||||
</div>
|
value: counts["jump-host"] + counts["exit-node"],
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
label: "Home Router",
|
||||||
|
value: counts["home-router"],
|
||||||
|
icon: <HomeIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
|
|||||||
+103
-830
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
DatabaseIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
ServerIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
|
import { SegmentedControl } from "@/components/form-kit"
|
||||||
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||||
|
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
||||||
|
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
|
||||||
|
import { SliceChips } from "@/components/statistics/slice-chips"
|
||||||
|
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
|
||||||
|
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
|
||||||
|
import {
|
||||||
|
StatisticsBreakdownDataGrid,
|
||||||
|
type StatisticsSliceKind,
|
||||||
|
} from "@/components/data-grids/statistics-breakdown-data-grid"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||||
|
import type { Filter } from "@/components/reui/filters"
|
||||||
|
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
|
||||||
|
import {
|
||||||
|
isStatisticsPivotDim,
|
||||||
|
isStatisticsSliceKind,
|
||||||
|
STATISTICS_DIMS,
|
||||||
|
} from "@/lib/statistics-dims"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||||
|
import {
|
||||||
|
getStatistics,
|
||||||
|
getStatisticsPivot,
|
||||||
|
STATISTICS_UNBOUND_USER_ID,
|
||||||
|
type StatisticsDto,
|
||||||
|
type StatisticsPivotDto,
|
||||||
|
type StatisticsQuery,
|
||||||
|
} from "@/shared/api/statistics"
|
||||||
|
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BI-куб трафика: критерий → остальные разрезы + pivot.
|
||||||
|
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||||
|
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
|
||||||
|
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMPTY: StatisticsDto = {
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
grain: "day",
|
||||||
|
kpis: {
|
||||||
|
bytes: 0,
|
||||||
|
packets: 0,
|
||||||
|
avgBps: 0,
|
||||||
|
users: 0,
|
||||||
|
servers: 0,
|
||||||
|
ifaces: 0,
|
||||||
|
topCountry: "",
|
||||||
|
topService: "",
|
||||||
|
},
|
||||||
|
series: [],
|
||||||
|
users: [],
|
||||||
|
servers: [],
|
||||||
|
interfaces: [],
|
||||||
|
countries: [],
|
||||||
|
services: [],
|
||||||
|
asns: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PIVOT: StatisticsPivotDto = {
|
||||||
|
rowDim: "country",
|
||||||
|
colDim: "service",
|
||||||
|
metric: "bytes",
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
otherBytes: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CubeSlices {
|
||||||
|
country?: string
|
||||||
|
service?: string
|
||||||
|
asn?: string
|
||||||
|
serverId?: string
|
||||||
|
userId?: string
|
||||||
|
iface?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const
|
||||||
|
|
||||||
|
function readRange(sp: URLSearchParams): DateRangeYmd {
|
||||||
|
const from = sp.get("from")
|
||||||
|
const to = sp.get("to")
|
||||||
|
if (from && to && from <= to) return { from, to }
|
||||||
|
return rangeForPreset("7d")
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDim(sp: URLSearchParams): StatisticsSliceKind {
|
||||||
|
const t = sp.get("dim") ?? sp.get("tab")
|
||||||
|
return t && isStatisticsSliceKind(t) ? t : "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readView(sp: URLSearchParams): "explore" | "pivot" {
|
||||||
|
return sp.get("view") === "pivot" ? "pivot" : "explore"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPlanes(sp: URLSearchParams): "unique" | "all" {
|
||||||
|
return sp.get("planes") === "all" ? "all" : "unique"
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
|
||||||
|
const v = sp.get(key)
|
||||||
|
return v && isStatisticsPivotDim(v) ? v : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSlices(sp: URLSearchParams): CubeSlices {
|
||||||
|
const next: CubeSlices = {}
|
||||||
|
for (const key of SLICE_KEYS) {
|
||||||
|
const v = sp.get(key)?.trim()
|
||||||
|
if (v) next[key] = v
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function slicesToFilters(slices: CubeSlices): Filter[] {
|
||||||
|
return SLICE_KEYS.flatMap((key) => {
|
||||||
|
const val = slices[key]
|
||||||
|
if (!val) return []
|
||||||
|
return [{ id: key, field: key, operator: "is", values: [val] }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersToSlices(filters: Filter[]): CubeSlices {
|
||||||
|
const next: CubeSlices = {}
|
||||||
|
for (const f of filters) {
|
||||||
|
const raw = String(f.values[0] ?? "").trim()
|
||||||
|
if (!raw) continue
|
||||||
|
if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2)
|
||||||
|
else if (f.field === "service") next.service = raw
|
||||||
|
else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "")
|
||||||
|
else if (f.field === "serverId") next.serverId = raw
|
||||||
|
else if (f.field === "userId") next.userId = raw
|
||||||
|
else if (f.field === "iface") next.iface = raw
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery {
|
||||||
|
const serverId = slices.serverId ? Number(slices.serverId) : undefined
|
||||||
|
const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined
|
||||||
|
return {
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined,
|
||||||
|
userId: slices.userId,
|
||||||
|
iface: slices.iface,
|
||||||
|
country: slices.country && slices.country.length === 2 ? slices.country : undefined,
|
||||||
|
service: slices.service,
|
||||||
|
asn: Number.isFinite(asn) ? asn : undefined,
|
||||||
|
planes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
|
||||||
|
if (kind === "users") return slices.userId
|
||||||
|
if (kind === "servers") return slices.serverId
|
||||||
|
if (kind === "countries") return slices.country
|
||||||
|
if (kind === "services") return slices.service
|
||||||
|
if (kind === "asns") return slices.asn
|
||||||
|
if (kind === "interfaces" && slices.serverId && slices.iface) {
|
||||||
|
return `${slices.serverId}:${slices.iface}`
|
||||||
|
}
|
||||||
|
if (kind === "interfaces") return slices.iface
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
|
||||||
|
if (kind === "users") return data.users
|
||||||
|
if (kind === "servers") return data.servers
|
||||||
|
if (kind === "interfaces") return data.interfaces
|
||||||
|
if (kind === "countries") return data.countries
|
||||||
|
if (kind === "services") return data.services
|
||||||
|
return data.asns
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnySlice(slices: CubeSlices): boolean {
|
||||||
|
return SLICE_KEYS.some((k) => Boolean(slices[k]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
|
||||||
|
const hidden = new Set<StatisticsSliceKind>()
|
||||||
|
if (slices.userId) hidden.add("users")
|
||||||
|
if (slices.serverId) hidden.add("servers")
|
||||||
|
if (slices.iface) hidden.add("interfaces")
|
||||||
|
if (slices.country) hidden.add("countries")
|
||||||
|
if (slices.service) hidden.add("services")
|
||||||
|
if (slices.asn) hidden.add("asns")
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
|
||||||
|
const next: CubeSlices = { ...slices }
|
||||||
|
if (kind === "users") {
|
||||||
|
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
|
||||||
|
if (next.userId === rowId) delete next.userId
|
||||||
|
else next.userId = rowId
|
||||||
|
} else if (kind === "servers") {
|
||||||
|
if (next.serverId === rowId) delete next.serverId
|
||||||
|
else next.serverId = rowId
|
||||||
|
} else if (kind === "countries") {
|
||||||
|
if (next.country === rowId) delete next.country
|
||||||
|
else next.country = rowId
|
||||||
|
} else if (kind === "services") {
|
||||||
|
if (next.service === rowId) delete next.service
|
||||||
|
else next.service = rowId
|
||||||
|
} else if (kind === "asns") {
|
||||||
|
if (next.asn === rowId) delete next.asn
|
||||||
|
else next.asn = rowId
|
||||||
|
} else {
|
||||||
|
const colon = rowId.indexOf(":")
|
||||||
|
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
|
||||||
|
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
|
||||||
|
if (next.iface === iface && next.serverId === sid) {
|
||||||
|
delete next.iface
|
||||||
|
delete next.serverId
|
||||||
|
} else {
|
||||||
|
next.iface = iface
|
||||||
|
if (sid) next.serverId = sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
|
||||||
|
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
|
||||||
|
return applyDimValue(slices, kind, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipList(slices: CubeSlices): { key: string; label: string }[] {
|
||||||
|
const chips: { key: string; label: string }[] = []
|
||||||
|
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
|
||||||
|
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
|
||||||
|
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
|
||||||
|
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
|
||||||
|
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
|
||||||
|
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
|
||||||
|
return chips
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatisticsPageInner() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const range = useMemo(() => readRange(searchParams), [searchParams])
|
||||||
|
const slices = useMemo(() => readSlices(searchParams), [searchParams])
|
||||||
|
const filters = useMemo(() => slicesToFilters(slices), [slices])
|
||||||
|
const dim = useMemo(() => readDim(searchParams), [searchParams])
|
||||||
|
const view = useMemo(() => readView(searchParams), [searchParams])
|
||||||
|
const planes = useMemo(() => readPlanes(searchParams), [searchParams])
|
||||||
|
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
|
||||||
|
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
|
||||||
|
|
||||||
|
const [data, setData] = useState<StatisticsDto>(EMPTY)
|
||||||
|
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const replaceParams = useCallback(
|
||||||
|
(patch: Record<string, string | undefined>) => {
|
||||||
|
const sp = new URLSearchParams(searchParams.toString())
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v) sp.set(k, v)
|
||||||
|
else sp.delete(k)
|
||||||
|
}
|
||||||
|
const qs = sp.toString()
|
||||||
|
if (qs === searchParams.toString()) return
|
||||||
|
window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics")
|
||||||
|
},
|
||||||
|
[searchParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setRange = useCallback(
|
||||||
|
(next: DateRangeYmd) => {
|
||||||
|
replaceParams({ from: next.from, to: next.to })
|
||||||
|
},
|
||||||
|
[replaceParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setSlices = useCallback(
|
||||||
|
(next: CubeSlices) => {
|
||||||
|
replaceParams({
|
||||||
|
country: next.country,
|
||||||
|
service: next.service,
|
||||||
|
asn: next.asn,
|
||||||
|
serverId: next.serverId,
|
||||||
|
userId: next.userId,
|
||||||
|
iface: next.iface,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[replaceParams],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefsHydrated || !isLive) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const query = toQuery(range, slices, planes)
|
||||||
|
const dto = await getStatistics(backendUrl, query)
|
||||||
|
if (!cancelled) setData(dto)
|
||||||
|
if (view === "pivot" && pivotRow !== pivotCol) {
|
||||||
|
const matrix = await getStatisticsPivot(backendUrl, {
|
||||||
|
...query,
|
||||||
|
row: pivotRow,
|
||||||
|
col: pivotCol,
|
||||||
|
metric: "bytes",
|
||||||
|
})
|
||||||
|
if (!cancelled) setPivot(matrix)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(EMPTY)
|
||||||
|
setPivot(EMPTY_PIVOT)
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes])
|
||||||
|
|
||||||
|
const viewData = isLive ? data : EMPTY
|
||||||
|
const sliced = hasAnySlice(slices)
|
||||||
|
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0)
|
||||||
|
|
||||||
|
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
|
||||||
|
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
|
||||||
|
if (kind === "interfaces" && row.label.includes("· дубль")) return
|
||||||
|
setSlices(applyDimValue(slices, kind, row.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePivotCell(rowId: string, colId: string) {
|
||||||
|
if (rowId === "__other__" || colId === "__other__") return
|
||||||
|
let next = applyPivotDim(slices, pivotRow, rowId)
|
||||||
|
next = applyPivotDim(next, pivotCol, colId)
|
||||||
|
replaceParams({
|
||||||
|
country: next.country,
|
||||||
|
service: next.service,
|
||||||
|
asn: next.asn,
|
||||||
|
serverId: next.serverId,
|
||||||
|
userId: next.userId,
|
||||||
|
iface: next.iface,
|
||||||
|
view: "explore",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpis = viewData.kpis
|
||||||
|
const chips = chipList(slices)
|
||||||
|
const countLabel =
|
||||||
|
view === "pivot"
|
||||||
|
? `${pivot.rows.length} × ${pivot.columns.length}`
|
||||||
|
: sliced
|
||||||
|
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
|
||||||
|
: `${rowsForKind(viewData, dim).length} строк`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||||||
|
actions={<PeriodSelector range={range} onChange={setRange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
{!isLive ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Живые данные выключены</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!slices.serverId && isLive && !emptyCube ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Уникальный объём</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка трафика"
|
||||||
|
isLoading={loading}
|
||||||
|
skeletonCount={5}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
label: "Объём",
|
||||||
|
value: formatBytes(kpis.bytes),
|
||||||
|
hint: "GRE/WG клиентов, без hops",
|
||||||
|
icon: <DatabaseIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "packets",
|
||||||
|
label: "Пакеты",
|
||||||
|
value: kpis.packets.toLocaleString("ru-RU"),
|
||||||
|
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bps",
|
||||||
|
label: "Средний bitrate",
|
||||||
|
value: fmtBps(kpis.avgBps),
|
||||||
|
icon: <GaugeIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
label: "Пользователи",
|
||||||
|
value: String(kpis.users),
|
||||||
|
icon: <UsersIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверы",
|
||||||
|
value: String(kpis.servers),
|
||||||
|
hint: slices.serverId
|
||||||
|
? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined)
|
||||||
|
: planes === "all"
|
||||||
|
? "WAN и дубли в списке"
|
||||||
|
: "без WAN и overlay",
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
|
||||||
|
|
||||||
|
<DataPageCard>
|
||||||
|
<DataPageToolbar
|
||||||
|
leading={
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SegmentedControl
|
||||||
|
value={view}
|
||||||
|
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })}
|
||||||
|
options={[
|
||||||
|
{ value: "explore", label: "Разрез" },
|
||||||
|
{ value: "pivot", label: "Сводка" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
value={planes}
|
||||||
|
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })}
|
||||||
|
options={[
|
||||||
|
{ value: "unique", label: "Уникальный" },
|
||||||
|
{ value: "all", label: "Все плоскости" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{view === "explore" && !sliced ? (
|
||||||
|
<DimensionSelect
|
||||||
|
label="Критерий"
|
||||||
|
value={dim}
|
||||||
|
onChange={(next) => replaceParams({ dim: next })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{view === "pivot" ? (
|
||||||
|
<>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Строки"
|
||||||
|
value={pivotRow}
|
||||||
|
exclude={pivotCol}
|
||||||
|
onChange={(next) => replaceParams({ pivotRow: next })}
|
||||||
|
/>
|
||||||
|
<PivotDimSelect
|
||||||
|
label="Колонки"
|
||||||
|
value={pivotCol}
|
||||||
|
exclude={pivotRow}
|
||||||
|
onChange={(next) => replaceParams({ pivotCol: next })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
|
||||||
|
filterFields={STATISTICS_FILTER_FIELDS}
|
||||||
|
countLabel={countLabel}
|
||||||
|
/>
|
||||||
|
<SliceChips
|
||||||
|
chips={chips}
|
||||||
|
onRemove={(key) => {
|
||||||
|
const next = { ...slices }
|
||||||
|
delete next[key as keyof CubeSlices]
|
||||||
|
setSlices(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{emptyCube ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Нет данных куба"
|
||||||
|
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
|
||||||
|
/>
|
||||||
|
) : view === "pivot" ? (
|
||||||
|
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
|
||||||
|
) : sliced ? (
|
||||||
|
<BreakdownDashboard
|
||||||
|
data={viewData}
|
||||||
|
hidden={hiddenKinds(slices)}
|
||||||
|
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatisticsBreakdownDataGrid
|
||||||
|
rows={rowsForKind(viewData, dim)}
|
||||||
|
kind={dim}
|
||||||
|
selectedId={selectedIdForKind(dim, slices)}
|
||||||
|
onRowClick={(row) => handleRowClick(dim, row)}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatisticsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<StatisticsPageInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
+121
-119
@@ -4,8 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { servers as mockServers } from "@/lib/data"
|
import { servers as mockServers } from "@/lib/data"
|
||||||
import { Flag } from "@/components/flag"
|
import type { ServerStatus } from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from "@/components/reui/frame"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import {
|
||||||
|
type ServerTileItem,
|
||||||
|
} from "@/components/server-tile-rail"
|
||||||
import {
|
import {
|
||||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -259,12 +273,14 @@ function Terminal({
|
|||||||
if (isLive && server.backendId !== null) {
|
if (isLive && server.backendId !== null) {
|
||||||
setExecuting(true)
|
setExecuting(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
const data = await requestJson<{ output?: string; error?: string }>(
|
||||||
method: "POST",
|
backendUrl,
|
||||||
headers: { "Content-Type": "application/json" },
|
`/api/servers/${server.backendId}/exec`,
|
||||||
body: JSON.stringify({ command: cmd }),
|
{
|
||||||
})
|
method: "POST",
|
||||||
const data = await res.json() as { output?: string; error?: string }
|
body: JSON.stringify({ command: cmd }),
|
||||||
|
},
|
||||||
|
)
|
||||||
const text = data.output ?? data.error ?? "(empty response)"
|
const text = data.output ?? data.error ?? "(empty response)"
|
||||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||||
text.split("\n").forEach(line =>
|
text.split("\n").forEach(line =>
|
||||||
@@ -427,7 +443,7 @@ interface BackendServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalPage() {
|
export default function TerminalPage() {
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
// Server list state
|
// Server list state
|
||||||
@@ -437,14 +453,13 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
// Load servers from backend when in live mode
|
// Load servers from backend when in live mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return
|
if (!isLive || !prefsHydrated) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setServersLoading(true)
|
setServersLoading(true)
|
||||||
fetch(`${backendUrl}/api/servers`)
|
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||||
.then(r => r.json() as Promise<BackendServer[]>)
|
.then((data) => {
|
||||||
.then(data => {
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLiveServers(data.map(s => ({
|
setLiveServers(data.map(s => ({
|
||||||
uid: String(s.id),
|
uid: String(s.id),
|
||||||
@@ -462,7 +477,7 @@ export default function TerminalPage() {
|
|||||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [isLive, backendUrl, refreshKey])
|
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||||
|
|
||||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||||
|
|
||||||
@@ -490,6 +505,64 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => {
|
||||||
|
return termServers.map((s) => ({
|
||||||
|
id: s.uid,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
country: s.country || undefined,
|
||||||
|
status: (s.status ?? undefined) as ServerStatus | undefined,
|
||||||
|
enabled: s.enabled,
|
||||||
|
selectable: s.enabled && s.status !== "offline",
|
||||||
|
title: [s.name, s.host].filter(Boolean).join(" · "),
|
||||||
|
}))
|
||||||
|
}, [termServers])
|
||||||
|
|
||||||
|
const handleSelectServer = useCallback((id: string) => {
|
||||||
|
setSelectedUid(id)
|
||||||
|
setRefreshKey((k) => k + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const railHeaderRight = isLive && !serversLoading
|
||||||
|
? <Badge variant="success-light" size="xs">LIVE</Badge>
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
function QuickCmds() {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="min-h-0 shrink-0">
|
||||||
|
<FramePanel className="flex max-h-56 flex-col gap-0 p-0">
|
||||||
|
<FrameHeader className="border-b px-3 py-2">
|
||||||
|
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Быстрые команды
|
||||||
|
</FrameTitle>
|
||||||
|
</FrameHeader>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-0.5 p-1.5">
|
||||||
|
{QUICK_CMDS.map(({ cmd, label }) => (
|
||||||
|
<button
|
||||||
|
key={cmd}
|
||||||
|
type="button"
|
||||||
|
className="truncate rounded-md px-2 py-1.5 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() => injectCommand(cmd)}
|
||||||
|
title={cmd}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
<FrameFooter className="border-t text-[10px] text-muted-foreground/70">
|
||||||
|
<p>↑↓ — история команд</p>
|
||||||
|
<p>Ctrl+L — очистить экран</p>
|
||||||
|
{isLive
|
||||||
|
? <p className="text-info">Команды выполняются на роутере</p>
|
||||||
|
: <p>Режим: mock-данные</p>}
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function injectCommand(cmd: string) {
|
function injectCommand(cmd: string) {
|
||||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -500,108 +573,39 @@ export default function TerminalPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<ServerRailLayout
|
||||||
<PageHeader
|
items={railItems}
|
||||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
selectedId={selected?.uid ?? selectedUid}
|
||||||
actions={
|
onSelect={handleSelectServer}
|
||||||
<Button variant="outline" size="sm" onClick={() => {
|
showAll={false}
|
||||||
setRefreshKey(k => k + 1)
|
showCount={false}
|
||||||
if (isLive) setSelectedUid("")
|
showType={false}
|
||||||
}}>
|
headerRight={railHeaderRight}
|
||||||
<RefreshCwIcon className="size-4" />Переподключить
|
loading={serversLoading}
|
||||||
</Button>
|
extra={<QuickCmds />}
|
||||||
}
|
contentClassName="overflow-hidden p-3 md:p-4"
|
||||||
/>
|
header={
|
||||||
|
<PageHeader
|
||||||
<div className="flex-1 overflow-hidden p-6">
|
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
actions={
|
||||||
|
<>
|
||||||
{/* ── sidebar ── */}
|
<ServerRailMobileButton />
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
<Button
|
||||||
|
variant="outline"
|
||||||
{/* server picker */}
|
size="sm"
|
||||||
<div>
|
onClick={() => {
|
||||||
<div className="flex items-center gap-2 mb-2">
|
setRefreshKey((k) => k + 1)
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
if (isLive) setSelectedUid("")
|
||||||
{isLive && serversLoading && (
|
}}
|
||||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
>
|
||||||
)}
|
<RefreshCwIcon className="size-4" />
|
||||||
{isLive && !serversLoading && (
|
Переподключить
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
|
</Button>
|
||||||
<span className="size-1 rounded-full bg-emerald-400" />LIVE
|
</>
|
||||||
</span>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{isLive && !serversLoading && liveServers.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground px-2.5">
|
|
||||||
Нет доступных серверов
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{termServers.map(s => {
|
|
||||||
const isOffline = s.status === "offline"
|
|
||||||
const isSelected = s.uid === selectedUid
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s.uid}
|
|
||||||
disabled={isOffline || !s.enabled}
|
|
||||||
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
|
|
||||||
"flex items-center gap-2",
|
|
||||||
isSelected
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted",
|
|
||||||
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"inline-block size-1.5 rounded-full shrink-0",
|
|
||||||
s.status === "online" ? "bg-emerald-500" :
|
|
||||||
s.status === "degraded" ? "bg-amber-400" :
|
|
||||||
s.status === null ? "bg-sky-400" : "bg-red-500",
|
|
||||||
)} />
|
|
||||||
{s.country && <Flag code={s.country} />}
|
|
||||||
<span className="truncate font-mono">{s.name}</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* quick commands */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
||||||
Быстрые команды
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{QUICK_CMDS.map(({ cmd, label }) => (
|
|
||||||
<button
|
|
||||||
key={cmd}
|
|
||||||
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
|
|
||||||
onClick={() => injectCommand(cmd)}
|
|
||||||
title={cmd}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* hints */}
|
|
||||||
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
|
|
||||||
<p>↑↓ — история команд</p>
|
|
||||||
<p>Ctrl+L — очистить экран</p>
|
|
||||||
{isLive
|
|
||||||
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
|
|
||||||
: <p>Режим: mock-данные</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── terminal ── */}
|
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<Terminal
|
<Terminal
|
||||||
key={termKey}
|
key={termKey}
|
||||||
@@ -610,12 +614,10 @@ export default function TerminalPage() {
|
|||||||
backendUrl={backendUrl}
|
backendUrl={backendUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
|
<div className="flex h-full items-center justify-center rounded-lg border border-[#30363d] bg-[#0d1117] font-mono text-sm text-[#8b949e]">
|
||||||
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+691
-315
File diff suppressed because it is too large
Load Diff
+89
-46
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
|||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
<KpiStatGrid
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
aria-label="Сводка ресурсов"
|
||||||
{[
|
items={[
|
||||||
{ icon: <ServerIcon className="size-4 text-muted-foreground" />, label: String(rows.length), sub: "серверов всего", color: "text-foreground" },
|
{
|
||||||
{ icon: <CpuIcon className="size-4" />, label: `${avgCpu}%`, sub: "средний CPU", color: resPctColor(avgCpu) },
|
id: "servers",
|
||||||
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
|
label: "Серверов всего",
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
|
value: rows.length,
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
icon: <ServerIcon className="size-4" />,
|
||||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
iconClassName: "text-muted-foreground",
|
||||||
].map(kpi => (
|
},
|
||||||
<Frame key={kpi.sub} className="h-full">
|
{
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
id: "cpu",
|
||||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
label: "Средний CPU",
|
||||||
{kpi.icon}
|
value: `${avgCpu}%`,
|
||||||
</IconTile>
|
icon: <CpuIcon className="size-4" />,
|
||||||
<div className="min-w-0 flex flex-col gap-0.5">
|
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
|
||||||
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
|
||||||
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
},
|
||||||
</div>
|
{
|
||||||
</FramePanel>
|
id: "ram",
|
||||||
</Frame>
|
label: "Средний RAM",
|
||||||
))}
|
value: `${avgRam}%`,
|
||||||
</div>
|
icon: <HardDriveIcon className="size-4" />,
|
||||||
|
iconClassName: avgRam >= 85 ? "text-destructive" : avgRam >= 70 ? "text-warning" : "text-success",
|
||||||
|
variant: avgRam >= 85 ? "destructive" : avgRam >= 70 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-cpu",
|
||||||
|
label: "CPU > 85%",
|
||||||
|
value: highCpu,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: highCpu > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: highCpu > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-ram",
|
||||||
|
label: "RAM > 85%",
|
||||||
|
value: highRam,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: highRam > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
|
variant: highRam > 0 ? "destructive" : "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "high-hdd",
|
||||||
|
label: "Диск > 85%",
|
||||||
|
value: highHdd,
|
||||||
|
icon: <AlertCircleIcon className="size-4" />,
|
||||||
|
iconClassName: highHdd > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
|
variant: highHdd > 0 ? "warning" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
{/* ── Table ─────────────────────────────────────────────────────────── */}
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
|
|||||||
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
||||||
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
|
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||||
{[
|
<KpiStatGrid
|
||||||
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
|
aria-label="Сводка speed-проб"
|
||||||
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
|
items={[
|
||||||
{ label: "Макс TX", value: maxTx != null ? `${maxTx}` : "—", unit: maxTx != null ? "Мбит/с" : "", color: "text-[var(--chart-tx)]" },
|
{
|
||||||
{ label: "Макс RX", value: maxRx != null ? `${maxRx}` : "—", unit: maxRx != null ? "Мбит/с" : "", color: "text-[var(--chart-rx)]" },
|
id: "probes",
|
||||||
].map(k => (
|
label: "Speed-пробы",
|
||||||
<Frame key={k.label} className="h-full">
|
value: `${speedProbes.length} шт`,
|
||||||
<FramePanel className="flex flex-col gap-0.5">
|
icon: <ArrowUpDownIcon className="size-4" />,
|
||||||
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
iconClassName: "text-muted-foreground",
|
||||||
<div className="flex items-baseline gap-1">
|
},
|
||||||
<span className={cn("text-2xl leading-none font-bold tabular-nums", k.color)}>{k.value}</span>
|
{
|
||||||
{k.unit && <span className="text-xs text-muted-foreground">{k.unit}</span>}
|
id: "runs",
|
||||||
</div>
|
label: "Тестов выполнено",
|
||||||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
value: `${doneRuns.length} run`,
|
||||||
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
|
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
|
||||||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
icon: <PlayIcon className="size-4" />,
|
||||||
</p>
|
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
|
||||||
)}
|
variant: runningCnt > 0 ? "warning" : "default",
|
||||||
</FramePanel>
|
},
|
||||||
</Frame>
|
{
|
||||||
))}
|
id: "max-tx",
|
||||||
|
label: "Макс TX",
|
||||||
|
value: maxTx != null ? `${maxTx} Мбит/с` : "—",
|
||||||
|
icon: <ArrowUpIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "max-rx",
|
||||||
|
label: "Макс RX",
|
||||||
|
value: maxRx != null ? `${maxRx} Мбит/с` : "—",
|
||||||
|
icon: <ArrowDownIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
|
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||||
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
|
import { UserSheet } from "@/components/users/user-sheet"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import { servers as mockServers } from "@/lib/data"
|
||||||
|
import { useDataSource } from "@/lib/data-source"
|
||||||
|
import {
|
||||||
|
ALL_SECTIONS,
|
||||||
|
INIT_USERS,
|
||||||
|
bindingDiffKey,
|
||||||
|
userInitials,
|
||||||
|
type AppUser,
|
||||||
|
type AppUserForm,
|
||||||
|
type UserServerOption,
|
||||||
|
} from "@/lib/users"
|
||||||
|
import { listServers } from "@/shared/api/servers"
|
||||||
|
import {
|
||||||
|
createAppUser,
|
||||||
|
createUserBinding,
|
||||||
|
deleteAppUser,
|
||||||
|
deleteUserBinding,
|
||||||
|
listAppUsers,
|
||||||
|
updateAppUser,
|
||||||
|
} from "@/shared/api/users"
|
||||||
|
import { ApiClientError } from "@/shared/api/http-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
CableIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UserCheckIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
function toServerOptionsFromMock(): UserServerOption[] {
|
||||||
|
return mockServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<AppUser[]>(INIT_USERS)
|
||||||
|
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(toServerOptionsFromMock)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [list, srvs] = await Promise.all([
|
||||||
|
listAppUsers(backendUrl),
|
||||||
|
listServers(backendUrl),
|
||||||
|
])
|
||||||
|
setUsers(list)
|
||||||
|
setServerOptions(srvs.map((s) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
})))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Не удалось загрузить пользователей")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
setUsers(INIT_USERS)
|
||||||
|
setServerOptions(toServerOptionsFromMock())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadLive()
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const boundCount = users.reduce((n, u) => n + u.bindings.length, 0)
|
||||||
|
const activeCount = users.filter((u) => u.active).length
|
||||||
|
|
||||||
|
const applyBindingsDiff = async (userId: string, next: AppUserForm["bindings"], prev: AppUser["bindings"]) => {
|
||||||
|
const nextKeys = new Set(next.map(bindingDiffKey))
|
||||||
|
const prevKeys = new Map(prev.map((b) => [bindingDiffKey(b), b] as const))
|
||||||
|
for (const b of prev) {
|
||||||
|
if (!nextKeys.has(bindingDiffKey(b))) {
|
||||||
|
await deleteUserBinding(backendUrl, userId, b.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const b of next) {
|
||||||
|
if (!prevKeys.has(bindingDiffKey(b))) {
|
||||||
|
await createUserBinding(backendUrl, userId, {
|
||||||
|
serverId: Number(b.serverId),
|
||||||
|
interfaceName: b.interfaceName,
|
||||||
|
interfaceType: b.interfaceType,
|
||||||
|
peerPublicKey: b.peerPublicKey,
|
||||||
|
peerName: b.peerName,
|
||||||
|
comment: b.comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (form: AppUserForm) => {
|
||||||
|
if (!isLive) {
|
||||||
|
if (!editUser) {
|
||||||
|
const id = `u${Date.now()}`
|
||||||
|
const created: AppUser = {
|
||||||
|
id,
|
||||||
|
...form,
|
||||||
|
last: "только что",
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
bindings: form.bindings.map((b, i) => ({ ...b, id: `b${id}-${i}`, userId: id })),
|
||||||
|
}
|
||||||
|
setUsers((prev) => [...prev, created])
|
||||||
|
} else {
|
||||||
|
setUsers((prev) => prev.map((u) => u.id === editUser.id ? { ...u, ...form, avatar: userInitials(form.name) } : u))
|
||||||
|
}
|
||||||
|
setSheetOpen(false)
|
||||||
|
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (!editUser) {
|
||||||
|
const created = await createAppUser(backendUrl, {
|
||||||
|
name: form.name,
|
||||||
|
login: form.login,
|
||||||
|
email: form.email,
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
sections: form.sections,
|
||||||
|
servers: form.servers,
|
||||||
|
})
|
||||||
|
await applyBindingsDiff(created.id, form.bindings, [])
|
||||||
|
await loadLive()
|
||||||
|
} else {
|
||||||
|
await updateAppUser(backendUrl, editUser.id, {
|
||||||
|
name: form.name,
|
||||||
|
login: form.login,
|
||||||
|
email: form.email,
|
||||||
|
role: form.role,
|
||||||
|
active: form.active,
|
||||||
|
avatar: userInitials(form.name),
|
||||||
|
sections: form.sections,
|
||||||
|
servers: form.servers,
|
||||||
|
})
|
||||||
|
await applyBindingsDiff(editUser.id, form.bindings, editUser.bindings)
|
||||||
|
await loadLive()
|
||||||
|
}
|
||||||
|
setSheetOpen(false)
|
||||||
|
toast.success(editUser ? "Пользователь сохранён" : "Пользователь создан")
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiClientError ? err.message : err instanceof Error ? err.message : "Ошибка сохранения"
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
if (!isLive) {
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id))
|
||||||
|
setDeleteTarget(null)
|
||||||
|
toast.success("Пользователь удалён")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteAppUser(backendUrl, deleteTarget.id)
|
||||||
|
setDeleteTarget(null)
|
||||||
|
await loadLive()
|
||||||
|
toast.success("Пользователь удалён")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Не удалось удалить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={() => { setEditUser(null); setSheetOpen(true) }}>
|
||||||
|
<PlusIcon className="size-4" />Пригласить
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
||||||
|
{/* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile */}
|
||||||
|
<KpiStatGrid
|
||||||
|
aria-label="Сводка пользователей"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Пользователи",
|
||||||
|
value: users.length,
|
||||||
|
icon: <UsersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
label: "Активные",
|
||||||
|
value: activeCount,
|
||||||
|
icon: <UserCheckIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ifaces",
|
||||||
|
label: "Привязанные ifaces",
|
||||||
|
value: boundCount,
|
||||||
|
icon: <CableIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||||
|
<DataPageCard>
|
||||||
|
<UsersDataGrid
|
||||||
|
users={users}
|
||||||
|
serversCount={serverOptions.length}
|
||||||
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
|
isLoading={loading}
|
||||||
|
onEdit={(u) => { setEditUser(u); setSheetOpen(true) }}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
</DataPageCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserSheet
|
||||||
|
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||||
|
open={sheetOpen}
|
||||||
|
user={editUser}
|
||||||
|
users={users}
|
||||||
|
servers={serverOptions}
|
||||||
|
isLive={isLive}
|
||||||
|
backendUrl={backendUrl}
|
||||||
|
saving={saving}
|
||||||
|
onSave={(f) => { void handleSave(f) }}
|
||||||
|
onClose={() => setSheetOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(v) => { if (!v) setDeleteTarget(null) }}>
|
||||||
|
<AlertDialogContent size="default">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||||
|
<TrashIcon />
|
||||||
|
</AlertDialogMedia>
|
||||||
|
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteTarget.name} · {deleteTarget.email || deleteTarget.login}. Привязки интерфейсов будут удалены.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteTarget(null)}>Отмена</AlertDialogCancel>
|
||||||
|
<AlertDialogAction variant="destructive" onClick={() => { void handleDelete() }}>
|
||||||
|
Удалить
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+237
-120
@@ -1,36 +1,63 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { vxlanTunnels, servers } from "@/lib/data"
|
import { vxlanTunnels as mockVxlanTunnels, servers as mockServers } from "@/lib/data"
|
||||||
import type { VxlanTunnel } from "@/lib/data"
|
import type { Server, VxlanTunnel } from "@/lib/data"
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||||
import { IconTile } from "@/components/reui/icon-tile"
|
|
||||||
import { OpsPanel } from "@/components/ops-panel"
|
import { OpsPanel } from "@/components/ops-panel"
|
||||||
import { DataPageCard } from "@/components/data-page-card"
|
import { DataPageCard } from "@/components/data-page-card"
|
||||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
import {
|
import {
|
||||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon,
|
||||||
CodeXmlIcon, LayersIcon,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {
|
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
import { useDataSource } from "@/lib/data-source"
|
||||||
SheetDescription, SheetFooter, SheetClose,
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
} from "@/components/ui/sheet"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||||
|
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
interface BackendServer {
|
||||||
|
id: number
|
||||||
function serverFor(id: string) {
|
name: string
|
||||||
return servers.find((s) => s.id === id)
|
host: string
|
||||||
|
type?: Server["type"]
|
||||||
|
site?: string
|
||||||
|
country: string
|
||||||
|
asn?: string
|
||||||
|
enabled: boolean
|
||||||
|
status?: Server["status"]
|
||||||
|
latency?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── RSC generator ───────────────────────────────────────────────────────────
|
interface VxlanApiResponse {
|
||||||
|
tunnels: VxlanTunnel[]
|
||||||
|
}
|
||||||
|
|
||||||
function generateVxlanRsc(t: VxlanTunnel): string {
|
function mapBackendServer(s: BackendServer): Server {
|
||||||
const srv = serverFor(t.serverId)
|
return {
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name || s.host,
|
||||||
|
host: s.host,
|
||||||
|
model: "—",
|
||||||
|
os: "—",
|
||||||
|
site: s.site ?? "",
|
||||||
|
country: s.country || "UN",
|
||||||
|
asn: s.asn ?? "",
|
||||||
|
type: s.type ?? "exit-node",
|
||||||
|
enabled: s.enabled,
|
||||||
|
status: s.status ?? "online",
|
||||||
|
latency: s.latency ?? null,
|
||||||
|
sessions: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateVxlanRsc(t: VxlanTunnel, serverById: Record<string, Server>): string {
|
||||||
|
const srv = serverById[t.serverId]
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
|
||||||
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
|
||||||
@@ -48,7 +75,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
|||||||
if (!t.enabled) lines.push(` disabled=yes \\`)
|
if (!t.enabled) lines.push(` disabled=yes \\`)
|
||||||
lines.push(``)
|
lines.push(``)
|
||||||
|
|
||||||
// FDB entries for remote VTEPs
|
|
||||||
for (const vtep of t.remoteVteps) {
|
for (const vtep of t.remoteVteps) {
|
||||||
lines.push(`/interface/vxlan/vteps/add \\`)
|
lines.push(`/interface/vxlan/vteps/add \\`)
|
||||||
lines.push(` interface=${t.name} \\`)
|
lines.push(` interface=${t.name} \\`)
|
||||||
@@ -56,7 +82,6 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
|||||||
lines.push(``)
|
lines.push(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bridge
|
|
||||||
lines.push(`# Добавить в bridge:`)
|
lines.push(`# Добавить в bridge:`)
|
||||||
lines.push(`/interface/bridge/port/add \\`)
|
lines.push(`/interface/bridge/port/add \\`)
|
||||||
lines.push(` bridge=bridge-overlay \\`)
|
lines.push(` bridge=bridge-overlay \\`)
|
||||||
@@ -65,121 +90,214 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
|||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
function ExportSheet({
|
||||||
|
open, tunnel, onClose, serverById,
|
||||||
function ExportSheet({ open, tunnel, onClose }: {
|
}: {
|
||||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
open: boolean
|
||||||
|
tunnel: VxlanTunnel | null
|
||||||
|
onClose: () => void
|
||||||
|
serverById: Record<string, Server>
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false)
|
const code = useMemo(
|
||||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
() => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""),
|
||||||
|
[tunnel, serverById],
|
||||||
function handleCopy() {
|
)
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
|
||||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
<CodeExportSheet
|
||||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
open={open}
|
||||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
onClose={onClose}
|
||||||
<div className="flex items-start justify-between gap-4">
|
title="Экспорт VXLAN"
|
||||||
<div>
|
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
formats={[
|
||||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
{
|
||||||
</div>
|
id: "rsc",
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
label: "MikroTik .rsc",
|
||||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||||
</Button>
|
code,
|
||||||
</div>
|
},
|
||||||
</SheetHeader>
|
]}
|
||||||
<div className="flex-1 overflow-y-auto">
|
/>
|
||||||
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
|
|
||||||
{code.split("\n").map((line, i) => {
|
|
||||||
const isComment = line.startsWith("#")
|
|
||||||
const isCmd = /^\//.test(line.trimStart())
|
|
||||||
const isParam = /^\s+[a-z]/.test(line)
|
|
||||||
return (
|
|
||||||
<span key={i} className={
|
|
||||||
isComment ? "text-muted-foreground"
|
|
||||||
: isCmd ? "text-sky-400"
|
|
||||||
: isParam ? "text-violet-300"
|
|
||||||
: "text-foreground"
|
|
||||||
}>
|
|
||||||
{line}{"\n"}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
|
||||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
|
|
||||||
<Button className="flex-1" onClick={handleCopy}>
|
|
||||||
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
|
|
||||||
{copied ? "Скопировано" : "Копировать .rsc"}
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export Sheet ─────────────────────────────────────────────────────────────
|
|
||||||
export default function VxlanPage() {
|
export default function VxlanPage() {
|
||||||
const [search, setSearch] = useState("")
|
const { mode, backendUrl } = useDataSource()
|
||||||
|
const isLive = mode === "live"
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||||
|
|
||||||
|
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
|
||||||
|
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [liveError, setLiveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadLive = useCallback(async () => {
|
||||||
|
if (!isLive) return
|
||||||
|
setLoading(true)
|
||||||
|
setLiveError(null)
|
||||||
|
try {
|
||||||
|
const [tunnelsRes, serversRes] = await Promise.all([
|
||||||
|
requestJson<VxlanApiResponse>(backendUrl, "/api/vxlan"),
|
||||||
|
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
|
||||||
|
])
|
||||||
|
setLiveTunnels(tunnelsRes.tunnels ?? [])
|
||||||
|
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
|
||||||
|
} catch (e) {
|
||||||
|
setLiveError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||||
|
setLiveTunnels([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLive, backendUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setLiveTunnels([])
|
||||||
|
setLiveServers([])
|
||||||
|
setLiveError(null)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void loadLive()
|
||||||
|
})
|
||||||
|
}, [isLive, loadLive])
|
||||||
|
|
||||||
|
const displayTunnels = isLive ? liveTunnels : mockVxlanTunnels
|
||||||
|
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
|
||||||
|
|
||||||
|
const effectiveServerId =
|
||||||
|
selectedServerId === ALL_SERVERS_ID || displayServers.some((s) => s.id === selectedServerId)
|
||||||
|
? selectedServerId
|
||||||
|
: ALL_SERVERS_ID
|
||||||
|
|
||||||
|
const scopedTunnels = useMemo(() => {
|
||||||
|
if (effectiveServerId === ALL_SERVERS_ID) return displayTunnels
|
||||||
|
return displayTunnels.filter((t) => t.serverId === effectiveServerId)
|
||||||
|
}, [displayTunnels, effectiveServerId])
|
||||||
|
|
||||||
|
const serverById = useMemo(
|
||||||
|
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||||
|
[displayServers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railItems = useMemo<ServerTileItem[]>(() => (
|
||||||
|
displayServers.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
host: s.host,
|
||||||
|
site: s.site,
|
||||||
|
country: s.country,
|
||||||
|
status: s.status,
|
||||||
|
type: s.type,
|
||||||
|
enabled: s.enabled,
|
||||||
|
meta: String(displayTunnels.filter((t) => t.serverId === s.id).length),
|
||||||
|
}))
|
||||||
|
), [displayServers, displayTunnels])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search) return vxlanTunnels
|
if (!search) return scopedTunnels
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return vxlanTunnels.filter((t) =>
|
return scopedTunnels.filter((t) =>
|
||||||
t.name.includes(q) ||
|
t.name.includes(q) ||
|
||||||
String(t.vni).includes(q) ||
|
String(t.vni).includes(q) ||
|
||||||
t.vtepIp.includes(q) ||
|
t.vtepIp.includes(q) ||
|
||||||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
|
(serverById[t.serverId]?.name.toLowerCase().includes(q) ?? false),
|
||||||
)
|
)
|
||||||
}, [search])
|
}, [search, scopedTunnels, serverById])
|
||||||
|
|
||||||
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
|
const upCount = scopedTunnels.filter((t) => t.status === "up").length
|
||||||
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
|
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<>
|
||||||
<PageHeader
|
<ServerRailLayout
|
||||||
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
items={railItems}
|
||||||
actions={
|
selectedId={effectiveServerId}
|
||||||
<Button size="sm">
|
onSelect={setSelectedServerId}
|
||||||
<PlusIcon className="size-4" />Новый VXLAN
|
showAll
|
||||||
</Button>
|
allCount={displayServers.length}
|
||||||
}
|
loading={isLive && loading && displayServers.length === 0}
|
||||||
/>
|
header={
|
||||||
|
<PageHeader
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ServerRailMobileButton />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { void loadLive() }}
|
||||||
|
disabled={!isLive || loading}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button size="sm">
|
||||||
|
<PlusIcon className="size-4" />Новый VXLAN
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
{/* KPI */}
|
{isLive && liveError && (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<Alert variant="warning" className="py-2">
|
||||||
{[
|
<AlertCircleIcon />
|
||||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
<AlertDescription className="text-xs">Ошибка загрузки: {liveError}</AlertDescription>
|
||||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
</Alert>
|
||||||
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
|
)}
|
||||||
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
|
{isLive && !loading && displayTunnels.length === 0 && !liveError && (
|
||||||
].map((s) => (
|
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
<Frame key={s.label} className="h-full">
|
На опрошенных серверах нет VXLAN-интерфейсов
|
||||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
</div>
|
||||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
)}
|
||||||
{s.icon}
|
{mode === "mock" && (
|
||||||
</IconTile>
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
Моковые данные
|
||||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
</span>
|
||||||
<p className="text-2xl leading-none font-bold tabular-nums">{s.value}</p>
|
)}
|
||||||
</div>
|
|
||||||
</FramePanel>
|
<KpiStatGrid
|
||||||
</Frame>
|
aria-label="Сводка VXLAN"
|
||||||
))}
|
items={[
|
||||||
</div>
|
{
|
||||||
|
id: "tunnels",
|
||||||
|
label: "Туннелей",
|
||||||
|
value: scopedTunnels.length,
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-muted-foreground",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "up",
|
||||||
|
label: "Активных",
|
||||||
|
value: upCount,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "vni",
|
||||||
|
label: "Уникальных VNI",
|
||||||
|
value: vnis,
|
||||||
|
icon: <LayersIcon className="size-4" />,
|
||||||
|
iconClassName: "text-info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "servers",
|
||||||
|
label: "Серверов",
|
||||||
|
value: new Set(scopedTunnels.map((t) => t.serverId)).size,
|
||||||
|
icon: <NetworkIcon className="size-4" />,
|
||||||
|
iconClassName: "text-primary",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Info banner */}
|
|
||||||
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
|
||||||
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -191,7 +309,6 @@ export default function VxlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DataPageCard>
|
<DataPageCard>
|
||||||
<DataPageToolbar
|
<DataPageToolbar
|
||||||
search={search}
|
search={search}
|
||||||
@@ -201,12 +318,11 @@ export default function VxlanPage() {
|
|||||||
/>
|
/>
|
||||||
<VxlanDataGrid
|
<VxlanDataGrid
|
||||||
tunnels={filtered}
|
tunnels={filtered}
|
||||||
servers={servers}
|
servers={displayServers}
|
||||||
onExport={setExportTunnel}
|
onExport={setExportTunnel}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>
|
</DataPageCard>
|
||||||
|
|
||||||
{/* Reference */}
|
|
||||||
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
<OpsPanel title="RouterOS 7 · /interface/vxlan — быстрые команды" contentClassName="px-5 py-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
|
||||||
{[
|
{[
|
||||||
@@ -260,13 +376,14 @@ export default function VxlanPage() {
|
|||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ServerRailLayout>
|
||||||
|
|
||||||
<ExportSheet
|
<ExportSheet
|
||||||
open={!!exportTunnel}
|
open={!!exportTunnel}
|
||||||
tunnel={exportTunnel}
|
tunnel={exportTunnel}
|
||||||
onClose={() => setExportTunnel(null)}
|
onClose={() => setExportTunnel(null)}
|
||||||
|
serverById={serverById}
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+844
-224
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
export default function AccessDeniedPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Нет доступа</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
|
||||||
|
необходимых прав. Обратитесь к администратору auth-portal.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/auth/callback"
|
||||||
|
className="text-primary text-sm underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Войти снова
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import {
|
||||||
|
clearPortalHandoffFlag,
|
||||||
|
clearToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getToken,
|
||||||
|
parseHashToken,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
|
setToken,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
|
||||||
|
export default function AuthCallbackPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const error = params.get("error")
|
||||||
|
if (error === "sso_loop" || error === "jwt_rejected") {
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
|
if (accessToken) {
|
||||||
|
setToken(accessToken)
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes("mm")) {
|
||||||
|
setMessage("Нет доступа к приложению")
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/config", {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore network — proceed */
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = firstAllowedPath()
|
||||||
|
if (next === "/access-denied") {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getToken() && getClaims()) {
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
if (!getClaims()!.apps.includes("mm")) {
|
||||||
|
router.replace("/access-denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace(firstAllowedPath())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
-1
@@ -1,8 +1,19 @@
|
|||||||
# Path to SQLite database file
|
# PostgreSQL connection string
|
||||||
|
DATABASE_URL=postgres://mmapp:[email protected]:5432/mmapp
|
||||||
|
|
||||||
|
# SQLite file used only for one-shot boot ETL (empty PG + existing mikrotik.db)
|
||||||
DATABASE_PATH=./mikrotik.db
|
DATABASE_PATH=./mikrotik.db
|
||||||
|
# SQLITE_IMPORT_FULL_HISTORY=true
|
||||||
|
|
||||||
# Port for the Fastify server
|
# Port for the Fastify server
|
||||||
PORT=8000
|
PORT=8000
|
||||||
|
|
||||||
# Allowed CORS origin (Next.js frontend)
|
# Allowed CORS origin (Next.js frontend)
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Portal SSO (false = open API for local/dev)
|
||||||
|
AUTH_REQUIRED=false
|
||||||
|
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ dist/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
storage/backups/
|
storage/backups/
|
||||||
|
storage/geoip/
|
||||||
|
|||||||
+28
-3
@@ -5,10 +5,23 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY packages/contracts/package.json packages/contracts/
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
COPY backend/package.json backend/
|
COPY backend/package.json backend/
|
||||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||||
|
RUN node -e "\
|
||||||
|
const fs=require('fs');\
|
||||||
|
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||||
|
p.dependencies={};\
|
||||||
|
p.devDependencies={};\
|
||||||
|
delete p.scripts;\
|
||||||
|
p.workspaces=['packages/*','backend'];\
|
||||||
|
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||||
|
"
|
||||||
|
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||||
|
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||||
|
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||||
&& npm rebuild better-sqlite3
|
&& npm rebuild better-sqlite3
|
||||||
|
|
||||||
FROM deps AS build
|
FROM deps AS build
|
||||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
|||||||
COPY backend backend
|
COPY backend backend
|
||||||
RUN npm run build -w @mmapp/contracts \
|
RUN npm run build -w @mmapp/contracts \
|
||||||
&& npm run build -w mikrotik-manager-backend \
|
&& npm run build -w mikrotik-manager-backend \
|
||||||
&& npm prune --omit=dev
|
&& npm prune --omit=dev \
|
||||||
|
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||||
|
&& mkdir -p backend/node_modules
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS runner
|
FROM node:22-bookworm-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -32,6 +47,16 @@ COPY --from=build /app/node_modules ./node_modules
|
|||||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||||
COPY --from=build /app/backend/dist ./backend/dist
|
COPY --from=build /app/backend/dist ./backend/dist
|
||||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||||
RUN mkdir -p /app/data
|
COPY --from=build /app/backend/drizzle ./backend/drizzle
|
||||||
|
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends gnupg wget ca-certificates \
|
||||||
|
&& wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-client-18 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& mkdir -p /app/data
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["node", "backend/dist/index.js"]
|
CMD ["node", "backend/dist/index.js"]
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ config()
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dialect: "sqlite",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_PATH ?? "./mikrotik.db",
|
url: process.env.DATABASE_URL ?? "postgres://mmapp:[email protected]:5432/mmapp",
|
||||||
},
|
},
|
||||||
verbose: true,
|
verbose: true,
|
||||||
strict: true,
|
strict: true,
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
CREATE TABLE `server_snapshots` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`server_id` integer NOT NULL,
|
|
||||||
`polled_at` text NOT NULL,
|
|
||||||
`status` text NOT NULL,
|
|
||||||
`latency_ms` real,
|
|
||||||
`ros_version` text,
|
|
||||||
`board_name` text,
|
|
||||||
`uptime` text,
|
|
||||||
`cpu_load` integer,
|
|
||||||
`free_memory` integer,
|
|
||||||
`total_memory` integer,
|
|
||||||
`identity_name` text,
|
|
||||||
`raw_interfaces` text,
|
|
||||||
`raw_ip_addresses` text,
|
|
||||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `servers` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`name` text DEFAULT '' NOT NULL,
|
|
||||||
`host` text NOT NULL,
|
|
||||||
`port` integer DEFAULT 443 NOT NULL,
|
|
||||||
`username` text DEFAULT 'admin' NOT NULL,
|
|
||||||
`password` text DEFAULT '' NOT NULL,
|
|
||||||
`use_ssl` integer DEFAULT true NOT NULL,
|
|
||||||
`verify_ssl` integer DEFAULT false NOT NULL,
|
|
||||||
`type` text DEFAULT 'home-router' NOT NULL,
|
|
||||||
`site` text DEFAULT '' NOT NULL,
|
|
||||||
`country` text DEFAULT '' NOT NULL,
|
|
||||||
`asn` text DEFAULT '' NOT NULL,
|
|
||||||
`comment` text DEFAULT '' NOT NULL,
|
|
||||||
`enabled` integer DEFAULT true NOT NULL,
|
|
||||||
`created_at` text DEFAULT (datetime('now')) NOT NULL,
|
|
||||||
`updated_at` text DEFAULT (datetime('now')) NOT NULL
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
-- MikrotikManager PostgreSQL 18 schema (not 1:1 SQLite).
|
||||||
|
-- Applied once at backend boot via src/db/migrate.ts
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 443,
|
||||||
|
username TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'home-router'
|
||||||
|
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||||
|
site TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
asn TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||||
|
wan_uplinks JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id BIGINT,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||||
|
last_datagram_at TIMESTAMPTZ,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||||
|
peers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
bot_token TEXT NOT NULL DEFAULT '',
|
||||||
|
chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
message_thread_id INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||||
|
account_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||||
|
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||||
|
hour INTEGER NOT NULL DEFAULT 3,
|
||||||
|
minute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
week_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
month_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||||
|
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||||
|
server_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_source_finished_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_migration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
sqlite_imported_at TIMESTAMPTZ,
|
||||||
|
sqlite_path TEXT,
|
||||||
|
sqlite_sha256 TEXT,
|
||||||
|
report_json JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
community TEXT NOT NULL,
|
||||||
|
community_name TEXT,
|
||||||
|
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||||
|
gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_address TEXT NOT NULL,
|
||||||
|
gateway TEXT NOT NULL,
|
||||||
|
distance INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scope INTEGER,
|
||||||
|
target_scope INTEGER,
|
||||||
|
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||||
|
check_gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
polled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||||
|
latency_ms DOUBLE PRECISION,
|
||||||
|
ros_version TEXT,
|
||||||
|
board_name TEXT,
|
||||||
|
uptime TEXT,
|
||||||
|
cpu_load INTEGER,
|
||||||
|
free_memory BIGINT,
|
||||||
|
total_memory BIGINT,
|
||||||
|
identity_name TEXT,
|
||||||
|
raw_interfaces JSONB,
|
||||||
|
raw_ip_addresses JSONB,
|
||||||
|
PRIMARY KEY (id, polled_at)
|
||||||
|
) PARTITION BY RANGE (polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_polled_brin ON server_snapshots USING BRIN (polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time ON traffic_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_sampled_brin ON traffic_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ok BOOLEAN NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id ON servers_rest_ping_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_brin ON servers_rest_ping_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT NOT NULL,
|
||||||
|
proto INTEGER NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
out_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
next_hop TEXT NOT NULL DEFAULT '',
|
||||||
|
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_bucket_brin ON flow_buckets USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_stats_brin ON flow_minute_stats USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_brin ON flow_minute_dims USING BRIN (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, dim, key)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rtt_ms INTEGER,
|
||||||
|
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time ON uptime_probe_samples(probe_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_brin ON uptime_probe_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||||
|
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||||
|
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
board_name TEXT NOT NULL DEFAULT '',
|
||||||
|
ros_version TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time ON uptime_resource_samples(server_id, sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_brin ON uptime_resource_samples USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||||
|
last_error TEXT,
|
||||||
|
last_ping_rtt_ms INTEGER,
|
||||||
|
last_ping_loss_pct INTEGER,
|
||||||
|
last_ping_at TIMESTAMPTZ,
|
||||||
|
last_ping_error TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
probe_id TEXT,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
src_address TEXT,
|
||||||
|
dst_address TEXT,
|
||||||
|
src_interface_address TEXT,
|
||||||
|
dst_interface_address TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
ping_rtt_ms INTEGER,
|
||||||
|
ping_loss_pct INTEGER,
|
||||||
|
ping_error TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||||
|
step TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
cert_name TEXT NOT NULL,
|
||||||
|
domain_names JSONB NOT NULL,
|
||||||
|
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||||
|
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||||
|
server_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown_override TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||||
|
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
confirm_stability_sec INTEGER,
|
||||||
|
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||||
|
recovery_stability_sec INTEGER,
|
||||||
|
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
condition_line TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||||
|
scope_key TEXT PRIMARY KEY,
|
||||||
|
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_payload_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||||
|
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||||
|
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||||
|
rule_id TEXT PRIMARY KEY,
|
||||||
|
payload_hash TEXT NOT NULL,
|
||||||
|
since_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
rule_name TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
fired_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dedupe_key TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_key TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
finished_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_json JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
source_module TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
payload_json JSONB
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
sections_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
servers_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB NOT NULL,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_brin ON internet_path_snapshots USING BRIN (sampled_at);
|
||||||
|
|
||||||
|
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
-- Compact PostgreSQL 18 schema: wipe all app tables (not schema_migrations),
|
||||||
|
-- recreate with smaller time-series rows. SQLite is re-imported after this.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
AND tablename <> 'schema_migrations'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.tablename);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 443,
|
||||||
|
username TEXT NOT NULL DEFAULT 'admin',
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
use_ssl BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
verify_ssl BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
type TEXT NOT NULL DEFAULT 'home-router'
|
||||||
|
CHECK (type IN ('jump-host', 'exit-node', 'home-router')),
|
||||||
|
site TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
asn TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
lan_subnet TEXT NOT NULL DEFAULT '',
|
||||||
|
wan_uplinks JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
mgmt_tunnel_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 30,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 120,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_flow_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
collector_ip TEXT NOT NULL DEFAULT '10.255.254.1',
|
||||||
|
flow_listen_port INTEGER NOT NULL DEFAULT 4739,
|
||||||
|
wg_listen_port INTEGER NOT NULL DEFAULT 51821,
|
||||||
|
prefix TEXT NOT NULL DEFAULT '10.255.254.0/24',
|
||||||
|
public_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
host_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
host_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
hub_server_id BIGINT,
|
||||||
|
retention_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
|
top_n INTEGER NOT NULL DEFAULT 200,
|
||||||
|
map_service_min_share_pct DOUBLE PRECISION NOT NULL DEFAULT 5,
|
||||||
|
last_datagram_at TIMESTAMPTZ,
|
||||||
|
last_exporter_ip TEXT,
|
||||||
|
last_error TEXT,
|
||||||
|
packets_received BIGINT NOT NULL DEFAULT 0,
|
||||||
|
peers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
resources_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ping_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
speed_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
||||||
|
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
bot_token TEXT NOT NULL DEFAULT '',
|
||||||
|
chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
message_thread_id INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||||
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||||
|
account_private_key TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||||
|
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
frequency TEXT NOT NULL DEFAULT 'daily' CHECK (frequency IN ('daily', 'weekly', 'monthly')),
|
||||||
|
hour INTEGER NOT NULL DEFAULT 3,
|
||||||
|
minute INTEGER NOT NULL DEFAULT 0,
|
||||||
|
week_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
month_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||||
|
format TEXT NOT NULL DEFAULT 'rsc' CHECK (format IN ('rsc', 'backup')),
|
||||||
|
server_ids_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 300,
|
||||||
|
retention_days INTEGER NOT NULL DEFAULT 14,
|
||||||
|
last_collected_at TIMESTAMPTZ,
|
||||||
|
last_duration_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_source_finished_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_migration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
sqlite_imported_at TIMESTAMPTZ,
|
||||||
|
sqlite_path TEXT,
|
||||||
|
sqlite_sha256 TEXT,
|
||||||
|
report_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
community TEXT NOT NULL,
|
||||||
|
community_name TEXT,
|
||||||
|
action TEXT NOT NULL DEFAULT 'route' CHECK (action IN ('route', 'blackhole')),
|
||||||
|
gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort ON filter_rules(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recursive_routes (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_address TEXT NOT NULL,
|
||||||
|
gateway TEXT NOT NULL,
|
||||||
|
distance INTEGER NOT NULL DEFAULT 1,
|
||||||
|
scope INTEGER,
|
||||||
|
target_scope INTEGER,
|
||||||
|
routing_table TEXT NOT NULL DEFAULT 'main',
|
||||||
|
check_gateway TEXT NOT NULL DEFAULT '',
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
disabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort ON recursive_routes(server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS server_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
polled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('online', 'offline')),
|
||||||
|
latency_ms DOUBLE PRECISION,
|
||||||
|
ros_version TEXT,
|
||||||
|
board_name TEXT,
|
||||||
|
uptime TEXT,
|
||||||
|
cpu_load INTEGER,
|
||||||
|
free_memory BIGINT,
|
||||||
|
total_memory BIGINT,
|
||||||
|
identity_name TEXT,
|
||||||
|
raw_interfaces JSONB COMPRESSION lz4,
|
||||||
|
raw_ip_addresses JSONB COMPRESSION lz4,
|
||||||
|
PRIMARY KEY (id, polled_at)
|
||||||
|
) PARTITION BY RANGE (polled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_server_snapshots_server_time ON server_snapshots(server_id, polled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS traffic_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
rx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
tx_bps BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flags SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, sampled_at, interface_name, peer_public_key)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time ON traffic_samples(server_id, interface_name, sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ok BOOLEAN NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
error TEXT,
|
||||||
|
PRIMARY KEY (server_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_buckets (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
src INET NOT NULL,
|
||||||
|
dst INET NOT NULL,
|
||||||
|
proto SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
src_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dst_port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
in_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
out_iface TEXT NOT NULL DEFAULT '',
|
||||||
|
next_hop INET,
|
||||||
|
flow_start_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
flow_end_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, src, dst, proto, src_port, dst_port, in_iface)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_buckets_server_time ON flow_buckets(server_id, bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_stats (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
unique_src INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_dst INTEGER NOT NULL DEFAULT 0,
|
||||||
|
conversations INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_minute_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, dim, key)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_minute_dims_time ON flow_minute_dims(bucket_at, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_dims (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
dim TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, dim, key)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_flow_daily_dims_day ON flow_daily_dims(day, dim);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_ip_meta (
|
||||||
|
prefix TEXT PRIMARY KEY,
|
||||||
|
asn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
country TEXT NOT NULL DEFAULT '',
|
||||||
|
lat DOUBLE PRECISION,
|
||||||
|
lng DOUBLE PRECISION,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
ok INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_asn_meta (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
probe_filter TEXT NOT NULL DEFAULT '—',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
interval_sec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
show_on_dashboard BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort ON uptime_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
||||||
|
probe_id TEXT NOT NULL REFERENCES uptime_probes(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
rtt_ms INTEGER,
|
||||||
|
loss_pct INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'down' CHECK (status IN ('up', 'warn', 'down')),
|
||||||
|
PRIMARY KEY (probe_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offline' CHECK (status IN ('online', 'offline')),
|
||||||
|
cpu_load INTEGER NOT NULL DEFAULT 0,
|
||||||
|
free_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_memory BIGINT NOT NULL DEFAULT 0,
|
||||||
|
free_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_hdd_space BIGINT NOT NULL DEFAULT 0,
|
||||||
|
uptime_seconds BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_run_at TIMESTAMPTZ,
|
||||||
|
last_tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
last_status TEXT CHECK (last_status IN ('done', 'error')),
|
||||||
|
last_error TEXT,
|
||||||
|
last_ping_rtt_ms INTEGER,
|
||||||
|
last_ping_loss_pct INTEGER,
|
||||||
|
last_ping_at TIMESTAMPTZ,
|
||||||
|
last_ping_error TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort ON uptime_speed_probes(src_server_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
probe_id TEXT,
|
||||||
|
src_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
dst_server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
src_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
dst_interface TEXT NOT NULL DEFAULT '',
|
||||||
|
src_address TEXT,
|
||||||
|
dst_address TEXT,
|
||||||
|
src_interface_address TEXT,
|
||||||
|
dst_interface_address TEXT,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tcp' CHECK (protocol IN ('tcp', 'udp')),
|
||||||
|
direction TEXT NOT NULL DEFAULT 'both' CHECK (direction IN ('transmit', 'receive', 'both')),
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 10,
|
||||||
|
tx_avg_mbps DOUBLE PRECISION,
|
||||||
|
rx_avg_mbps DOUBLE PRECISION,
|
||||||
|
ping_rtt_ms INTEGER,
|
||||||
|
ping_loss_pct INTEGER,
|
||||||
|
ping_error TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'done', 'failed')),
|
||||||
|
step TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'scheduler')),
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
cert_name TEXT NOT NULL,
|
||||||
|
domain_names JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||||
|
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT REFERENCES servers(id) ON DELETE SET NULL,
|
||||||
|
server_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual' CHECK (kind IN ('manual', 'auto')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
combine_mode TEXT NOT NULL DEFAULT 'any' CHECK (combine_mode IN ('any', 'all')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown_override TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
condition TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cooldown TEXT NOT NULL DEFAULT '5м',
|
||||||
|
rule_chat_id TEXT NOT NULL DEFAULT '',
|
||||||
|
confirm_stability_sec INTEGER,
|
||||||
|
recovery_mode TEXT NOT NULL DEFAULT 'always' CHECK (recovery_mode IN ('always', 'never', 'conditional')),
|
||||||
|
recovery_stability_sec INTEGER,
|
||||||
|
group_id TEXT REFERENCES alert_groups(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
|
||||||
|
condition_line TEXT NOT NULL,
|
||||||
|
sort_index INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
||||||
|
scope_key TEXT PRIMARY KEY,
|
||||||
|
last_fired_at TEXT NOT NULL DEFAULT '',
|
||||||
|
last_payload_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
||||||
|
kind TEXT PRIMARY KEY CHECK (kind IN ('gre', 'bgp')),
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
||||||
|
rule_id TEXT PRIMARY KEY,
|
||||||
|
payload_hash TEXT NOT NULL,
|
||||||
|
since_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
rule_id TEXT,
|
||||||
|
group_id TEXT,
|
||||||
|
rule_name TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('critical', 'warning', 'info')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
sent_ok BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
fired_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alert_outbox (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dedupe_key TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'telegram' CHECK (channel IN ('telegram')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
next_attempt_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_outbox_dedupe ON alert_outbox(dedupe_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt ON alert_outbox(status, next_attempt_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alert_outbox_pending_next ON alert_outbox(next_attempt_at) WHERE status = 'pending';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
job_key TEXT NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
finished_at TIMESTAMPTZ NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('ok', 'error')),
|
||||||
|
error TEXT,
|
||||||
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
result_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time ON scheduler_runs(job_key, started_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK (level IN ('critical', 'warning', 'info')),
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
source_module TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
entity_type TEXT,
|
||||||
|
entity_id TEXT,
|
||||||
|
payload_json JSONB COMPRESSION lz4
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
avatar TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen TIMESTAMPTZ,
|
||||||
|
sections_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
servers_json JSONB COMPRESSION lz4 NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_interface_bindings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
interface_name TEXT NOT NULL,
|
||||||
|
interface_type TEXT NOT NULL DEFAULT 'other' CHECK (interface_type IN ('ether', 'gre', 'wg', 'other')),
|
||||||
|
peer_public_key TEXT NOT NULL DEFAULT '',
|
||||||
|
peer_name TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (server_id, interface_name, peer_public_key)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_iface_bind_user ON user_interface_bindings(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
|
||||||
|
sampled_at TIMESTAMPTZ NOT NULL,
|
||||||
|
payload_json JSONB COMPRESSION lz4 NOT NULL,
|
||||||
|
PRIMARY KEY (id, sampled_at)
|
||||||
|
) PARTITION BY RANGE (sampled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled ON internet_path_snapshots(sampled_at);
|
||||||
|
|
||||||
|
INSERT INTO traffic_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO traffic_flow_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO uptime_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO evobgp_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO servers_api_ping_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO internet_path_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_telegram_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO acme_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO certificate_renew_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO backup_schedule_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO alert_engine_cursor (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO data_migration (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- S3-compatible storage for RouterOS backups
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_storage_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
provider TEXT NOT NULL DEFAULT 'local' CHECK (provider IN ('local', 's3')),
|
||||||
|
s3_endpoint TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_region TEXT NOT NULL DEFAULT 'us-east-1',
|
||||||
|
s3_bucket TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_prefix TEXT NOT NULL DEFAULT 'mikrotik',
|
||||||
|
s3_access_key_id TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||||
|
s3_force_path_style BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
keep_local_copy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_test_at TIMESTAMPTZ,
|
||||||
|
last_test_error TEXT,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO backup_storage_settings (id)
|
||||||
|
VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS storage TEXT NOT NULL DEFAULT 'local';
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS s3_key TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS s3_etag TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD COLUMN IF NOT EXISTS upload_error TEXT;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'backup_entries_storage_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE backup_entries
|
||||||
|
ADD CONSTRAINT backup_entries_storage_check
|
||||||
|
CHECK (storage IN ('local', 's3', 'both'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- GeoLite2 mmdb (страна/ASN для netflow): настройки автообновления зеркала P3TERX
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS geoip_settings (
|
||||||
|
id BIGINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
update_interval_sec INTEGER NOT NULL DEFAULT 604800,
|
||||||
|
last_check_at TIMESTAMPTZ,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_error TEXT,
|
||||||
|
country_build_at TIMESTAMPTZ,
|
||||||
|
asn_build_at TIMESTAMPTZ,
|
||||||
|
etags_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO geoip_settings (id)
|
||||||
|
VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Statistics cube: hour + daily facts (server × iface × country × service × ASN).
|
||||||
|
-- Compact types. FILLFACTOR/autovacuum нельзя на partitioned parent (PG 42809) —
|
||||||
|
-- задаются на листовых партициях в ensurePartitionFor.
|
||||||
|
-- Retention: DROP partitions only (see PARTITION_SPECS).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_hour_facts (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
bucket_at TIMESTAMPTZ NOT NULL,
|
||||||
|
iface TEXT NOT NULL,
|
||||||
|
country CHAR(2) NOT NULL,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
asn INTEGER NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, bucket_at, iface, country, service, asn)
|
||||||
|
) PARTITION BY RANGE (bucket_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS flow_daily_facts (
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
iface TEXT NOT NULL,
|
||||||
|
country CHAR(2) NOT NULL,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
asn INTEGER NOT NULL,
|
||||||
|
bytes BIGINT NOT NULL DEFAULT 0,
|
||||||
|
packets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (server_id, day, iface, country, service, asn)
|
||||||
|
) PARTITION BY RANGE (day);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- История desired/actual снапшотов managed-секций (фильтры, рекурсивные маршруты).
|
||||||
|
-- Retention — prune в сервисе (последние 50 на пару server+section).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config_revisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
server_id BIGINT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
section TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_revisions_server_section_created
|
||||||
|
ON config_revisions (server_id, section, created_at DESC);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- IPFIX postNAT (IANA 225/226) + postNAPT ports (IANA 227/228) from MikroTik Traffic Flow.
|
||||||
|
-- Needed to rebuild facts with the same internet dest as the network map.
|
||||||
|
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst INET;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_src_port INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE flow_buckets ADD COLUMN IF NOT EXISTS nat_dst_port INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "postgresql",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "7",
|
||||||
"when": 1777572014210,
|
"when": 1777572014210,
|
||||||
"tag": "0000_living_xorn",
|
"tag": "0000_postgresql",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "MikroTik Manager backend — Fastify + Drizzle + SQLite",
|
"description": "MikroTik Manager backend — Fastify + Drizzle + PostgreSQL",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -10,10 +10,26 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
|
||||||
|
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
|
||||||
|
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
|
||||||
|
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
|
||||||
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||||
|
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
|
||||||
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
||||||
|
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
|
||||||
|
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
|
||||||
|
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
|
||||||
|
"test:backups": "tsx src/services/s3-backup-client.test.ts",
|
||||||
|
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
|
||||||
|
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
|
||||||
|
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/jwt": "^10.2.2",
|
||||||
"@fastify/type-provider-zod": "^1.0.0",
|
"@fastify/type-provider-zod": "^1.0.0",
|
||||||
"@mmapp/contracts": "1.0.0",
|
"@mmapp/contracts": "1.0.0",
|
||||||
"acme-client": "^5.4.0",
|
"acme-client": "^5.4.0",
|
||||||
@@ -21,14 +37,19 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"pino-pretty": "^13.1.3",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"maxmind": "^5.0.7",
|
||||||
|
"pg": "^8.23.0",
|
||||||
"undici": "^8.1.0",
|
"undici": "^8.1.0",
|
||||||
"zod": "^4.4.1"
|
"zod": "^4.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"jose": "^6.2.11",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Database from "better-sqlite3"
|
||||||
|
import fs from "node:fs"
|
||||||
|
|
||||||
|
const path = process.argv[2] ?? "./mikrotik.db"
|
||||||
|
if (!fs.existsSync(path)) {
|
||||||
|
console.error("NO_SQLITE", path)
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
const st = fs.statSync(path)
|
||||||
|
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||||||
|
const tables = db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
|
||||||
|
.all()
|
||||||
|
console.log("FILE_BYTES", st.size)
|
||||||
|
console.log("TABLES", tables.length)
|
||||||
|
for (const t of tables) {
|
||||||
|
const c = db.prepare(`SELECT COUNT(*) AS c FROM "${t.name}"`).get()
|
||||||
|
console.log(`COUNT\t${t.name}\t${c.c}`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = db.prepare(`SELECT COUNT(*) AS c FROM (
|
||||||
|
SELECT dedupe_key FROM alert_outbox GROUP BY dedupe_key HAVING COUNT(*) > 1
|
||||||
|
)`).get()
|
||||||
|
console.log("DUP_DEDUPE_KEYS", d.c)
|
||||||
|
} catch (e) {
|
||||||
|
console.log("DUP_DEDUPE_ERR", e instanceof Error ? e.message : e)
|
||||||
|
}
|
||||||
|
for (const [label, sql] of [
|
||||||
|
["ORPHAN_FLOW_MINUTE", `SELECT COUNT(*) c FROM flow_minute_stats s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||||
|
["ORPHAN_FLOW_BUCKETS", `SELECT COUNT(*) c FROM flow_buckets s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||||
|
["ORPHAN_BINDINGS", `SELECT COUNT(*) c FROM user_interface_bindings s WHERE s.server_id NOT IN (SELECT id FROM servers)`],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
console.log(label, db.prepare(sql).get().c)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(label + "_ERR", e instanceof Error ? e.message : e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
+52
-2
@@ -3,17 +3,67 @@ import { z } from "zod"
|
|||||||
|
|
||||||
config()
|
config()
|
||||||
|
|
||||||
|
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (v === undefined || v === "") return fallback
|
||||||
|
return v === "1" || v.toLowerCase() === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = process.env.NODE_ENV === "production"
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
|
DATABASE_URL: z.string().default("postgres://mmapp:[email protected]:5432/mmapp"),
|
||||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||||
|
SQLITE_IMPORT_FULL_HISTORY: z.boolean().default(false),
|
||||||
PORT: z.coerce.number().int().positive().default(8000),
|
PORT: z.coerce.number().int().positive().default(8000),
|
||||||
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
||||||
|
AUTH_REQUIRED: z.boolean().default(false),
|
||||||
|
AUTH_JWT_SECRET: z.string().default(""),
|
||||||
|
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
|
||||||
|
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsed = envSchema.safeParse(process.env)
|
const raw = {
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
|
DATABASE_PATH: process.env.DATABASE_PATH,
|
||||||
|
SQLITE_IMPORT_FULL_HISTORY: boolEnv(process.env.SQLITE_IMPORT_FULL_HISTORY, false),
|
||||||
|
PORT: process.env.PORT,
|
||||||
|
CORS_ORIGIN: process.env.CORS_ORIGIN,
|
||||||
|
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
|
AUTH_JWT_SECRET:
|
||||||
|
process.env.AUTH_JWT_SECRET?.trim() ||
|
||||||
|
process.env.JWT_SECRET?.trim() ||
|
||||||
|
(isProd ? "" : "dev-secret-change-me"),
|
||||||
|
AUTH_ISSUER:
|
||||||
|
process.env.AUTH_ISSUER?.trim() ||
|
||||||
|
process.env.ISSUER?.trim() ||
|
||||||
|
"https://auth.shnt.top",
|
||||||
|
AUTH_PORTAL_URL: (
|
||||||
|
process.env.AUTH_PORTAL_URL ??
|
||||||
|
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
|
||||||
|
"http://localhost:5175"
|
||||||
|
).replace(/\/$/, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = envSchema.safeParse(raw)
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = parsed.data
|
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
|
||||||
|
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
DATABASE_URL: parsed.data.DATABASE_URL,
|
||||||
|
DATABASE_PATH: parsed.data.DATABASE_PATH,
|
||||||
|
sqliteImportFullHistory: parsed.data.SQLITE_IMPORT_FULL_HISTORY,
|
||||||
|
PORT: parsed.data.PORT,
|
||||||
|
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
|
||||||
|
authRequired: parsed.data.AUTH_REQUIRED,
|
||||||
|
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
|
||||||
|
authIssuer: parsed.data.AUTH_ISSUER,
|
||||||
|
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { pool } from "./index.js"
|
||||||
|
import { applySqlMigrations } from "./migrate.js"
|
||||||
|
import { dropExpiredPartitions, ensurePartitionsAround } from "./partitions.js"
|
||||||
|
import { importSqliteToPostgres, shouldImportSqlite, sqliteFileLooksPresent } from "./sqlite-import.js"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
|
||||||
|
const ETL_LOCK = 8723101
|
||||||
|
|
||||||
|
export async function initDatabase(): Promise<void> {
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
await ensurePartitionsAround(pool)
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query("SELECT pg_advisory_lock($1)", [ETL_LOCK])
|
||||||
|
if (await shouldImportSqlite(pool, env.DATABASE_PATH)) {
|
||||||
|
console.log(`SQLite → PostgreSQL: импорт ${env.DATABASE_PATH}`)
|
||||||
|
const report = await importSqliteToPostgres(pool, env.DATABASE_PATH, { strict: true })
|
||||||
|
console.log(
|
||||||
|
`SQLite → PostgreSQL: готово за ${report.durationMs}ms, таблиц ${Object.keys(report.tables).length}`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||||
|
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||||
|
)
|
||||||
|
if (!marker.rows[0]?.sqlite_imported_at && !sqliteFileLooksPresent(env.DATABASE_PATH)) {
|
||||||
|
console.warn(
|
||||||
|
`SQLite → PostgreSQL: файл ${env.DATABASE_PATH} не найден, база после wipe остаётся пустой (defaults settings)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await client.query("SELECT pg_advisory_unlock($1)", [ETL_LOCK])
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
await dropExpiredPartitions(pool)
|
||||||
|
await ensurePartitionsAround(pool)
|
||||||
|
}
|
||||||
+52
-706
@@ -1,725 +1,71 @@
|
|||||||
import Database from "better-sqlite3"
|
import { isMainThread } from "node:worker_threads"
|
||||||
import { existsSync, readFileSync } from "node:fs"
|
import pg from "pg"
|
||||||
import path from "node:path"
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
|
||||||
type SqliteHandle = InstanceType<typeof Database>
|
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3"
|
|
||||||
import { env } from "../config.js"
|
import { env } from "../config.js"
|
||||||
import * as schema from "./schema.js"
|
import * as schema from "./schema.js"
|
||||||
|
import { bindSql } from "./sql-bind.js"
|
||||||
|
|
||||||
const sqlite = new Database(env.DATABASE_PATH)
|
const INT8_OID = 20
|
||||||
|
const DATE_OID = 1082
|
||||||
|
|
||||||
// WAL mode for better concurrent read performance
|
pg.types.setTypeParser(INT8_OID, (val) => {
|
||||||
sqlite.pragma("journal_mode = WAL")
|
const n = Number(val)
|
||||||
sqlite.pragma("foreign_keys = ON")
|
return Number.isSafeInteger(n) ? n : val
|
||||||
sqlite.exec(`
|
})
|
||||||
CREATE TABLE IF NOT EXISTS servers (
|
pg.types.setTypeParser(DATE_OID, (val) => val)
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL DEFAULT '',
|
|
||||||
host TEXT NOT NULL,
|
|
||||||
port INTEGER NOT NULL DEFAULT 443,
|
|
||||||
username TEXT NOT NULL DEFAULT 'admin',
|
|
||||||
password TEXT NOT NULL DEFAULT '',
|
|
||||||
use_ssl INTEGER NOT NULL DEFAULT 1,
|
|
||||||
verify_ssl INTEGER NOT NULL DEFAULT 0,
|
|
||||||
type TEXT NOT NULL DEFAULT 'home-router',
|
|
||||||
site TEXT NOT NULL DEFAULT '',
|
|
||||||
country TEXT NOT NULL DEFAULT '',
|
|
||||||
asn TEXT NOT NULL DEFAULT '',
|
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
lan_subnet TEXT NOT NULL DEFAULT '',
|
|
||||||
wan_uplinks TEXT NOT NULL DEFAULT '[]',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS server_snapshots (
|
export function createPool(max = 16): pg.Pool {
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
return new pg.Pool({
|
||||||
server_id INTEGER NOT NULL,
|
connectionString: env.DATABASE_URL,
|
||||||
polled_at TEXT NOT NULL,
|
max,
|
||||||
status TEXT NOT NULL,
|
idleTimeoutMillis: 30_000,
|
||||||
latency_ms REAL,
|
connectionTimeoutMillis: 8_000,
|
||||||
ros_version TEXT,
|
statement_timeout: 120_000,
|
||||||
board_name TEXT,
|
})
|
||||||
uptime TEXT,
|
|
||||||
cpu_load INTEGER,
|
|
||||||
free_memory INTEGER,
|
|
||||||
total_memory INTEGER,
|
|
||||||
identity_name TEXT,
|
|
||||||
raw_interfaces TEXT,
|
|
||||||
raw_ip_addresses TEXT,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
community TEXT NOT NULL,
|
|
||||||
community_name TEXT,
|
|
||||||
action TEXT NOT NULL DEFAULT 'route',
|
|
||||||
gateway TEXT NOT NULL DEFAULT '',
|
|
||||||
gateway_tunnel_id TEXT NOT NULL DEFAULT '',
|
|
||||||
description TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_filter_rules_server_sort
|
|
||||||
ON filter_rules(server_id, sort_order);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recursive_routes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
dst_address TEXT NOT NULL,
|
|
||||||
gateway TEXT NOT NULL,
|
|
||||||
distance INTEGER NOT NULL DEFAULT 1,
|
|
||||||
scope INTEGER,
|
|
||||||
target_scope INTEGER,
|
|
||||||
routing_table TEXT NOT NULL DEFAULT 'main',
|
|
||||||
check_gateway TEXT NOT NULL DEFAULT '',
|
|
||||||
country TEXT NOT NULL DEFAULT '',
|
|
||||||
comment TEXT NOT NULL DEFAULT '',
|
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_recursive_routes_server_sort
|
|
||||||
ON recursive_routes(server_id, sort_order);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS traffic_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
interval_sec INTEGER NOT NULL DEFAULT 30,
|
|
||||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
|
||||||
last_collected_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS traffic_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
interface_name TEXT NOT NULL,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
rx_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
tx_bytes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
rx_bps INTEGER NOT NULL DEFAULT 0,
|
|
||||||
tx_bps INTEGER NOT NULL DEFAULT 0,
|
|
||||||
running INTEGER NOT NULL DEFAULT 0,
|
|
||||||
disabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_time
|
|
||||||
ON traffic_samples(server_id, sampled_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_traffic_samples_server_iface_time
|
|
||||||
ON traffic_samples(server_id, interface_name, sampled_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
interval_sec INTEGER NOT NULL DEFAULT 15,
|
|
||||||
probe_interval_sec INTEGER NOT NULL DEFAULT 15,
|
|
||||||
speed_interval_sec INTEGER NOT NULL DEFAULT 60,
|
|
||||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
|
||||||
last_collected_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_probes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
src_server_id INTEGER NOT NULL,
|
|
||||||
src_interface TEXT NOT NULL DEFAULT '',
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
probe_filter TEXT NOT NULL DEFAULT '—',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
show_on_dashboard INTEGER NOT NULL DEFAULT 0,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uptime_probes_server_sort
|
|
||||||
ON uptime_probes(src_server_id, sort_order);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_probe_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
probe_id TEXT NOT NULL,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
rtt_ms INTEGER,
|
|
||||||
loss_pct INTEGER NOT NULL DEFAULT 0,
|
|
||||||
status TEXT NOT NULL DEFAULT 'down',
|
|
||||||
FOREIGN KEY (probe_id) REFERENCES uptime_probes(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uptime_probe_samples_probe_time
|
|
||||||
ON uptime_probe_samples(probe_id, sampled_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_resource_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'offline',
|
|
||||||
cpu_load INTEGER NOT NULL DEFAULT 0,
|
|
||||||
free_memory INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_memory INTEGER NOT NULL DEFAULT 0,
|
|
||||||
free_hdd_space INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_hdd_space INTEGER NOT NULL DEFAULT 0,
|
|
||||||
uptime_seconds INTEGER NOT NULL DEFAULT 0,
|
|
||||||
board_name TEXT NOT NULL DEFAULT '',
|
|
||||||
ros_version TEXT NOT NULL DEFAULT '',
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uptime_resource_samples_server_time
|
|
||||||
ON uptime_resource_samples(server_id, sampled_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_speed_probes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
src_server_id INTEGER NOT NULL,
|
|
||||||
dst_server_id INTEGER NOT NULL,
|
|
||||||
src_interface TEXT NOT NULL DEFAULT '',
|
|
||||||
dst_interface TEXT NOT NULL DEFAULT '',
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
|
||||||
direction TEXT NOT NULL DEFAULT 'both',
|
|
||||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
last_run_at TEXT,
|
|
||||||
last_tx_avg_mbps REAL,
|
|
||||||
last_rx_avg_mbps REAL,
|
|
||||||
last_status TEXT,
|
|
||||||
last_error TEXT,
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_probes_src_sort
|
|
||||||
ON uptime_speed_probes(src_server_id, sort_order);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS uptime_speed_test_runs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
probe_id TEXT,
|
|
||||||
src_server_id INTEGER NOT NULL,
|
|
||||||
dst_server_id INTEGER NOT NULL,
|
|
||||||
src_interface TEXT NOT NULL DEFAULT '',
|
|
||||||
dst_interface TEXT NOT NULL DEFAULT '',
|
|
||||||
src_address TEXT,
|
|
||||||
dst_address TEXT,
|
|
||||||
src_interface_address TEXT,
|
|
||||||
dst_interface_address TEXT,
|
|
||||||
protocol TEXT NOT NULL DEFAULT 'tcp',
|
|
||||||
direction TEXT NOT NULL DEFAULT 'both',
|
|
||||||
duration_sec INTEGER NOT NULL DEFAULT 10,
|
|
||||||
tx_avg_mbps REAL,
|
|
||||||
rx_avg_mbps REAL,
|
|
||||||
ping_rtt_ms INTEGER,
|
|
||||||
ping_loss_pct INTEGER,
|
|
||||||
ping_error TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'done',
|
|
||||||
error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (src_server_id) REFERENCES servers(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (dst_server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at
|
|
||||||
ON uptime_speed_test_runs(created_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS scheduler_runs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
job_key TEXT NOT NULL,
|
|
||||||
started_at TEXT NOT NULL,
|
|
||||||
finished_at TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
error TEXT,
|
|
||||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
||||||
result_json TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time
|
|
||||||
ON scheduler_runs(job_key, finished_at);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS internet_path_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
interval_sec INTEGER NOT NULL DEFAULT 300,
|
|
||||||
retention_days INTEGER NOT NULL DEFAULT 14,
|
|
||||||
last_collected_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS internet_path_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_internet_path_snapshots_sampled
|
|
||||||
ON internet_path_snapshots(sampled_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
level TEXT NOT NULL,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
source_module TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
entity_type TEXT,
|
|
||||||
entity_id TEXT,
|
|
||||||
payload_json TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_level_created_at ON events(level, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_source_created_at ON events(source_module, created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_event_type_created_at ON events(event_type, created_at DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS servers_api_ping_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
interval_sec INTEGER NOT NULL DEFAULT 120,
|
|
||||||
last_collected_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS servers_rest_ping_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
server_id INTEGER NOT NULL,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
ok INTEGER NOT NULL DEFAULT 0,
|
|
||||||
latency_ms INTEGER,
|
|
||||||
error TEXT,
|
|
||||||
FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id
|
|
||||||
ON servers_rest_ping_samples(server_id, id DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
target_label TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id
|
|
||||||
ON alert_gre_tunnel_samples(target_label, id DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
sampled_at TEXT NOT NULL,
|
|
||||||
peer_key TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id
|
|
||||||
ON alert_bgp_peer_samples(peer_key, id DESC);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS evobgp_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
base_url TEXT NOT NULL DEFAULT '',
|
|
||||||
api_key TEXT NOT NULL DEFAULT '',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
bot_token TEXT NOT NULL DEFAULT '',
|
|
||||||
chat_id TEXT NOT NULL DEFAULT '',
|
|
||||||
message_thread_id INTEGER,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
|
||||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
|
||||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
|
||||||
account_private_key TEXT NOT NULL DEFAULT '',
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
step TEXT NOT NULL DEFAULT 'queued',
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
server_id TEXT NOT NULL,
|
|
||||||
cert_name TEXT NOT NULL,
|
|
||||||
domain_names TEXT NOT NULL,
|
|
||||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
|
||||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
|
||||||
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
started_at TEXT,
|
|
||||||
finished_at TEXT,
|
|
||||||
error TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS certificate_renew_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
|
||||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
|
||||||
last_collected_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
|
||||||
hour INTEGER NOT NULL DEFAULT 3,
|
|
||||||
minute INTEGER NOT NULL DEFAULT 0,
|
|
||||||
week_day INTEGER NOT NULL DEFAULT 0,
|
|
||||||
month_day INTEGER NOT NULL DEFAULT 1,
|
|
||||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
|
||||||
format TEXT NOT NULL DEFAULT 'rsc',
|
|
||||||
server_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
last_run_at TEXT,
|
|
||||||
last_duration_ms INTEGER,
|
|
||||||
last_error TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS backup_entries (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
server_id TEXT NOT NULL,
|
|
||||||
server_name TEXT NOT NULL,
|
|
||||||
filename TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
kind TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
notes TEXT,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_backup_entries_server_created ON backup_entries(server_id, created_at);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_backup_entries_filename ON backup_entries(filename);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
condition TEXT NOT NULL,
|
|
||||||
severity TEXT NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
cooldown TEXT NOT NULL DEFAULT '5м',
|
|
||||||
rule_chat_id TEXT NOT NULL DEFAULT '',
|
|
||||||
recovery_mode TEXT NOT NULL DEFAULT 'always',
|
|
||||||
recovery_stability_sec INTEGER,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_history (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
rule_id TEXT,
|
|
||||||
rule_name TEXT NOT NULL,
|
|
||||||
severity TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
sent_ok INTEGER NOT NULL DEFAULT 1,
|
|
||||||
fired_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_groups (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
combine_mode TEXT NOT NULL DEFAULT 'any',
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
cooldown_override TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rule_targets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
rule_id TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
sort_index INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_rule_conditions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
rule_id TEXT NOT NULL,
|
|
||||||
condition_line TEXT NOT NULL,
|
|
||||||
sort_index INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_engine_state (
|
|
||||||
scope_key TEXT PRIMARY KEY,
|
|
||||||
last_fired_at TEXT NOT NULL DEFAULT '',
|
|
||||||
last_payload_hash TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_engine_prev_live (
|
|
||||||
kind TEXT PRIMARY KEY,
|
|
||||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending (
|
|
||||||
rule_id TEXT PRIMARY KEY,
|
|
||||||
payload_hash TEXT NOT NULL,
|
|
||||||
since_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_destinations (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
kind TEXT NOT NULL DEFAULT 'telegram',
|
|
||||||
label TEXT NOT NULL DEFAULT '',
|
|
||||||
telegram_chat_id TEXT NOT NULL DEFAULT '',
|
|
||||||
message_thread_id INTEGER,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_outbox (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
dedupe_key TEXT NOT NULL,
|
|
||||||
channel TEXT NOT NULL DEFAULT 'telegram',
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
||||||
next_attempt_at TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
sent_at TEXT,
|
|
||||||
last_error TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt
|
|
||||||
ON alert_outbox(status, next_attempt_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe
|
|
||||||
ON alert_outbox(dedupe_key);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alert_engine_cursor (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
last_source_finished_at TEXT,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
|
|
||||||
// Lightweight schema evolution for existing databases without migrations
|
|
||||||
const recursiveCols = sqlite.prepare(`PRAGMA table_info('recursive_routes')`).all() as Array<{ name?: string }>
|
|
||||||
const hasCountryColumn = recursiveCols.some((c) => c.name === "country")
|
|
||||||
if (!hasCountryColumn) {
|
|
||||||
sqlite.exec(`ALTER TABLE recursive_routes ADD COLUMN country TEXT NOT NULL DEFAULT ''`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uptimeProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_probes')`).all() as Array<{ name?: string }>
|
export const pool = createPool(isMainThread ? 16 : 4)
|
||||||
const hasSrcInterfaceColumn = uptimeProbeCols.some((c) => c.name === "src_interface")
|
export const db = drizzle(pool, { schema })
|
||||||
if (!hasSrcInterfaceColumn) {
|
|
||||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN src_interface TEXT NOT NULL DEFAULT ''`)
|
export async function dbQuery<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||||
}
|
text: string,
|
||||||
const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on_dashboard")
|
params?: unknown[] | Record<string, unknown>,
|
||||||
if (!hasShowOnDashboardColumn) {
|
): Promise<pg.QueryResult<T>> {
|
||||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`)
|
const q = bindSql(text, params)
|
||||||
}
|
return pool.query<T>(q)
|
||||||
const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec")
|
|
||||||
if (!hasProbeIntervalSecColumn) {
|
|
||||||
sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }>
|
export async function dbAll<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||||
const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec")
|
text: string,
|
||||||
if (!hasProbeIntervalColumn) {
|
params?: unknown[] | Record<string, unknown>,
|
||||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN probe_interval_sec INTEGER NOT NULL DEFAULT 15`)
|
): Promise<T[]> {
|
||||||
}
|
const res = await dbQuery<T>(text, params)
|
||||||
const hasSpeedIntervalColumn = uptimeSettingsCols.some((c) => c.name === "speed_interval_sec")
|
return res.rows
|
||||||
if (!hasSpeedIntervalColumn) {
|
|
||||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled")
|
export async function dbGet<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||||
if (!hasUptimeJobFlags) {
|
text: string,
|
||||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`)
|
params?: unknown[] | Record<string, unknown>,
|
||||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`)
|
): Promise<T | undefined> {
|
||||||
sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`)
|
const rows = await dbAll<T>(text, params)
|
||||||
sqlite.exec(
|
return rows[0]
|
||||||
`UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }>
|
export async function withAdvisoryLock<T>(key: number, fn: () => Promise<T>): Promise<T> {
|
||||||
const ensureSpeedProbeCol = (name: string, ddl: string) => {
|
const client = await pool.connect()
|
||||||
if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl)
|
try {
|
||||||
}
|
await client.query("SELECT pg_advisory_lock($1)", [key])
|
||||||
ensureSpeedProbeCol("last_run_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_run_at TEXT`)
|
return await fn()
|
||||||
ensureSpeedProbeCol("last_tx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_tx_avg_mbps REAL`)
|
} finally {
|
||||||
ensureSpeedProbeCol("last_rx_avg_mbps", `ALTER TABLE uptime_speed_probes ADD COLUMN last_rx_avg_mbps REAL`)
|
|
||||||
ensureSpeedProbeCol("last_status", `ALTER TABLE uptime_speed_probes ADD COLUMN last_status TEXT`)
|
|
||||||
ensureSpeedProbeCol("last_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_error TEXT`)
|
|
||||||
ensureSpeedProbeCol("last_ping_rtt_ms", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_rtt_ms INTEGER`)
|
|
||||||
ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_loss_pct INTEGER`)
|
|
||||||
ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`)
|
|
||||||
ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`)
|
|
||||||
|
|
||||||
const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }>
|
|
||||||
if (!schedulerRunCols.some((c) => c.name === "result_json")) {
|
|
||||||
sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }>
|
|
||||||
if (!serverCols.some((c) => c.name === "lan_subnet")) {
|
|
||||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`)
|
|
||||||
}
|
|
||||||
if (!serverCols.some((c) => c.name === "wan_uplinks")) {
|
|
||||||
sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }>
|
|
||||||
if (!alertTgCols.some((c) => c.name === "message_thread_id")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }>
|
|
||||||
if (!alertRulesCols.some((c) => c.name === "group_id")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`)
|
|
||||||
}
|
|
||||||
if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`)
|
|
||||||
}
|
|
||||||
if (!alertRulesCols.some((c) => c.name === "recovery_mode")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`)
|
|
||||||
}
|
|
||||||
if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }>
|
|
||||||
if (!alertHistoryCols.some((c) => c.name === "group_id")) {
|
|
||||||
sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Одна строка target на правило из legacy-колонки `alert_rules.target` */
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index)
|
|
||||||
SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules
|
|
||||||
WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets)
|
|
||||||
`)
|
|
||||||
|
|
||||||
/** Одна строка условия из legacy `alert_rules.condition` */
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index)
|
|
||||||
SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules
|
|
||||||
WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions)
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days)
|
|
||||||
SELECT 1, 1, 30, 14
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM traffic_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO uptime_settings (id, enabled, interval_sec, retention_days)
|
|
||||||
SELECT 1, 1, 15, 14
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM uptime_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO evobgp_settings (id, base_url, api_key, enabled)
|
|
||||||
SELECT 1, '', '', 0
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO servers_api_ping_settings (id, enabled, interval_sec)
|
|
||||||
SELECT 1, 0, 120
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO internet_path_settings (id, enabled, interval_sec, retention_days)
|
|
||||||
SELECT 1, 1, 300, 14
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM internet_path_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO alert_telegram_settings (id, bot_token, chat_id)
|
|
||||||
SELECT 1, '', ''
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
|
|
||||||
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
|
||||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
|
||||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
|
|
||||||
SELECT 1, 1, 21600, 30
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
|
|
||||||
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
sqlite.exec(`
|
|
||||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
|
||||||
SELECT 1, NULL
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1);
|
|
||||||
`)
|
|
||||||
|
|
||||||
const backupEntryCount = sqlite.prepare(`SELECT COUNT(*) AS c FROM backup_entries`).get() as { c: number }
|
|
||||||
if (backupEntryCount.c === 0) {
|
|
||||||
const legacyIndexPath = path.resolve(process.cwd(), "storage", "backups", "index.json")
|
|
||||||
if (existsSync(legacyIndexPath)) {
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(readFileSync(legacyIndexPath, "utf8")) as unknown
|
await client.query("SELECT pg_advisory_unlock($1)", [key])
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
const insert = sqlite.prepare(`
|
|
||||||
INSERT OR IGNORE INTO backup_entries (id, server_id, server_name, filename, size_bytes, kind, notes, created_at)
|
|
||||||
VALUES (@id, @serverId, @serverName, @filename, @sizeBytes, @kind, @notes, @createdAt)
|
|
||||||
`)
|
|
||||||
for (const row of parsed) {
|
|
||||||
if (!row || typeof row !== "object") continue
|
|
||||||
const item = row as Record<string, unknown>
|
|
||||||
const id = String(item.id ?? "").trim()
|
|
||||||
const filename = String(item.filename ?? "").trim()
|
|
||||||
if (!id || !filename) continue
|
|
||||||
insert.run({
|
|
||||||
id,
|
|
||||||
serverId: String(item.serverId ?? ""),
|
|
||||||
serverName: String(item.serverName ?? ""),
|
|
||||||
filename,
|
|
||||||
sizeBytes: Number(item.sizeBytes ?? 0) || 0,
|
|
||||||
kind: item.kind === "auto" ? "auto" : "manual",
|
|
||||||
notes: item.notes == null ? null : String(item.notes),
|
|
||||||
createdAt: String(item.createdAt ?? new Date().toISOString()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* legacy index.json не читается — пропускаем */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
client.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema })
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool.end()
|
||||||
/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */
|
}
|
||||||
export const sqliteDatabase: SqliteHandle = sqlite
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/** JSONB from node-pg may already be an object; SQLite leftovers may be strings. */
|
||||||
|
|
||||||
|
export function parseJsonObject(value: unknown): Record<string, unknown> {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonArray(value: unknown): unknown[] {
|
||||||
|
if (Array.isArray(value)) return value
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
if (Array.isArray(parsed)) return parsed
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toJsonb(value: unknown): unknown {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { readdirSync, readFileSync } from "node:fs"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import type { Pool } from "pg"
|
||||||
|
|
||||||
|
const FIRST_MIGRATION = "0000_postgresql.sql"
|
||||||
|
|
||||||
|
function migrationsDir(): string {
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const candidates = [
|
||||||
|
join(here, "..", "..", "drizzle"),
|
||||||
|
join(process.cwd(), "drizzle"),
|
||||||
|
join(process.cwd(), "backend", "drizzle"),
|
||||||
|
]
|
||||||
|
for (const dir of candidates) {
|
||||||
|
try {
|
||||||
|
readFileSync(join(dir, FIRST_MIGRATION), "utf8")
|
||||||
|
return dir
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Не найден backend/drizzle/0000_postgresql.sql")
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrationId(file: string): string {
|
||||||
|
return file.replace(/\.sql$/i, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySqlMigrations(pool: Pool): Promise<void> {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
const dir = migrationsDir()
|
||||||
|
const files = readdirSync(dir)
|
||||||
|
.filter((f) => /^\d{4}_.+\.sql$/i.test(f))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
const applied = new Set(
|
||||||
|
(await pool.query<{ id: string }>(`SELECT id FROM schema_migrations`)).rows.map((r) => r.id),
|
||||||
|
)
|
||||||
|
for (const file of files) {
|
||||||
|
const id = migrationId(file)
|
||||||
|
if (applied.has(id)) continue
|
||||||
|
const sql = readFileSync(join(dir, file), "utf8")
|
||||||
|
await pool.query(sql)
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO schema_migrations (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Pool } from "pg"
|
||||||
|
|
||||||
|
export type PartitionKind = "day" | "week" | "month"
|
||||||
|
|
||||||
|
export interface PartitionSpec {
|
||||||
|
parent: string
|
||||||
|
kind: PartitionKind
|
||||||
|
keepDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leaf-only: PG forbids storage params on partitioned parents (SQLSTATE 42809). */
|
||||||
|
const FACT_LEAF_STORAGE =
|
||||||
|
"fillfactor = 70, autovacuum_vacuum_scale_factor = 0.05, autovacuum_vacuum_cost_limit = 2000"
|
||||||
|
|
||||||
|
const FACT_PARENTS = new Set(["flow_hour_facts", "flow_daily_facts"])
|
||||||
|
|
||||||
|
export const PARTITION_SPECS: PartitionSpec[] = [
|
||||||
|
{ parent: "flow_buckets", kind: "day", keepDays: 4 },
|
||||||
|
{ parent: "flow_minute_stats", kind: "day", keepDays: 4 },
|
||||||
|
{ parent: "flow_minute_dims", kind: "day", keepDays: 4 },
|
||||||
|
{ parent: "flow_daily_dims", kind: "month", keepDays: 420 },
|
||||||
|
{ parent: "flow_hour_facts", kind: "day", keepDays: 3 },
|
||||||
|
{ parent: "flow_daily_facts", kind: "month", keepDays: 420 },
|
||||||
|
{ parent: "traffic_samples", kind: "week", keepDays: 21 },
|
||||||
|
{ parent: "servers_rest_ping_samples", kind: "week", keepDays: 35 },
|
||||||
|
{ parent: "uptime_probe_samples", kind: "week", keepDays: 21 },
|
||||||
|
{ parent: "uptime_resource_samples", kind: "week", keepDays: 21 },
|
||||||
|
{ parent: "server_snapshots", kind: "week", keepDays: 21 },
|
||||||
|
{ parent: "internet_path_snapshots", kind: "week", keepDays: 21 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const WEEK_SLACK_DAYS = 7
|
||||||
|
|
||||||
|
async function resolveKeepDays(pool: Pool): Promise<Map<string, number>> {
|
||||||
|
const map = new Map(PARTITION_SPECS.map((s) => [s.parent, s.keepDays]))
|
||||||
|
try {
|
||||||
|
const traffic = await pool.query<{ retention_days: number }>(
|
||||||
|
`SELECT retention_days FROM traffic_settings WHERE id = 1`,
|
||||||
|
)
|
||||||
|
const td = Number(traffic.rows[0]?.retention_days)
|
||||||
|
if (Number.isFinite(td) && td > 0) map.set("traffic_samples", td + WEEK_SLACK_DAYS)
|
||||||
|
|
||||||
|
const uptime = await pool.query<{ retention_days: number }>(
|
||||||
|
`SELECT retention_days FROM uptime_settings WHERE id = 1`,
|
||||||
|
)
|
||||||
|
const ud = Number(uptime.rows[0]?.retention_days)
|
||||||
|
if (Number.isFinite(ud) && ud > 0) {
|
||||||
|
map.set("uptime_probe_samples", ud + WEEK_SLACK_DAYS)
|
||||||
|
map.set("uptime_resource_samples", ud + WEEK_SLACK_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = await pool.query<{ retention_days: number }>(
|
||||||
|
`SELECT retention_days FROM internet_path_settings WHERE id = 1`,
|
||||||
|
)
|
||||||
|
const pd = Number(path.rows[0]?.retention_days)
|
||||||
|
if (Number.isFinite(pd) && pd > 0) map.set("internet_path_snapshots", pd + WEEK_SLACK_DAYS)
|
||||||
|
} catch {
|
||||||
|
/* settings may be absent mid-migration */
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcDate(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUtcDays(d: Date, n: number): Date {
|
||||||
|
const x = utcDate(d)
|
||||||
|
x.setUTCDate(x.getUTCDate() + n)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeekUtc(d: Date): Date {
|
||||||
|
const x = utcDate(d)
|
||||||
|
const day = x.getUTCDay() || 7
|
||||||
|
x.setUTCDate(x.getUTCDate() - (day - 1))
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfMonthUtc(d: Date): Date {
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDay(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function partName(parent: string, kind: PartitionKind, start: Date): string {
|
||||||
|
if (kind === "day") return `${parent}_d_${fmtDay(start).replaceAll("-", "")}`
|
||||||
|
if (kind === "week") return `${parent}_w_${fmtDay(start).replaceAll("-", "")}`
|
||||||
|
return `${parent}_m_${start.getUTCFullYear()}${String(start.getUTCMonth() + 1).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFor(kind: PartitionKind, ts: Date): { start: Date; end: Date } {
|
||||||
|
if (kind === "day") {
|
||||||
|
const start = utcDate(ts)
|
||||||
|
return { start, end: addUtcDays(start, 1) }
|
||||||
|
}
|
||||||
|
if (kind === "week") {
|
||||||
|
const start = startOfWeekUtc(ts)
|
||||||
|
return { start, end: addUtcDays(start, 7) }
|
||||||
|
}
|
||||||
|
const start = startOfMonthUtc(ts)
|
||||||
|
const end = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + 1, 1))
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePartitionFor(
|
||||||
|
pool: Pool,
|
||||||
|
parent: string,
|
||||||
|
kind: PartitionKind,
|
||||||
|
ts: Date,
|
||||||
|
): Promise<string> {
|
||||||
|
const { start, end } = rangeFor(kind, ts)
|
||||||
|
const name = partName(parent, kind, start)
|
||||||
|
const from = fmtDay(start)
|
||||||
|
const to = fmtDay(end)
|
||||||
|
await pool.query(
|
||||||
|
`CREATE TABLE IF NOT EXISTS ${name} PARTITION OF ${parent} FOR VALUES FROM ('${from}') TO ('${to}')`,
|
||||||
|
)
|
||||||
|
if (FACT_PARENTS.has(parent)) {
|
||||||
|
await pool.query(`ALTER TABLE ${name} SET (${FACT_LEAF_STORAGE})`)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePartitionsBetween(
|
||||||
|
pool: Pool,
|
||||||
|
parent: string,
|
||||||
|
kind: PartitionKind,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = from.getTime() <= to.getTime() ? from : to
|
||||||
|
const end = from.getTime() <= to.getTime() ? to : from
|
||||||
|
for (let t = new Date(start.getTime()); t <= end; ) {
|
||||||
|
await ensurePartitionFor(pool, parent, kind, t)
|
||||||
|
if (kind === "day") t = addUtcDays(t, 1)
|
||||||
|
else if (kind === "week") t = addUtcDays(t, 7)
|
||||||
|
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePartitionsAround(pool: Pool, around = new Date()): Promise<void> {
|
||||||
|
const keepDays = await resolveKeepDays(pool)
|
||||||
|
for (const spec of PARTITION_SPECS) {
|
||||||
|
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||||
|
const daysAhead = spec.kind === "month" ? 40 : spec.kind === "week" ? 21 : 8
|
||||||
|
const start = addUtcDays(around, -keep)
|
||||||
|
const end = addUtcDays(around, daysAhead)
|
||||||
|
for (let t = new Date(start.getTime()); t < end; ) {
|
||||||
|
await ensurePartitionFor(pool, spec.parent, spec.kind, t)
|
||||||
|
if (spec.kind === "day") t = addUtcDays(t, 1)
|
||||||
|
else if (spec.kind === "week") t = addUtcDays(t, 7)
|
||||||
|
else t = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropExpiredPartitions(pool: Pool, around = new Date()): Promise<void> {
|
||||||
|
const keepDays = await resolveKeepDays(pool)
|
||||||
|
for (const spec of PARTITION_SPECS) {
|
||||||
|
const keep = keepDays.get(spec.parent) ?? spec.keepDays
|
||||||
|
const cutoff = addUtcDays(around, -keep)
|
||||||
|
const { rows } = await pool.query<{ relname: string }>(
|
||||||
|
`SELECT c.relname
|
||||||
|
FROM pg_inherits i
|
||||||
|
JOIN pg_class c ON c.oid = i.inhrelid
|
||||||
|
JOIN pg_class p ON p.oid = i.inhparent
|
||||||
|
WHERE p.relname = $1`,
|
||||||
|
[spec.parent],
|
||||||
|
)
|
||||||
|
for (const row of rows) {
|
||||||
|
const m = row.relname.match(/_([dwm])_(\d{8}|\d{6})$/)
|
||||||
|
if (!m) continue
|
||||||
|
const stamp = m[2]
|
||||||
|
let start: Date
|
||||||
|
if (stamp.length === 6) {
|
||||||
|
start = new Date(Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, 1))
|
||||||
|
} else {
|
||||||
|
start = new Date(`${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)}T00:00:00Z`)
|
||||||
|
}
|
||||||
|
if (start < cutoff) {
|
||||||
|
await pool.query(`DROP TABLE IF EXISTS ${row.relname}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function specForParent(parent: string): PartitionSpec | undefined {
|
||||||
|
return PARTITION_SPECS.find((s) => s.parent === parent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { withPgOrSkip } from "../test/pg.js"
|
||||||
|
import { dbQuery, pool } from "./index.js"
|
||||||
|
import { applySqlMigrations } from "./migrate.js"
|
||||||
|
import { ensurePartitionFor } from "./partitions.js"
|
||||||
|
|
||||||
|
if (!(await withPgOrSkip())) {
|
||||||
|
console.log("pg-schema.test.ts: skip")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ n: string }>(`SELECT COUNT(*)::text AS n FROM servers`)
|
||||||
|
assert.ok(rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO servers (name, host) VALUES ('pg-schema-test', '127.0.0.1')
|
||||||
|
`)
|
||||||
|
const { rows } = await dbQuery<{ id: number }>(`SELECT id FROM servers WHERE name = 'pg-schema-test' LIMIT 1`)
|
||||||
|
const id = rows[0]?.id
|
||||||
|
assert.ok(id)
|
||||||
|
|
||||||
|
await ensurePartitionFor(pool, "flow_buckets", "day", new Date())
|
||||||
|
const bucketAt = new Date().toISOString()
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO flow_buckets (
|
||||||
|
server_id, bucket_at, src, dst, proto, src_port, dst_port, bytes, packets, in_iface
|
||||||
|
) VALUES ($1, $2, '10.0.0.1', '8.8.8.8', 6, 1, 443, 10, 1, 'wg-flow')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, [id, bucketAt])
|
||||||
|
|
||||||
|
await dbQuery(`DELETE FROM flow_buckets WHERE server_id = $1`, [id])
|
||||||
|
await dbQuery(`DELETE FROM servers WHERE id = $1`, [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-test-1', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||||
|
ON CONFLICT (dedupe_key) DO NOTHING
|
||||||
|
`)
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-test-2', 'pg-dedupe-key', '{}'::jsonb, now())
|
||||||
|
`)
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
assert.equal(threw, true, "alert_outbox.dedupe_key UNIQUE")
|
||||||
|
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232", publicKey: "x" }]
|
||||||
|
await dbQuery(
|
||||||
|
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-json-arr', 'pg-json-arr', $1::jsonb, now())`,
|
||||||
|
[JSON.stringify(peers)],
|
||||||
|
)
|
||||||
|
const { rows } = await dbQuery<{ payload_json: unknown }>(
|
||||||
|
`SELECT payload_json FROM alert_outbox WHERE id = 'pg-json-arr'`,
|
||||||
|
)
|
||||||
|
assert.equal(Array.isArray(rows[0]?.payload_json), true)
|
||||||
|
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-arr'`)
|
||||||
|
|
||||||
|
let arrayAsPgArrayFailed = false
|
||||||
|
try {
|
||||||
|
await dbQuery(
|
||||||
|
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-json-bad', 'pg-json-bad', $1, now())`,
|
||||||
|
[peers],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
arrayAsPgArrayFailed = err instanceof Error && /json|22P02/i.test(err.message)
|
||||||
|
}
|
||||||
|
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-bad'`).catch(() => undefined)
|
||||||
|
assert.equal(arrayAsPgArrayFailed, true, "JS array must not be bound as jsonb without stringify")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ attname: string }>(`
|
||||||
|
SELECT a.attname
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
|
||||||
|
WHERE i.indrelid = 'traffic_samples'::regclass AND i.indisprimary
|
||||||
|
ORDER BY k.ord
|
||||||
|
`)
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((r) => r.attname),
|
||||||
|
["server_id", "sampled_at", "interface_name", "peer_public_key"],
|
||||||
|
"traffic_samples PK без id",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'traffic_samples'
|
||||||
|
`)
|
||||||
|
const cols = new Set(rows.map((r) => r.column_name))
|
||||||
|
assert.equal(cols.has("id"), false)
|
||||||
|
assert.equal(cols.has("running"), false)
|
||||||
|
assert.equal(cols.has("disabled"), false)
|
||||||
|
assert.equal(cols.has("flags"), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
|
||||||
|
SELECT column_name, udt_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'flow_buckets'
|
||||||
|
AND column_name IN ('src', 'dst', 'next_hop', 'proto', 'nat_src', 'nat_dst', 'nat_src_port', 'nat_dst_port')
|
||||||
|
`)
|
||||||
|
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
|
||||||
|
assert.equal(by.src, "inet")
|
||||||
|
assert.equal(by.dst, "inet")
|
||||||
|
assert.equal(by.next_hop, "inet")
|
||||||
|
assert.equal(by.proto, "int2")
|
||||||
|
assert.equal(by.nat_src, "inet")
|
||||||
|
assert.equal(by.nat_dst, "inet")
|
||||||
|
assert.equal(by.nat_src_port, "int4")
|
||||||
|
assert.equal(by.nat_dst_port, "int4")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ indexdef: string }>(`
|
||||||
|
SELECT indexdef FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'traffic_samples'
|
||||||
|
`)
|
||||||
|
const defs = rows.map((r) => r.indexdef.toLowerCase())
|
||||||
|
assert.equal(defs.some((d) => d.includes("using brin")), false, "нет BRIN на traffic_samples")
|
||||||
|
assert.equal(
|
||||||
|
defs.filter((d) => d.includes("idx_traffic_samples_server_iface_time")).length,
|
||||||
|
1,
|
||||||
|
"один btree (server_id, interface_name, sampled_at)",
|
||||||
|
)
|
||||||
|
assert.equal(defs.some((d) => d.includes("idx_traffic_samples_server_time")), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const { rows } = await dbQuery<{ column_name: string }>(`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'uptime_resource_samples'
|
||||||
|
`)
|
||||||
|
const cols = new Set(rows.map((r) => r.column_name))
|
||||||
|
assert.equal(cols.has("board_name"), false)
|
||||||
|
assert.equal(cols.has("ros_version"), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const mig = await dbQuery<{ id: string }>(
|
||||||
|
`SELECT id FROM schema_migrations WHERE id = '0002_compact_schema'`,
|
||||||
|
)
|
||||||
|
assert.equal(mig.rows.length, 1, "0002 применена")
|
||||||
|
|
||||||
|
await dbQuery(`INSERT INTO servers (name, host) VALUES ('pg-wipe-idempotent', '127.0.0.1')`)
|
||||||
|
await applySqlMigrations(pool)
|
||||||
|
const still = await dbQuery<{ n: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS n FROM servers WHERE name = 'pg-wipe-idempotent'`,
|
||||||
|
)
|
||||||
|
assert.equal(still.rows[0]?.n, "1", "повторный applySqlMigrations не wipe")
|
||||||
|
await dbQuery(`DELETE FROM servers WHERE name = 'pg-wipe-idempotent'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const mig = await dbQuery<{ id: string }>(
|
||||||
|
`SELECT id FROM schema_migrations WHERE id = '0006_config_revisions'`,
|
||||||
|
)
|
||||||
|
assert.equal(mig.rows.length, 1, "0006 применена")
|
||||||
|
|
||||||
|
const { rows } = await dbQuery<{ column_name: string; udt_name: string }>(`
|
||||||
|
SELECT column_name, udt_name FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'config_revisions'
|
||||||
|
`)
|
||||||
|
const by = Object.fromEntries(rows.map((r) => [r.column_name, r.udt_name]))
|
||||||
|
assert.equal(by.payload, "jsonb")
|
||||||
|
assert.equal(by.fingerprint, "text")
|
||||||
|
assert.equal(by.section, "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const mig = await dbQuery<{ id: string }>(
|
||||||
|
`SELECT id FROM schema_migrations WHERE id = '0007_flow_buckets_nat'`,
|
||||||
|
)
|
||||||
|
assert.equal(mig.rows.length, 1, "0007 применена")
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const marker = await dbQuery<{ sqlite_imported_at: string | null }>(
|
||||||
|
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||||
|
)
|
||||||
|
assert.ok(marker.rows[0], "data_migration singleton после wipe")
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("pg-schema.test.ts: ok")
|
||||||
+493
-290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { bindSql } from "./sql-bind.js"
|
||||||
|
|
||||||
|
{
|
||||||
|
const q = bindSql("SELECT 1")
|
||||||
|
assert.deepEqual(q, { text: "SELECT 1", values: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const q = bindSql("SELECT * FROM t WHERE a = ? AND b = ?", [1, "x"])
|
||||||
|
assert.equal(q.text, "SELECT * FROM t WHERE a = $1 AND b = $2")
|
||||||
|
assert.deepEqual(q.values, [1, "x"])
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const q = bindSql("INSERT INTO t (a, b) VALUES (@a, @b)", { a: 3, b: "y" })
|
||||||
|
assert.equal(q.text, "INSERT INTO t (a, b) VALUES ($1, $2)")
|
||||||
|
assert.deepEqual(q.values, [3, "y"])
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("sql-bind.test.ts: ok")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Convert SQLite-style @name / ? placeholders to node-pg $n. */
|
||||||
|
|
||||||
|
export function bindSql(
|
||||||
|
sql: string,
|
||||||
|
params?: unknown[] | Record<string, unknown>,
|
||||||
|
): { text: string; values: unknown[] } {
|
||||||
|
if (params == null) return { text: sql, values: [] }
|
||||||
|
if (Array.isArray(params)) {
|
||||||
|
let i = 0
|
||||||
|
const text = sql.replace(/\?/g, () => {
|
||||||
|
i += 1
|
||||||
|
return `$${i}`
|
||||||
|
})
|
||||||
|
return { text, values: params }
|
||||||
|
}
|
||||||
|
const values: unknown[] = []
|
||||||
|
const text = sql.replace(/@([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, name: string) => {
|
||||||
|
values.push(params[name])
|
||||||
|
return `$${values.length}`
|
||||||
|
})
|
||||||
|
return { text, values }
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import Database from "better-sqlite3"
|
||||||
|
import type { Pool } from "pg"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
import { ensurePartitionsBetween, specForParent } from "./partitions.js"
|
||||||
|
import { encodeTrafficFlags } from "./traffic-flags.js"
|
||||||
|
|
||||||
|
export interface ImportReport {
|
||||||
|
sqlitePath: string
|
||||||
|
sqliteSha256: string
|
||||||
|
tables: Record<string, { sqlite: number; copied: number; skipped: number }>
|
||||||
|
rejects: string[]
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT_RETENTION_DAYS = 14
|
||||||
|
|
||||||
|
type ColKind = "ts" | "date" | "bool" | "json" | "json-null" | "bigint-id" | "int" | "text" | "num" | "flags" | "inet"
|
||||||
|
|
||||||
|
const INSERT_CHUNK = 1000
|
||||||
|
|
||||||
|
interface TableCopy {
|
||||||
|
table: string
|
||||||
|
columns: Array<[string, ColKind]>
|
||||||
|
timeCol?: string
|
||||||
|
retentionDays?: number
|
||||||
|
identity?: boolean
|
||||||
|
upsert?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLES: TableCopy[] = [
|
||||||
|
{ table: "servers", identity: true, columns: [
|
||||||
|
["id", "int"], ["name", "text"], ["host", "text"], ["port", "int"], ["username", "text"],
|
||||||
|
["password", "text"], ["use_ssl", "bool"], ["verify_ssl", "bool"], ["type", "text"],
|
||||||
|
["site", "text"], ["country", "text"], ["asn", "text"], ["comment", "text"], ["enabled", "bool"],
|
||||||
|
["lan_subnet", "text"], ["wan_uplinks", "json"], ["mgmt_tunnel_ip", "text"],
|
||||||
|
["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "traffic_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||||
|
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||||
|
["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "servers_api_ping_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||||
|
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||||
|
["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "traffic_flow_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["collector_ip", "text"], ["flow_listen_port", "int"],
|
||||||
|
["wg_listen_port", "int"], ["prefix", "text"], ["public_endpoint", "text"],
|
||||||
|
["host_public_key", "text"], ["host_private_key", "text"], ["hub_server_id", "int"],
|
||||||
|
["retention_hours", "int"], ["top_n", "int"], ["map_service_min_share_pct", "num"],
|
||||||
|
["last_datagram_at", "ts"], ["last_exporter_ip", "text"], ["last_error", "text"],
|
||||||
|
["packets_received", "int"], ["peers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["resources_enabled", "bool"], ["ping_enabled", "bool"],
|
||||||
|
["speed_enabled", "bool"], ["interval_sec", "int"], ["probe_interval_sec", "int"],
|
||||||
|
["speed_interval_sec", "int"], ["retention_days", "int"], ["last_collected_at", "ts"],
|
||||||
|
["last_duration_ms", "int"], ["last_error", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "evobgp_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["base_url", "text"], ["api_key", "text"], ["enabled", "bool"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_telegram_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["bot_token", "text"], ["chat_id", "text"], ["message_thread_id", "int"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "acme_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["directory_url", "text"], ["cloudflare_api_token", "text"],
|
||||||
|
["default_zone_id", "text"], ["account_private_key", "text"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "certificate_renew_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["renew_before_days", "int"],
|
||||||
|
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "backup_schedule_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["frequency", "text"], ["hour", "int"], ["minute", "int"],
|
||||||
|
["week_day", "int"], ["month_day", "int"], ["keep_count", "int"], ["format", "text"],
|
||||||
|
["server_ids_json", "json"], ["last_run_at", "ts"], ["last_duration_ms", "int"],
|
||||||
|
["last_error", "text"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "backup_storage_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["provider", "text"], ["s3_endpoint", "text"], ["s3_region", "text"],
|
||||||
|
["s3_bucket", "text"], ["s3_prefix", "text"], ["s3_access_key_id", "text"],
|
||||||
|
["s3_secret_access_key", "text"], ["s3_force_path_style", "bool"], ["keep_local_copy", "bool"],
|
||||||
|
["last_test_at", "ts"], ["last_test_error", "text"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "internet_path_settings", upsert: true, columns: [
|
||||||
|
["id", "int"], ["enabled", "bool"], ["interval_sec", "int"], ["retention_days", "int"],
|
||||||
|
["last_collected_at", "ts"], ["last_duration_ms", "int"], ["last_error", "text"],
|
||||||
|
["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_engine_cursor", upsert: true, columns: [
|
||||||
|
["id", "int"], ["last_source_finished_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "filter_rules", identity: true, columns: [
|
||||||
|
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["community", "text"],
|
||||||
|
["community_name", "text"], ["action", "text"], ["gateway", "text"],
|
||||||
|
["gateway_tunnel_id", "text"], ["description", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "recursive_routes", identity: true, columns: [
|
||||||
|
["id", "int"], ["server_id", "int"], ["sort_order", "int"], ["dst_address", "text"],
|
||||||
|
["gateway", "text"], ["distance", "int"], ["scope", "int"], ["target_scope", "int"],
|
||||||
|
["routing_table", "text"], ["check_gateway", "text"], ["country", "text"], ["comment", "text"],
|
||||||
|
["disabled", "bool"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "server_snapshots", identity: true, timeCol: "polled_at", retentionDays: SNAPSHOT_RETENTION_DAYS, columns: [
|
||||||
|
["id", "int"], ["server_id", "int"], ["polled_at", "ts"], ["status", "text"], ["latency_ms", "num"],
|
||||||
|
["ros_version", "text"], ["board_name", "text"], ["uptime", "text"], ["cpu_load", "int"],
|
||||||
|
["free_memory", "int"], ["total_memory", "int"], ["identity_name", "text"],
|
||||||
|
["raw_interfaces", "json-null"], ["raw_ip_addresses", "json-null"],
|
||||||
|
]},
|
||||||
|
{ table: "traffic_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||||
|
["server_id", "int"], ["interface_name", "text"], ["peer_public_key", "text"],
|
||||||
|
["sampled_at", "ts"], ["rx_bytes", "int"], ["tx_bytes", "int"], ["rx_bps", "int"], ["tx_bps", "int"],
|
||||||
|
["flags", "flags"],
|
||||||
|
]},
|
||||||
|
{ table: "servers_rest_ping_samples", timeCol: "sampled_at", retentionDays: 30, columns: [
|
||||||
|
["server_id", "int"], ["sampled_at", "ts"], ["ok", "bool"], ["latency_ms", "int"], ["error", "text"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_buckets", timeCol: "bucket_at", retentionDays: 2, columns: [
|
||||||
|
["server_id", "int"], ["bucket_at", "ts"], ["src", "inet"], ["dst", "inet"], ["proto", "int"],
|
||||||
|
["src_port", "int"], ["dst_port", "int"], ["bytes", "int"], ["packets", "int"],
|
||||||
|
["in_iface", "text"], ["out_iface", "text"], ["next_hop", "inet"],
|
||||||
|
["flow_start_ms", "int"], ["flow_end_ms", "int"],
|
||||||
|
["nat_src", "inet"], ["nat_dst", "inet"], ["nat_src_port", "int"], ["nat_dst_port", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_minute_stats", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||||
|
["server_id", "int"], ["bucket_at", "ts"], ["bytes", "int"], ["packets", "int"],
|
||||||
|
["unique_src", "int"], ["unique_dst", "int"], ["conversations", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_minute_dims", timeCol: "bucket_at", retentionDays: 3, columns: [
|
||||||
|
["server_id", "int"], ["bucket_at", "ts"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_daily_dims", timeCol: "day", retentionDays: 396, columns: [
|
||||||
|
["server_id", "int"], ["day", "date"], ["dim", "text"], ["key", "text"], ["bytes", "int"], ["packets", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_ip_meta", columns: [
|
||||||
|
["prefix", "text"], ["asn", "int"], ["country", "text"], ["lat", "num"], ["lng", "num"],
|
||||||
|
["holder", "text"], ["ok", "int"], ["fetched_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "flow_asn_meta", columns: [
|
||||||
|
["asn", "int"], ["holder", "text"], ["fetched_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_probes", columns: [
|
||||||
|
["id", "text"], ["src_server_id", "int"], ["src_interface", "text"], ["name", "text"],
|
||||||
|
["target", "text"], ["probe_filter", "text"], ["enabled", "bool"], ["interval_sec", "int"],
|
||||||
|
["show_on_dashboard", "bool"], ["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_probe_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||||
|
["probe_id", "text"], ["sampled_at", "ts"], ["rtt_ms", "int"], ["loss_pct", "int"], ["status", "text"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_resource_samples", timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||||
|
["server_id", "int"], ["sampled_at", "ts"], ["status", "text"], ["cpu_load", "int"],
|
||||||
|
["free_memory", "int"], ["total_memory", "int"], ["free_hdd_space", "int"], ["total_hdd_space", "int"],
|
||||||
|
["uptime_seconds", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_speed_probes", columns: [
|
||||||
|
["id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"], ["src_interface", "text"],
|
||||||
|
["dst_interface", "text"], ["protocol", "text"], ["direction", "text"], ["duration_sec", "int"],
|
||||||
|
["enabled", "bool"], ["last_run_at", "ts"], ["last_tx_avg_mbps", "num"], ["last_rx_avg_mbps", "num"],
|
||||||
|
["last_status", "text"], ["last_error", "text"], ["last_ping_rtt_ms", "int"],
|
||||||
|
["last_ping_loss_pct", "int"], ["last_ping_at", "ts"], ["last_ping_error", "text"],
|
||||||
|
["sort_order", "int"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "uptime_speed_test_runs", columns: [
|
||||||
|
["id", "text"], ["probe_id", "text"], ["src_server_id", "int"], ["dst_server_id", "int"],
|
||||||
|
["src_interface", "text"], ["dst_interface", "text"], ["src_address", "text"], ["dst_address", "text"],
|
||||||
|
["src_interface_address", "text"], ["dst_interface_address", "text"], ["protocol", "text"],
|
||||||
|
["direction", "text"], ["duration_sec", "int"], ["tx_avg_mbps", "num"], ["rx_avg_mbps", "num"],
|
||||||
|
["ping_rtt_ms", "int"], ["ping_loss_pct", "int"], ["ping_error", "text"], ["status", "text"],
|
||||||
|
["error", "text"], ["created_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "app_users", columns: [
|
||||||
|
["id", "text"], ["name", "text"], ["login", "text"], ["email", "text"], ["role", "text"],
|
||||||
|
["active", "bool"], ["avatar", "text"], ["last_seen", "ts"], ["sections_json", "json"],
|
||||||
|
["servers_json", "json"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "user_interface_bindings", columns: [
|
||||||
|
["id", "text"], ["user_id", "text"], ["server_id", "int"], ["interface_name", "text"],
|
||||||
|
["interface_type", "text"], ["peer_public_key", "text"], ["peer_name", "text"],
|
||||||
|
["comment", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_groups", columns: [
|
||||||
|
["id", "text"], ["name", "text"], ["combine_mode", "text"], ["enabled", "bool"],
|
||||||
|
["cooldown_override", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_rules", columns: [
|
||||||
|
["id", "text"], ["name", "text"], ["type", "text"], ["target", "text"], ["condition", "text"],
|
||||||
|
["severity", "text"], ["enabled", "bool"], ["cooldown", "text"], ["rule_chat_id", "text"],
|
||||||
|
["confirm_stability_sec", "int"], ["recovery_mode", "text"], ["recovery_stability_sec", "int"],
|
||||||
|
["group_id", "text"], ["created_at", "ts"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_rule_targets", columns: [
|
||||||
|
["id", "text"], ["rule_id", "text"], ["target", "text"], ["sort_index", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_rule_conditions", columns: [
|
||||||
|
["id", "text"], ["rule_id", "text"], ["condition_line", "text"], ["sort_index", "int"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_engine_state", columns: [
|
||||||
|
["scope_key", "text"], ["last_fired_at", "text"], ["last_payload_hash", "text"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_engine_prev_live", columns: [
|
||||||
|
["kind", "text"], ["payload_json", "json"], ["updated_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_engine_confirm_pending", columns: [
|
||||||
|
["rule_id", "text"], ["payload_hash", "text"], ["since_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_history", columns: [
|
||||||
|
["id", "text"], ["rule_id", "text"], ["group_id", "text"], ["rule_name", "text"],
|
||||||
|
["severity", "text"], ["message", "text"], ["sent_ok", "bool"], ["fired_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "alert_outbox", columns: [
|
||||||
|
["id", "text"], ["dedupe_key", "text"], ["channel", "text"], ["status", "text"],
|
||||||
|
["retry_count", "int"], ["max_retries", "int"], ["next_attempt_at", "ts"],
|
||||||
|
["payload_json", "json"], ["created_at", "ts"], ["sent_at", "ts"], ["last_error", "text"],
|
||||||
|
]},
|
||||||
|
{ table: "certificate_issue_jobs", columns: [
|
||||||
|
["id", "text"], ["status", "text"], ["step", "text"], ["source", "text"],
|
||||||
|
["server_id", "bigint-id"], ["cert_name", "text"], ["domain_names", "json"],
|
||||||
|
["key_type", "text"], ["trust_store", "text"], ["requested_at", "ts"],
|
||||||
|
["started_at", "ts"], ["finished_at", "ts"], ["error", "text"],
|
||||||
|
]},
|
||||||
|
{ table: "backup_entries", columns: [
|
||||||
|
["id", "text"], ["server_id", "bigint-id"], ["server_name", "text"], ["filename", "text"],
|
||||||
|
["size_bytes", "int"], ["kind", "text"], ["notes", "text"], ["created_at", "ts"],
|
||||||
|
]},
|
||||||
|
{ table: "scheduler_runs", timeCol: "started_at", retentionDays: 30, columns: [
|
||||||
|
["id", "text"], ["job_key", "text"], ["started_at", "ts"], ["finished_at", "ts"],
|
||||||
|
["status", "text"], ["error", "text"], ["duration_ms", "int"], ["result_json", "json-null"],
|
||||||
|
]},
|
||||||
|
{ table: "events", timeCol: "created_at", retentionDays: 30, columns: [
|
||||||
|
["id", "text"], ["created_at", "ts"], ["level", "text"], ["event_type", "text"],
|
||||||
|
["source_module", "text"], ["title", "text"], ["message", "text"],
|
||||||
|
["entity_type", "text"], ["entity_id", "text"], ["payload_json", "json-null"],
|
||||||
|
]},
|
||||||
|
{ table: "internet_path_snapshots", identity: true, timeCol: "sampled_at", retentionDays: 14, columns: [
|
||||||
|
["id", "int"], ["sampled_at", "ts"], ["payload_json", "json"],
|
||||||
|
]},
|
||||||
|
]
|
||||||
|
|
||||||
|
function parseTs(value: unknown, strict: boolean, rejects: string[], ctx: string): string | null {
|
||||||
|
if (value == null || value === "") return null
|
||||||
|
const s = String(value)
|
||||||
|
try {
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(s)) return new Date(s).toISOString()
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) {
|
||||||
|
return new Date(`${s.replace(" ", "T")}Z`).toISOString()
|
||||||
|
}
|
||||||
|
const d = new Date(s)
|
||||||
|
if (!Number.isNaN(d.getTime())) return d.toISOString()
|
||||||
|
} catch {
|
||||||
|
/* fallthrough */
|
||||||
|
}
|
||||||
|
const msg = `${ctx}: неразобранный timestamp ${s}`
|
||||||
|
if (strict) throw new Error(msg)
|
||||||
|
rejects.push(msg)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value: unknown): string | null {
|
||||||
|
if (value == null || value === "") return null
|
||||||
|
return String(value).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(value: unknown): boolean {
|
||||||
|
return value === true || value === 1 || value === "1" || value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value: unknown, fallback: unknown): unknown {
|
||||||
|
if (value == null || value === "") return fallback
|
||||||
|
if (typeof value !== "string") return value
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInet(value: unknown): string | null {
|
||||||
|
const s = String(value ?? "").trim()
|
||||||
|
if (!s) return null
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerce(
|
||||||
|
kind: ColKind,
|
||||||
|
value: unknown,
|
||||||
|
strict: boolean,
|
||||||
|
rejects: string[],
|
||||||
|
ctx: string,
|
||||||
|
row?: Record<string, unknown>,
|
||||||
|
): unknown {
|
||||||
|
switch (kind) {
|
||||||
|
case "ts":
|
||||||
|
return parseTs(value, strict, rejects, ctx)
|
||||||
|
case "date":
|
||||||
|
return parseDate(value)
|
||||||
|
case "bool":
|
||||||
|
return parseBool(value)
|
||||||
|
case "json":
|
||||||
|
// Always JSON text. JS arrays must not go to node-pg as values — it encodes
|
||||||
|
// them as PG arrays (`{...}`), which jsonb rejects (22P02).
|
||||||
|
return JSON.stringify(parseJson(value, []))
|
||||||
|
case "json-null":
|
||||||
|
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
|
||||||
|
case "bigint-id": {
|
||||||
|
const t = String(value ?? "").trim()
|
||||||
|
if (!t) return null
|
||||||
|
const n = Number(t)
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
const msg = `${ctx}: server_id ${t}`
|
||||||
|
if (strict) throw new Error(msg)
|
||||||
|
rejects.push(msg)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
case "int":
|
||||||
|
if (value == null || value === "") return null
|
||||||
|
return Number(value)
|
||||||
|
case "num":
|
||||||
|
if (value == null || value === "") return null
|
||||||
|
return Number(value)
|
||||||
|
case "text":
|
||||||
|
return value == null ? "" : String(value)
|
||||||
|
case "flags":
|
||||||
|
return encodeTrafficFlags(parseBool(row?.running), parseBool(row?.disabled))
|
||||||
|
case "inet":
|
||||||
|
return parseInet(value)
|
||||||
|
default:
|
||||||
|
return value == null ? null : String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqliteTableExists(sqlite: Database.Database, name: string): boolean {
|
||||||
|
const row = sqlite.prepare(
|
||||||
|
`SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||||
|
).get(name) as { ok?: number } | undefined
|
||||||
|
return Boolean(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setval(pool: Pool, table: string): Promise<void> {
|
||||||
|
await pool.query(
|
||||||
|
`SELECT setval(
|
||||||
|
pg_get_serial_sequence($1, 'id'),
|
||||||
|
GREATEST(COALESCE((SELECT MAX(id) FROM ${table}), 1), 1),
|
||||||
|
true
|
||||||
|
)`,
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholderFor(kind: ColKind, index: number): string {
|
||||||
|
if (kind === "json" || kind === "json-null") return `$${index}::jsonb`
|
||||||
|
if (kind === "inet") return `$${index}::inet`
|
||||||
|
return `$${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function precreatePartitions(
|
||||||
|
sqlite: Database.Database,
|
||||||
|
pool: Pool,
|
||||||
|
spec: TableCopy,
|
||||||
|
where: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const part = specForParent(spec.table)
|
||||||
|
if (!part || !spec.timeCol) return
|
||||||
|
const bounds = sqlite.prepare(
|
||||||
|
`SELECT MIN(${spec.timeCol}) AS a, MAX(${spec.timeCol}) AS b FROM ${spec.table}${where}`,
|
||||||
|
).get() as { a?: unknown; b?: unknown }
|
||||||
|
if (bounds?.a == null || bounds?.b == null) return
|
||||||
|
const isDate = spec.columns.find((c) => c[0] === spec.timeCol)?.[1] === "date"
|
||||||
|
const fromIso = isDate
|
||||||
|
? `${String(bounds.a).slice(0, 10)}T00:00:00Z`
|
||||||
|
: parseTs(bounds.a, false, [], spec.table)
|
||||||
|
const toIso = isDate
|
||||||
|
? `${String(bounds.b).slice(0, 10)}T00:00:00Z`
|
||||||
|
: parseTs(bounds.b, false, [], spec.table)
|
||||||
|
if (!fromIso || !toIso) return
|
||||||
|
await ensurePartitionsBetween(pool, spec.table, part.kind, new Date(fromIso), new Date(toIso))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTable(
|
||||||
|
sqlite: Database.Database,
|
||||||
|
pool: Pool,
|
||||||
|
spec: TableCopy,
|
||||||
|
opts: { strict: boolean; fullHistory: boolean; rejects: string[] },
|
||||||
|
): Promise<{ sqlite: number; copied: number; skipped: number }> {
|
||||||
|
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||||
|
return { sqlite: 0, copied: 0, skipped: 0 }
|
||||||
|
}
|
||||||
|
let where = ""
|
||||||
|
if (spec.timeCol && spec.retentionDays && !opts.fullHistory) {
|
||||||
|
const cutoff = new Date(Date.now() - spec.retentionDays * 86400_000).toISOString()
|
||||||
|
where = ` WHERE ${spec.timeCol} >= '${cutoff.replace("T", " ").slice(0, 19)}' OR ${spec.timeCol} >= '${cutoff}'`
|
||||||
|
}
|
||||||
|
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
||||||
|
await precreatePartitions(sqlite, pool, spec, where)
|
||||||
|
const cols = spec.columns.map(([c]) => c)
|
||||||
|
const conflictSql = spec.upsert
|
||||||
|
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
||||||
|
: `ON CONFLICT DO NOTHING`
|
||||||
|
let copied = 0
|
||||||
|
let skipped = 0
|
||||||
|
const stmt = sqlite.prepare(`SELECT * FROM ${spec.table}${where}`)
|
||||||
|
const batch: unknown[][] = []
|
||||||
|
const flush = async () => {
|
||||||
|
if (batch.length === 0) return
|
||||||
|
const n = spec.columns.length
|
||||||
|
const valuesSql = batch.map((_, i) =>
|
||||||
|
`(${spec.columns.map(([, kind], j) => placeholderFor(kind, i * n + j + 1)).join(", ")})`,
|
||||||
|
).join(", ")
|
||||||
|
const insertSql = `INSERT INTO ${spec.table} (${cols.join(", ")}) VALUES ${valuesSql} ${conflictSql}`
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN")
|
||||||
|
await client.query(insertSql, batch.flat())
|
||||||
|
copied += batch.length
|
||||||
|
await client.query("COMMIT")
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK")
|
||||||
|
const detail = err instanceof Error ? err.message : String(err)
|
||||||
|
throw new Error(`${spec.table}: ${detail}`)
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
batch.length = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const row of stmt.iterate() as Iterable<Record<string, unknown>>) {
|
||||||
|
try {
|
||||||
|
const values = spec.columns.map(([col, kind]) =>
|
||||||
|
coerce(kind, row[col], opts.strict, opts.rejects, `${spec.table}.${col}`, row),
|
||||||
|
)
|
||||||
|
if (spec.table === "certificate_issue_jobs" && values[4] == null) {
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch.push(values)
|
||||||
|
if (batch.length >= INSERT_CHUNK) await flush()
|
||||||
|
} catch (err) {
|
||||||
|
skipped += 1
|
||||||
|
const msg = `${spec.table}: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
opts.rejects.push(msg)
|
||||||
|
if (opts.strict) throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await flush()
|
||||||
|
if (spec.identity) {
|
||||||
|
try {
|
||||||
|
await setval(pool, spec.table)
|
||||||
|
} catch {
|
||||||
|
/* partitioned identity sequence name may differ */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sqlite: total, copied, skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sqliteFileLooksPresent(path: string): boolean {
|
||||||
|
if (!existsSync(path) || path === ":memory:") return false
|
||||||
|
try {
|
||||||
|
const buf = readFileSync(path)
|
||||||
|
return buf.subarray(0, 16).toString("utf8").startsWith("SQLite format 3")
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promise<boolean> {
|
||||||
|
if (!sqliteFileLooksPresent(sqlitePath)) return false
|
||||||
|
const marker = await pool.query<{ sqlite_imported_at: string | null }>(
|
||||||
|
`SELECT sqlite_imported_at FROM data_migration WHERE id = 1`,
|
||||||
|
)
|
||||||
|
if (marker.rows[0]?.sqlite_imported_at) return false
|
||||||
|
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
||||||
|
if (Number(servers.rows[0]?.c ?? 0) > 0) {
|
||||||
|
console.warn(
|
||||||
|
"SQLite → PostgreSQL: повтор недописанного импорта (маркера нет, servers уже не пустые)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSqliteToPostgres(
|
||||||
|
pool: Pool,
|
||||||
|
sqlitePath: string,
|
||||||
|
opts?: { dryRun?: boolean; strict?: boolean; fullHistory?: boolean },
|
||||||
|
): Promise<ImportReport> {
|
||||||
|
const started = Date.now()
|
||||||
|
const rejects: string[] = []
|
||||||
|
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true })
|
||||||
|
try {
|
||||||
|
sqlite.pragma("journal_mode = WAL")
|
||||||
|
} catch {
|
||||||
|
/* readonly */
|
||||||
|
}
|
||||||
|
const sha = createHash("sha256").update(readFileSync(sqlitePath)).digest("hex")
|
||||||
|
const report: ImportReport = {
|
||||||
|
sqlitePath,
|
||||||
|
sqliteSha256: sha,
|
||||||
|
tables: {},
|
||||||
|
rejects,
|
||||||
|
durationMs: 0,
|
||||||
|
}
|
||||||
|
const fullHistory = opts?.fullHistory ?? env.sqliteImportFullHistory
|
||||||
|
const strict = opts?.strict ?? true
|
||||||
|
if (opts?.dryRun) {
|
||||||
|
for (const spec of TABLES) {
|
||||||
|
if (!sqliteTableExists(sqlite, spec.table)) {
|
||||||
|
report.tables[spec.table] = { sqlite: 0, copied: 0, skipped: 0 }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const c = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}`).get() as { c: number }).c
|
||||||
|
report.tables[spec.table] = { sqlite: c, copied: 0, skipped: 0 }
|
||||||
|
}
|
||||||
|
sqlite.close()
|
||||||
|
report.durationMs = Date.now() - started
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
for (const spec of TABLES) {
|
||||||
|
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
|
||||||
|
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
||||||
|
}
|
||||||
|
sqlite.close()
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE data_migration
|
||||||
|
SET sqlite_imported_at = now(), sqlite_path = $1, sqlite_sha256 = $2, report_json = $3::jsonb
|
||||||
|
WHERE id = 1`,
|
||||||
|
[sqlitePath, sha, JSON.stringify(report.tables)],
|
||||||
|
)
|
||||||
|
report.durationMs = Date.now() - started
|
||||||
|
if (strict && rejects.length > 0) {
|
||||||
|
throw new Error(`SQLite import strict: ${rejects.length} rejects\n${rejects.slice(0, 20).join("\n")}`)
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
/** node-pg encodes a JS array as a PostgreSQL array (`{...}`), not JSON (`[...]`). */
|
||||||
|
{
|
||||||
|
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232" }]
|
||||||
|
const asJson = JSON.stringify(peers)
|
||||||
|
assert.equal(asJson.startsWith("["), true)
|
||||||
|
assert.equal(asJson.includes("msk-gw02.rtnt.top:13232"), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("sqlite-json.test.ts: ok")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
decodeTrafficSampleFlags,
|
||||||
|
encodeTrafficFlags,
|
||||||
|
trafficFlagDisabled,
|
||||||
|
trafficFlagRunning,
|
||||||
|
} from "./traffic-flags.js"
|
||||||
|
|
||||||
|
assert.equal(encodeTrafficFlags(true, false), 1)
|
||||||
|
assert.equal(encodeTrafficFlags(false, true), 2)
|
||||||
|
assert.equal(encodeTrafficFlags(true, true), 3)
|
||||||
|
assert.equal(encodeTrafficFlags(false, false), 0)
|
||||||
|
assert.equal(trafficFlagRunning(1), true)
|
||||||
|
assert.equal(trafficFlagDisabled(1), false)
|
||||||
|
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false })
|
||||||
|
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false })
|
||||||
|
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false })
|
||||||
|
|
||||||
|
console.log("traffic-flags.test.ts: ok")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const TRAFFIC_FLAG_RUNNING = 1
|
||||||
|
export const TRAFFIC_FLAG_DISABLED = 2
|
||||||
|
|
||||||
|
export function encodeTrafficFlags(running: boolean, disabled: boolean): number {
|
||||||
|
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trafficFlagRunning(flags: number | null | undefined): boolean {
|
||||||
|
return ((Number(flags) || 0) & TRAFFIC_FLAG_RUNNING) !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trafficFlagDisabled(flags: number | null | undefined): boolean {
|
||||||
|
return ((Number(flags) || 0) & TRAFFIC_FLAG_DISABLED) !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeTrafficSampleFlags(flags: number | null | undefined): {
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
running: trafficFlagRunning(flags),
|
||||||
|
disabled: trafficFlagDisabled(flags),
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
-62
@@ -1,14 +1,20 @@
|
|||||||
import Fastify from "fastify"
|
import Fastify, { type FastifyError, type FastifyInstance } from "fastify"
|
||||||
import cors from "@fastify/cors"
|
import cors from "@fastify/cors"
|
||||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||||
|
import { monitorEventLoopDelay } from "node:perf_hooks"
|
||||||
import { env } from "./config.js"
|
import { env } from "./config.js"
|
||||||
|
import { initDatabase } from "./db/bootstrap.js"
|
||||||
|
import { closePool } from "./db/index.js"
|
||||||
|
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||||
import serversRoutes from "./routes/servers.js"
|
import serversRoutes from "./routes/servers.js"
|
||||||
import bgpRoutes from "./routes/bgp.js"
|
import bgpRoutes from "./routes/bgp.js"
|
||||||
import ospfRoutes from "./routes/ospf.js"
|
import ospfRoutes from "./routes/ospf.js"
|
||||||
import execRoutes from "./routes/exec.js"
|
import execRoutes from "./routes/exec.js"
|
||||||
import filtersRoutes from "./routes/filters.js"
|
import filtersRoutes from "./routes/filters.js"
|
||||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||||
import trafficRoutes from "./routes/traffic.js"
|
import trafficRoutes from "./routes/traffic.js"
|
||||||
|
import trafficFlowRoutes from "./routes/traffic-flow.js"
|
||||||
|
import geoipRoutes from "./routes/geoip.js"
|
||||||
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
import serversApiPingRoutes from "./routes/servers-api-ping.js"
|
||||||
import uptimeRoutes from "./routes/uptime.js"
|
import uptimeRoutes from "./routes/uptime.js"
|
||||||
import networkRoutes from "./routes/network.js"
|
import networkRoutes from "./routes/network.js"
|
||||||
@@ -22,73 +28,176 @@ import backupsRoutes from "./routes/backups.js"
|
|||||||
import certificatesRoutes from "./routes/certificates.js"
|
import certificatesRoutes from "./routes/certificates.js"
|
||||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||||
import eventsRoutes from "./routes/events.js"
|
import eventsRoutes from "./routes/events.js"
|
||||||
|
import wireguardRoutes from "./routes/wireguard.js"
|
||||||
|
import ipsecRoutes from "./routes/ipsec.js"
|
||||||
|
import vxlanRoutes from "./routes/vxlan.js"
|
||||||
|
import containersRoutes from "./routes/containers.js"
|
||||||
|
import firewallRoutes from "./routes/firewall.js"
|
||||||
|
import greRoutes from "./routes/gre.js"
|
||||||
|
import usersRoutes from "./routes/users.js"
|
||||||
|
import statisticsRoutes from "./routes/statistics.js"
|
||||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||||
|
import { getFlowWorkerHealth, startTrafficFlowListener, stopTrafficFlowListener } from "./services/traffic-flow-ingest.js"
|
||||||
|
import { initGeoip } from "./services/traffic-flow-geoip.js"
|
||||||
|
|
||||||
// ── app factory ────────────────────────────────────────────────────────────────
|
const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 })
|
||||||
|
eventLoopDelay.enable()
|
||||||
|
|
||||||
const app = Fastify({
|
export async function buildApp(opts?: {
|
||||||
bodyLimit: 512 * 1024 * 1024,
|
logger?: boolean
|
||||||
requestTimeout: 10 * 60 * 1000,
|
startScheduler?: boolean
|
||||||
logger: {
|
}): Promise<FastifyInstance> {
|
||||||
transport: {
|
const usePrettyLogger =
|
||||||
target: "pino-pretty",
|
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
const app = Fastify({
|
||||||
},
|
bodyLimit: 2 * 1024 * 1024,
|
||||||
},
|
requestTimeout: 10 * 60 * 1000,
|
||||||
})
|
logger:
|
||||||
|
opts?.logger === false
|
||||||
|
? false
|
||||||
|
: usePrettyLogger
|
||||||
|
? {
|
||||||
|
transport: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: "HH:MM:ss",
|
||||||
|
ignore: "pid,hostname",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: true,
|
||||||
|
})
|
||||||
|
|
||||||
// Use Zod for request validation and response serialization
|
app.setValidatorCompiler(validatorCompiler)
|
||||||
app.setValidatorCompiler(validatorCompiler)
|
app.setSerializerCompiler(serializerCompiler)
|
||||||
app.setSerializerCompiler(serializerCompiler)
|
|
||||||
|
|
||||||
// CORS — allow Next.js frontend
|
app.setErrorHandler((error: FastifyError, request, reply) => {
|
||||||
await app.register(cors, {
|
const status = typeof error.statusCode === "number" && error.statusCode >= 400
|
||||||
origin: env.CORS_ORIGIN,
|
? error.statusCode
|
||||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
: 500
|
||||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
if (status >= 500) {
|
||||||
})
|
request.log.error(error)
|
||||||
|
return reply.status(status).send({ error: "Внутренняя ошибка сервера" })
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : "Ошибка запроса"
|
||||||
|
return reply.status(status).send({ error: message })
|
||||||
|
})
|
||||||
|
|
||||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
await app.register(cors, {
|
||||||
|
origin: env.CORS_ORIGIN,
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
})
|
||||||
|
|
||||||
app.get("/health", async () => ({
|
await app.register(authPlugin)
|
||||||
status: "ok",
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
version: process.env.APP_VERSION ?? "dev",
|
|
||||||
}))
|
|
||||||
|
|
||||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
app.get("/health", async () => ({
|
||||||
await app.register(bgpRoutes, { prefix: "/api" })
|
status: "ok",
|
||||||
await app.register(ospfRoutes, { prefix: "/api" })
|
timestamp: new Date().toISOString(),
|
||||||
await app.register(execRoutes, { prefix: "/api" })
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
await app.register(filtersRoutes, { prefix: "/api" })
|
eventLoopDelayMs: Math.round(eventLoopDelay.mean / 1e6),
|
||||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
flowWorker: getFlowWorkerHealth(),
|
||||||
await app.register(trafficRoutes, { prefix: "/api" })
|
}))
|
||||||
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
|
||||||
await app.register(uptimeRoutes, { prefix: "/api" })
|
|
||||||
await app.register(networkRoutes, { prefix: "/api" })
|
|
||||||
await app.register(internetPathRoutes, { prefix: "/api" })
|
|
||||||
await app.register(evobgpRoutes, { prefix: "/api" })
|
|
||||||
await app.register(probesRoutes, { prefix: "/api" })
|
|
||||||
await app.register(schedulerRoutes, { prefix: "/api" })
|
|
||||||
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(alertsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(backupsRoutes, { prefix: "/api" })
|
|
||||||
await app.register(certificatesRoutes, { prefix: "/api" })
|
|
||||||
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
|
||||||
await app.register(eventsRoutes, { prefix: "/api" })
|
|
||||||
|
|
||||||
refreshScheduler()
|
app.get("/api/auth/config", async () => ({
|
||||||
app.addHook("onClose", async () => {
|
required: env.authRequired,
|
||||||
stopScheduler()
|
portal_url: env.authPortalUrl,
|
||||||
})
|
issuer: env.authIssuer,
|
||||||
|
}))
|
||||||
|
|
||||||
// ── start ──────────────────────────────────────────────────────────────────────
|
if (env.authRequired) {
|
||||||
|
app.addHook("preHandler", async (request, reply) => {
|
||||||
|
const pathname = request.url.split("?")[0] ?? request.url
|
||||||
|
if (!pathname.startsWith("/api/")) return
|
||||||
|
if (pathname === "/api/auth/config") return
|
||||||
|
await requireAuth(request, reply)
|
||||||
|
if (reply.sent) return
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||||
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
await app.register(bgpRoutes, { prefix: "/api" })
|
||||||
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
|
await app.register(ospfRoutes, { prefix: "/api" })
|
||||||
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
await app.register(execRoutes, { prefix: "/api" })
|
||||||
} catch (err) {
|
await app.register(filtersRoutes, { prefix: "/api" })
|
||||||
app.log.error(err)
|
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||||
process.exit(1)
|
await app.register(trafficRoutes, { prefix: "/api" })
|
||||||
|
await app.register(trafficFlowRoutes, { prefix: "/api" })
|
||||||
|
await app.register(geoipRoutes, { prefix: "/api" })
|
||||||
|
await app.register(serversApiPingRoutes, { prefix: "/api" })
|
||||||
|
await app.register(uptimeRoutes, { prefix: "/api" })
|
||||||
|
await app.register(networkRoutes, { prefix: "/api" })
|
||||||
|
await app.register(internetPathRoutes, { prefix: "/api" })
|
||||||
|
await app.register(evobgpRoutes, { prefix: "/api" })
|
||||||
|
await app.register(probesRoutes, { prefix: "/api" })
|
||||||
|
await app.register(schedulerRoutes, { prefix: "/api" })
|
||||||
|
await app.register(sidebarCountsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(alertsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(backupsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(certificatesRoutes, { prefix: "/api" })
|
||||||
|
await app.register(systemDatabaseRoutes, { prefix: "/api" })
|
||||||
|
await app.register(eventsRoutes, { prefix: "/api" })
|
||||||
|
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||||
|
await app.register(ipsecRoutes, { prefix: "/api" })
|
||||||
|
await app.register(vxlanRoutes, { prefix: "/api" })
|
||||||
|
await app.register(containersRoutes, { prefix: "/api" })
|
||||||
|
await app.register(firewallRoutes, { prefix: "/api" })
|
||||||
|
await app.register(greRoutes, { prefix: "/api" })
|
||||||
|
await app.register(usersRoutes, { prefix: "/api" })
|
||||||
|
await app.register(statisticsRoutes, { prefix: "/api" })
|
||||||
|
|
||||||
|
if (opts?.startScheduler !== false) {
|
||||||
|
await refreshScheduler()
|
||||||
|
await initGeoip()
|
||||||
|
await startTrafficFlowListener()
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
stopScheduler()
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMain =
|
||||||
|
process.argv[1] &&
|
||||||
|
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||||
|
|
||||||
|
if (isMain) {
|
||||||
|
try {
|
||||||
|
await initDatabase()
|
||||||
|
const app = await buildApp()
|
||||||
|
let shuttingDown = false
|
||||||
|
const shutdown = async (code: number) => {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
try {
|
||||||
|
stopTrafficFlowListener()
|
||||||
|
await app.close()
|
||||||
|
await closePool()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
process.exit(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.on("SIGTERM", () => { void shutdown(0) })
|
||||||
|
process.on("SIGINT", () => { void shutdown(0) })
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error(err)
|
||||||
|
void shutdown(1)
|
||||||
|
})
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
console.error(reason)
|
||||||
|
void shutdown(1)
|
||||||
|
})
|
||||||
|
await app.listen({ port: env.PORT, host: "0.0.0.0" })
|
||||||
|
console.log(
|
||||||
|
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
|
||||||
|
)
|
||||||
|
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { hasPermission, permissionForRequest } from "./permissions.js"
|
||||||
|
|
||||||
|
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
|
||||||
|
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
|
||||||
|
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/servers"),
|
||||||
|
"mm:servers:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/servers"),
|
||||||
|
"mm:servers:write",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/system/database/backup"),
|
||||||
|
"mm:settings:admin",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/statistics"),
|
||||||
|
"mm:traffic:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/traffic/flow/purge"),
|
||||||
|
"mm:traffic:write",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/unknown-thing"),
|
||||||
|
"mm:dashboard:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/wireguard"),
|
||||||
|
"mm:network:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/wireguard/interfaces"),
|
||||||
|
"mm:network:write",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/firewall/all"),
|
||||||
|
"mm:network:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/gre/tunnels"),
|
||||||
|
"mm:network:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/gre/tunnels"),
|
||||||
|
"mm:network:write",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("GET", "/api/users"),
|
||||||
|
"mm:users:read",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
permissionForRequest("POST", "/api/users"),
|
||||||
|
"mm:users:write",
|
||||||
|
)
|
||||||
|
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
|
||||||
|
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
|
||||||
|
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
|
||||||
|
|
||||||
|
console.log("permissions.test.ts: ok")
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||||
|
* Format: mm:<section>:<read|write|admin>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
apps: string[]
|
||||||
|
permissions: string[]
|
||||||
|
isAdmin?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true
|
||||||
|
if (required.startsWith("mm:users:") && granted.includes("mm:settings:admin")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const parts = required.split(":")
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
const [app, section, action] = parts
|
||||||
|
if (action === "read") {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === "write") {
|
||||||
|
return granted.includes(`${app}:${section}:admin`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
methods: string[]
|
||||||
|
match: (path: string) => boolean
|
||||||
|
permission: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/system") ||
|
||||||
|
p.startsWith("/api/scheduler") ||
|
||||||
|
p.startsWith("/api/evobgp"),
|
||||||
|
permission: "mm:settings:admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/users"),
|
||||||
|
permission: "mm:users:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/users"),
|
||||||
|
permission: "mm:users:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
|
||||||
|
permission: "mm:dashboard:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/servers"),
|
||||||
|
permission: "mm:servers:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/servers"),
|
||||||
|
permission: "mm:servers:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/filters"),
|
||||||
|
permission: "mm:filters:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/filters"),
|
||||||
|
permission: "mm:filters:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/bgp"),
|
||||||
|
permission: "mm:bgp:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/bgp"),
|
||||||
|
permission: "mm:bgp:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/uptime"),
|
||||||
|
permission: "mm:uptime:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/uptime"),
|
||||||
|
permission: "mm:uptime:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/traffic") || p.startsWith("/api/statistics"),
|
||||||
|
permission: "mm:traffic:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/traffic"),
|
||||||
|
permission: "mm:traffic:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/alerts"),
|
||||||
|
permission: "mm:alerts:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/alerts"),
|
||||||
|
permission: "mm:alerts:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/backups"),
|
||||||
|
permission: "mm:backups:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/backups"),
|
||||||
|
permission: "mm:backups:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/certificates"),
|
||||||
|
permission: "mm:certificates:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/certificates"),
|
||||||
|
permission: "mm:certificates:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/network") ||
|
||||||
|
p.startsWith("/api/ospf") ||
|
||||||
|
p.startsWith("/api/recursive") ||
|
||||||
|
p.startsWith("/api/probes") ||
|
||||||
|
p.startsWith("/api/internet-path") ||
|
||||||
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard") ||
|
||||||
|
p.startsWith("/api/ipsec") ||
|
||||||
|
p.startsWith("/api/firewall") ||
|
||||||
|
p.startsWith("/api/gre"),
|
||||||
|
permission: "mm:network:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/network") ||
|
||||||
|
p.startsWith("/api/ospf") ||
|
||||||
|
p.startsWith("/api/recursive") ||
|
||||||
|
p.startsWith("/api/probes") ||
|
||||||
|
p.startsWith("/api/internet-path") ||
|
||||||
|
p.startsWith("/api/exec") ||
|
||||||
|
p.startsWith("/api/wireguard") ||
|
||||||
|
p.startsWith("/api/ipsec") ||
|
||||||
|
p.startsWith("/api/firewall") ||
|
||||||
|
p.startsWith("/api/gre"),
|
||||||
|
permission: "mm:network:write",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||||
|
export function permissionForRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
const m = method.toUpperCase()
|
||||||
|
const pathname = path.split("?")[0] ?? path
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (!rule.methods.includes(m)) continue
|
||||||
|
if (rule.match(pathname)) return rule.permission
|
||||||
|
}
|
||||||
|
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -19,5 +19,31 @@ export function managedComment(label: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function managedRecursiveComment(comment?: string | null): string {
|
export function managedRecursiveComment(comment?: string | null): string {
|
||||||
return comment ? `${PRODUCT_NAME}:recursive ${comment}` : `${PRODUCT_NAME}:recursive`
|
const stripped = stripManagedRecursiveComment(comment ?? "")
|
||||||
|
return stripped ? `${PRODUCT_NAME}:recursive ${stripped}` : `${PRODUCT_NAME}:recursive`
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_RECURSIVE_PREFIX = /^recursive:\s*/i
|
||||||
|
|
||||||
|
export function stripManagedRecursiveComment(comment: string): string {
|
||||||
|
const value = comment.trim()
|
||||||
|
if (!value) return ""
|
||||||
|
const managedPrefixes = [
|
||||||
|
`${PRODUCT_NAME}:recursive`,
|
||||||
|
`${LEGACY_PRODUCT_NAME}:recursive`,
|
||||||
|
]
|
||||||
|
for (const prefix of managedPrefixes) {
|
||||||
|
if (value.startsWith(prefix)) return value.slice(prefix.length).trim()
|
||||||
|
}
|
||||||
|
if (LEGACY_RECURSIVE_PREFIX.test(value)) {
|
||||||
|
return value.replace(LEGACY_RECURSIVE_PREFIX, "").trim()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owned recursive route: MM/legacy prefix or old `recursive:` mask. */
|
||||||
|
export function isOwnedRecursiveComment(comment: string | undefined): boolean {
|
||||||
|
if (!comment) return false
|
||||||
|
const value = comment.trim()
|
||||||
|
return hasManagedRecursiveComment(value) || LEGACY_RECURSIVE_PREFIX.test(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,7 @@ export type ListEventsParams = {
|
|||||||
to?: string
|
to?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePayload(payloadJson: string | null): Record<string, unknown> {
|
import { parseJsonObject } from "../../../db/json.js"
|
||||||
if (!payloadJson) return {}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payloadJson) as unknown
|
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
||||||
return parsed as Record<string, unknown>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// keep backwards compatibility with malformed legacy payloads
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
||||||
return {
|
return {
|
||||||
@@ -48,13 +37,13 @@ function mapEventRow(row: typeof events.$inferSelect): EventItem {
|
|||||||
message: row.message,
|
message: row.message,
|
||||||
entityType: row.entityType ?? null,
|
entityType: row.entityType ?? null,
|
||||||
entityId: row.entityId ?? null,
|
entityId: row.entityId ?? null,
|
||||||
payload: parsePayload(row.payloadJson ?? null),
|
payload: parseJsonObject(row.payloadJson),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertEventsBatch(items: EventInsertInput[]) {
|
export async function insertEventsBatch(items: EventInsertInput[]) {
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return
|
||||||
db.insert(events)
|
await db.insert(events)
|
||||||
.values(
|
.values(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -66,25 +55,23 @@ export function insertEventsBatch(items: EventInsertInput[]) {
|
|||||||
message: item.message,
|
message: item.message,
|
||||||
entityType: item.entityType ?? null,
|
entityType: item.entityType ?? null,
|
||||||
entityId: item.entityId ?? null,
|
entityId: item.entityId ?? null,
|
||||||
payloadJson: item.payload ? JSON.stringify(item.payload) : null,
|
payloadJson: item.payload ?? null,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listEvents(params: ListEventsParams): EventItem[] {
|
export async function listEvents(params: ListEventsParams): Promise<EventItem[]> {
|
||||||
const where = and(
|
const where = and(
|
||||||
params.level ? eq(events.level, params.level) : undefined,
|
params.level ? eq(events.level, params.level) : undefined,
|
||||||
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
|
params.sourceModule ? eq(events.sourceModule, params.sourceModule) : undefined,
|
||||||
params.from ? gte(events.createdAt, params.from) : undefined,
|
params.from ? gte(events.createdAt, params.from) : undefined,
|
||||||
params.to ? lte(events.createdAt, params.to) : undefined,
|
params.to ? lte(events.createdAt, params.to) : undefined,
|
||||||
)
|
)
|
||||||
const rows = db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(events.createdAt))
|
.orderBy(desc(events.createdAt))
|
||||||
.limit(params.limit)
|
.limit(params.limit)
|
||||||
.all()
|
|
||||||
return rows.map(mapEventRow)
|
return rows.map(mapEventRow)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ function normalizeEventInput(input: AppendEventBody): EventInsertInput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendEvent(input: AppendEventBody) {
|
export async function appendEvent(input: AppendEventBody) {
|
||||||
const parsed = appendEventSchema.parse(input)
|
const parsed = appendEventSchema.parse(input)
|
||||||
insertEventsBatch([normalizeEventInput(parsed)])
|
await insertEventsBatch([normalizeEventInput(parsed)])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendEvents(inputs: AppendEventBody[]) {
|
export async function appendEvents(inputs: AppendEventBody[]) {
|
||||||
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
|
const rows = inputs.map((entry) => normalizeEventInput(appendEventSchema.parse(entry)))
|
||||||
insertEventsBatch(rows)
|
await insertEventsBatch(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readEvents(query: Partial<ListEventsQuery>): EventItem[] {
|
export async function readEvents(query: Partial<ListEventsQuery>): Promise<EventItem[]> {
|
||||||
const parsed = listEventsQuerySchema.parse(query)
|
const parsed = listEventsQuerySchema.parse(query)
|
||||||
return listEvents(parsed)
|
return await listEvents(parsed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
|
import { parseJsonArray } from "../../../db/json.js"
|
||||||
import type { ServerRead, WanUplinkRead } from "../../../types/server.js"
|
import type { ServerRead, WanUplinkRead } from "../../../types/server.js"
|
||||||
import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js"
|
import type { ServerRow, SnapshotRow } from "../repository/servers-repository.js"
|
||||||
|
|
||||||
function parseWanUplinksJson(raw: string | null | undefined): WanUplinkRead[] {
|
function parseWanUplinksJson(raw: unknown): WanUplinkRead[] {
|
||||||
if (raw == null || raw === "") return []
|
const data = parseJsonArray(raw)
|
||||||
try {
|
const out: WanUplinkRead[] = []
|
||||||
const data = JSON.parse(raw) as unknown
|
for (const row of data) {
|
||||||
if (!Array.isArray(data)) return []
|
if (typeof row !== "object" || row === null) continue
|
||||||
const out: WanUplinkRead[] = []
|
const r = row as Record<string, unknown>
|
||||||
for (const row of data) {
|
out.push({
|
||||||
if (typeof row !== "object" || row === null) continue
|
id: typeof r.id === "string" ? r.id : "",
|
||||||
const r = row as Record<string, unknown>
|
name: typeof r.name === "string" ? r.name : "",
|
||||||
out.push({
|
isp: typeof r.isp === "string" ? r.isp : "",
|
||||||
id: typeof r.id === "string" ? r.id : "",
|
iface: typeof r.iface === "string" ? r.iface : "",
|
||||||
name: typeof r.name === "string" ? r.name : "",
|
ip: typeof r.ip === "string" ? r.ip : "",
|
||||||
isp: typeof r.isp === "string" ? r.isp : "",
|
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
|
||||||
iface: typeof r.iface === "string" ? r.iface : "",
|
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
|
||||||
ip: typeof r.ip === "string" ? r.ip : "",
|
})
|
||||||
maxDl: typeof r.maxDl === "number" && Number.isFinite(r.maxDl) ? r.maxDl : 0,
|
|
||||||
maxUl: typeof r.maxUl === "number" && Number.isFinite(r.maxUl) ? r.maxUl : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
|
export function toServerRead(server: ServerRow, snap: SnapshotRow | undefined): ServerRead {
|
||||||
|
|||||||
@@ -1,53 +1,57 @@
|
|||||||
import { desc, eq } from "drizzle-orm"
|
import { desc, eq } from "drizzle-orm"
|
||||||
import { db } from "../../../db/index.js"
|
import { db } from "../../../db/index.js"
|
||||||
import { serverSnapshots, servers } from "../../../db/schema.js"
|
import { serverSnapshots, servers } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
export type ServerRow = typeof servers.$inferSelect
|
export type ServerRow = typeof servers.$inferSelect
|
||||||
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
export type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||||
|
|
||||||
export function listServerRows(): ServerRow[] {
|
export async function listServerRows(): Promise<ServerRow[]> {
|
||||||
return db.select().from(servers).all()
|
return await db.select().from(servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerRowById(id: number): ServerRow | undefined {
|
export async function getServerRowById(id: number): Promise<ServerRow | undefined> {
|
||||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
|
const rows = await db.select().from(servers).where(eq(servers.id, id)).limit(1)
|
||||||
|
return rows[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerRow(
|
export async function createServerRow(
|
||||||
values: Omit<typeof servers.$inferInsert, "id">,
|
values: Omit<typeof servers.$inferInsert, "id">,
|
||||||
): ServerRow {
|
): Promise<ServerRow> {
|
||||||
const [inserted] = db.insert(servers).values(values).returning().all()
|
const [inserted] = await db.insert(servers).values(values).returning()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return inserted
|
return inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateServerRowById(
|
export async function updateServerRowById(
|
||||||
id: number,
|
id: number,
|
||||||
values: Partial<ServerRow>,
|
values: Partial<ServerRow>,
|
||||||
): ServerRow {
|
): Promise<ServerRow> {
|
||||||
const [updated] = db.update(servers).set(values).where(eq(servers.id, id)).returning().all()
|
const [updated] = await db.update(servers).set(values).where(eq(servers.id, id)).returning()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteServerRowById(id: number): void {
|
export async function deleteServerRowById(id: number): Promise<void> {
|
||||||
db.delete(servers).where(eq(servers.id, id)).run()
|
await db.delete(servers).where(eq(servers.id, id))
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listSnapshotsByServerId(serverId: number, limit: number): SnapshotRow[] {
|
export async function listSnapshotsByServerId(serverId: number, limit: number): Promise<SnapshotRow[]> {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
.from(serverSnapshots)
|
.from(serverSnapshots)
|
||||||
.where(eq(serverSnapshots.serverId, serverId))
|
.where(eq(serverSnapshots.serverId, serverId))
|
||||||
.orderBy(desc(serverSnapshots.polledAt))
|
.orderBy(desc(serverSnapshots.polledAt))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatestSnapshot(serverId: number): SnapshotRow | undefined {
|
export async function getLatestSnapshot(serverId: number): Promise<SnapshotRow | undefined> {
|
||||||
return db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(serverSnapshots)
|
.from(serverSnapshots)
|
||||||
.where(eq(serverSnapshots.serverId, serverId))
|
.where(eq(serverSnapshots.serverId, serverId))
|
||||||
.orderBy(desc(serverSnapshots.polledAt))
|
.orderBy(desc(serverSnapshots.polledAt))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.all()[0]
|
return rows[0]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,45 +11,46 @@ import {
|
|||||||
import { toServerRead } from "../mapper/servers-mapper.js"
|
import { toServerRead } from "../mapper/servers-mapper.js"
|
||||||
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
|
import type { ServerCreate, ServerRead, ServerUpdate, SnapshotRead } from "../../../types/server.js"
|
||||||
|
|
||||||
export function listServersRead(): ServerRead[] {
|
export async function listServersRead(): Promise<ServerRead[]> {
|
||||||
return listServerRows().map((server) => toServerRead(server, getLatestSnapshot(server.id)))
|
const rows = await listServerRows()
|
||||||
|
return Promise.all(rows.map(async (server) => toServerRead(server, await getLatestSnapshot(server.id))))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerReadById(id: number): ServerRead | undefined {
|
export async function getServerReadById(id: number): Promise<ServerRead | undefined> {
|
||||||
const row = getServerRowById(id)
|
const row = await getServerRowById(id)
|
||||||
if (!row) return undefined
|
if (!row) return undefined
|
||||||
return toServerRead(row, getLatestSnapshot(row.id))
|
return toServerRead(row, await getLatestSnapshot(row.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(input: ServerCreate): ServerRead {
|
export async function createServer(input: ServerCreate): Promise<ServerRead> {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const { wanUplinks, ...rest } = input
|
const { wanUplinks, ...rest } = input
|
||||||
const inserted = createServerRow({
|
const inserted = await createServerRow({
|
||||||
...rest,
|
...rest,
|
||||||
wanUplinks: JSON.stringify(wanUplinks ?? []),
|
wanUplinks: wanUplinks ?? [],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
return toServerRead(inserted, undefined)
|
return toServerRead(inserted, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateServer(id: number, input: ServerUpdate): ServerRead {
|
export async function updateServer(id: number, input: ServerUpdate): Promise<ServerRead> {
|
||||||
const { wanUplinks, ...rest } = input
|
const { wanUplinks, ...rest } = input
|
||||||
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
|
const setPayload: Record<string, unknown> = { updatedAt: new Date().toISOString() }
|
||||||
for (const [k, v] of Object.entries(rest)) {
|
for (const [k, v] of Object.entries(rest)) {
|
||||||
if (v !== undefined) setPayload[k] = v
|
if (v !== undefined) setPayload[k] = v
|
||||||
}
|
}
|
||||||
if (wanUplinks !== undefined) {
|
if (wanUplinks !== undefined) {
|
||||||
setPayload.wanUplinks = JSON.stringify(wanUplinks)
|
setPayload.wanUplinks = wanUplinks
|
||||||
}
|
}
|
||||||
const updated = updateServerRowById(id, setPayload as never)
|
const updated = await updateServerRowById(id, setPayload as never)
|
||||||
return toServerRead(updated, getLatestSnapshot(updated.id))
|
return toServerRead(updated, await getLatestSnapshot(updated.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteServer(id: number): void {
|
export async function deleteServer(id: number): Promise<void> {
|
||||||
deleteServerRowById(id)
|
await deleteServerRowById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listServerSnapshots(id: number, limit: number): SnapshotRead[] {
|
export async function listServerSnapshots(id: number, limit: number): Promise<SnapshotRead[]> {
|
||||||
return listSnapshotsByServerId(id, limit).map(toSnapshotRead)
|
return (await listSnapshotsByServerId(id, limit)).map(toSnapshotRead)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { normalizeBindingPeer, PeerBindError } from "./peer-bind.js"
|
||||||
|
import { withPgOrSkip } from "../../test/pg.js"
|
||||||
|
import { dbQuery } from "../../db/index.js"
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeBindingPeer("wg", ""),
|
||||||
|
(err: unknown) => err instanceof PeerBindError && err.status === 400,
|
||||||
|
"WG без ключа — 400",
|
||||||
|
)
|
||||||
|
assert.equal(normalizeBindingPeer("ether", "ignored"), "")
|
||||||
|
assert.equal(normalizeBindingPeer("wg", " abc "), "abc")
|
||||||
|
|
||||||
|
if (!(await withPgOrSkip())) {
|
||||||
|
console.log("users bindings unique+cascade tests skip")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO servers (id, name, host) VALUES (91001, 'jh-bind-test', '10.0.0.1')
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`)
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO app_users (id, name, login)
|
||||||
|
VALUES ('u-bind-1', 'A', 'a.bind.test'), ('u-bind-2', 'B', 'b.bind.test')
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`)
|
||||||
|
await dbQuery(`DELETE FROM user_interface_bindings WHERE server_id = 91001`)
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||||
|
VALUES ('b-bind-1', 'u-bind-1', 91001, 'gre-office', 'gre')
|
||||||
|
`)
|
||||||
|
|
||||||
|
let uniqueIface = false
|
||||||
|
try {
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type)
|
||||||
|
VALUES ('b-bind-2', 'u-bind-2', 91001, 'gre-office', 'gre')
|
||||||
|
`)
|
||||||
|
} catch {
|
||||||
|
uniqueIface = true
|
||||||
|
}
|
||||||
|
assert.equal(uniqueIface, true, "один интерфейс на сервере — один пользователь")
|
||||||
|
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg-bind-1', 'u-bind-1', 91001, 'wg-server', 'wg', 'peer-key-aaa', 'phone')
|
||||||
|
`)
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key, peer_name)
|
||||||
|
VALUES ('wg-bind-2', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-bbb', 'laptop')
|
||||||
|
`)
|
||||||
|
|
||||||
|
let uniquePeer = false
|
||||||
|
try {
|
||||||
|
await dbQuery(`
|
||||||
|
INSERT INTO user_interface_bindings (id, user_id, server_id, interface_name, interface_type, peer_public_key)
|
||||||
|
VALUES ('wg-bind-3', 'u-bind-2', 91001, 'wg-server', 'wg', 'peer-key-aaa')
|
||||||
|
`)
|
||||||
|
} catch {
|
||||||
|
uniquePeer = true
|
||||||
|
}
|
||||||
|
assert.equal(uniquePeer, true, "один пир — один пользователь")
|
||||||
|
|
||||||
|
await dbQuery(`DELETE FROM app_users WHERE id = 'u-bind-1'`)
|
||||||
|
const leftover = await dbQuery<{ n: number }>(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM user_interface_bindings WHERE server_id = 91001`,
|
||||||
|
)
|
||||||
|
assert.equal(leftover.rows[0]?.n, 1, "каскад: привязки u1 удаляются, пир u2 остаётся")
|
||||||
|
|
||||||
|
await dbQuery(`DELETE FROM app_users WHERE id IN ('u-bind-1', 'u-bind-2')`)
|
||||||
|
await dbQuery(`DELETE FROM servers WHERE id = 91001`)
|
||||||
|
|
||||||
|
console.log("users bindings unique+cascade tests ok")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { mapRosInterfaceType, parseRawInterfaces, isUniqueConstraintError } from "./iface-type.js"
|
||||||
|
|
||||||
|
assert.equal(mapRosInterfaceType("ether"), "ether")
|
||||||
|
assert.equal(mapRosInterfaceType("ethernet"), "ether")
|
||||||
|
assert.equal(mapRosInterfaceType("GRE"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("gre6-tunnel"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("wg"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("wireguard"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("vlan"), "other")
|
||||||
|
assert.equal(mapRosInterfaceType(""), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("", "gre-tunnel1"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "MSK-DC"), "other")
|
||||||
|
assert.equal(mapRosInterfaceType("gre-tunnel", "MSK-DC"), "gre")
|
||||||
|
assert.equal(mapRosInterfaceType("", "wg-msk-spb"), "wg")
|
||||||
|
assert.equal(mapRosInterfaceType("", "ether1"), "ether")
|
||||||
|
|
||||||
|
const parsed = parseRawInterfaces(JSON.stringify([
|
||||||
|
{ name: "ether1", type: "ether", running: "true", disabled: "false" },
|
||||||
|
{ name: "gre-office", type: "gre-tunnel", running: "false", disabled: "false" },
|
||||||
|
{ name: "wg-msk", type: "wg", running: true, disabled: false },
|
||||||
|
{ name: "MSK-DC", type: "gre-tunnel", running: true, disabled: false },
|
||||||
|
{ name: "", type: "ether" },
|
||||||
|
]))
|
||||||
|
assert.equal(parsed.length, 4)
|
||||||
|
assert.equal(parsed[0]?.type, "ether")
|
||||||
|
assert.equal(parsed[0]?.running, true)
|
||||||
|
assert.equal(parsed[1]?.type, "gre")
|
||||||
|
assert.equal(parsed[1]?.running, false)
|
||||||
|
assert.equal(parsed[2]?.type, "wg")
|
||||||
|
assert.equal(parsed[3]?.type, "gre")
|
||||||
|
|
||||||
|
assert.equal(parseRawInterfaces("not-json").length, 0)
|
||||||
|
assert.equal(parseRawInterfaces(null).length, 0)
|
||||||
|
|
||||||
|
assert.equal(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE", message: "UNIQUE" }), true)
|
||||||
|
assert.equal(isUniqueConstraintError({ message: "UNIQUE constraint failed: t.c" }), true)
|
||||||
|
assert.equal(isUniqueConstraintError({ message: "other" }), false)
|
||||||
|
|
||||||
|
console.log("users iface-type tests ok")
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type InterfaceType = "ether" | "gre" | "wg" | "ipsec" | "other"
|
||||||
|
|
||||||
|
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
|
||||||
|
const t = String(raw ?? "").trim().toLowerCase()
|
||||||
|
if (t === "ether" || t === "ethernet" || t.startsWith("ether")) return "ether"
|
||||||
|
// RouterOS /interface type for GRE is "gre-tunnel" (also gre, gre6, gre6-tunnel)
|
||||||
|
if (t === "gre" || t.startsWith("gre-") || t.startsWith("gre6")) return "gre"
|
||||||
|
if (t === "wg" || t === "wireguard") return "wg"
|
||||||
|
|
||||||
|
const n = String(name ?? "").trim().toLowerCase()
|
||||||
|
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
|
||||||
|
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
|
||||||
|
if (n.startsWith("ipsec")) return "ipsec"
|
||||||
|
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRosIface {
|
||||||
|
name: string
|
||||||
|
type: InterfaceType
|
||||||
|
running: boolean
|
||||||
|
disabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBool(raw: unknown): boolean {
|
||||||
|
if (typeof raw === "boolean") return raw
|
||||||
|
const s = String(raw ?? "").trim().toLowerCase()
|
||||||
|
return s === "true" || s === "yes" || s === "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRawInterfaces(json: unknown): ParsedRosIface[] {
|
||||||
|
if (json == null || json === "") return []
|
||||||
|
try {
|
||||||
|
const parsed = typeof json === "string" ? JSON.parse(json) as unknown : json
|
||||||
|
const arr = Array.isArray(parsed) ? parsed : []
|
||||||
|
const out: ParsedRosIface[] = []
|
||||||
|
for (const item of arr) {
|
||||||
|
if (!item || typeof item !== "object") continue
|
||||||
|
const rec = item as Record<string, unknown>
|
||||||
|
const name = String(rec.name ?? "").trim()
|
||||||
|
if (!name) continue
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
type: mapRosInterfaceType(String(rec.type ?? ""), name),
|
||||||
|
running: asBool(rec.running),
|
||||||
|
disabled: asBool(rec.disabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUniqueConstraintError(err: unknown): boolean {
|
||||||
|
if (!err || typeof err !== "object") return false
|
||||||
|
const rec = err as { code?: unknown; message?: unknown }
|
||||||
|
const code = String(rec.code ?? "")
|
||||||
|
const msg = String(rec.message ?? "")
|
||||||
|
return code === "23505" || code.includes("SQLITE_CONSTRAINT") || /unique constraint/i.test(msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { InterfaceType } from "./iface-type.js"
|
||||||
|
|
||||||
|
export class PeerBindError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "PeerBindError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncPeerKey(key: string): string {
|
||||||
|
const k = key.trim()
|
||||||
|
if (k.length <= 20) return k
|
||||||
|
return `${k.slice(0, 8)}…${k.slice(-8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peerDisplayName(opts: {
|
||||||
|
publicKey: string
|
||||||
|
name?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}): string {
|
||||||
|
const name = (opts.name ?? "").trim()
|
||||||
|
if (name) return name
|
||||||
|
const comment = (opts.comment ?? "").trim()
|
||||||
|
if (comment) return comment
|
||||||
|
return truncPeerKey(opts.publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ether/GRE — пустой ключ. WG — обязательный public-key. IPsec — CN сертификата клиента. */
|
||||||
|
export function normalizeBindingPeer(
|
||||||
|
type: InterfaceType,
|
||||||
|
peerPublicKey: string | undefined,
|
||||||
|
): string {
|
||||||
|
const key = (peerPublicKey ?? "").trim()
|
||||||
|
if (type === "wg") {
|
||||||
|
if (!key) {
|
||||||
|
throw new PeerBindError("Для WireGuard укажите пир (public-key)", 400)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
if (type === "ipsec") {
|
||||||
|
if (!key) {
|
||||||
|
throw new PeerBindError("Для IPsec укажите клиента (CN сертификата)", 400)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { and, count, eq } from "drizzle-orm"
|
||||||
|
import { db } from "../../../db/index.js"
|
||||||
|
import { appUsers, userInterfaceBindings } from "../../../db/schema.js"
|
||||||
|
import { invalidateFlowCatalogCache } from "../../../services/traffic-flow-topology.js"
|
||||||
|
|
||||||
|
export type AppUserRow = typeof appUsers.$inferSelect
|
||||||
|
export type BindingRow = typeof userInterfaceBindings.$inferSelect
|
||||||
|
|
||||||
|
export async function listUserRows(): Promise<AppUserRow[]> {
|
||||||
|
return await db.select().from(appUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserRowById(id: string): Promise<AppUserRow | undefined> {
|
||||||
|
const rows = await db.select().from(appUsers).where(eq(appUsers.id, id)).limit(1)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserRowByLogin(login: string): Promise<AppUserRow | undefined> {
|
||||||
|
const rows = await db.select().from(appUsers).where(eq(appUsers.login, login)).limit(1)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserRow(values: typeof appUsers.$inferInsert): Promise<AppUserRow> {
|
||||||
|
const [inserted] = await db.insert(appUsers).values(values).returning()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserRowById(
|
||||||
|
id: string,
|
||||||
|
values: Partial<AppUserRow>,
|
||||||
|
): Promise<AppUserRow> {
|
||||||
|
const [updated] = await db.update(appUsers).set(values).where(eq(appUsers.id, id)).returning()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserRowById(id: string): Promise<void> {
|
||||||
|
await db.delete(appUsers).where(eq(appUsers.id, id))
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBindingRows(): Promise<BindingRow[]> {
|
||||||
|
return await db.select().from(userInterfaceBindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBindingRowsByUser(userId: string): Promise<BindingRow[]> {
|
||||||
|
return await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.userId, userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBindingRowById(id: string): Promise<BindingRow | undefined> {
|
||||||
|
const rows = await db.select().from(userInterfaceBindings).where(eq(userInterfaceBindings.id, id)).limit(1)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBindingByServerIfacePeer(
|
||||||
|
serverId: number,
|
||||||
|
interfaceName: string,
|
||||||
|
peerPublicKey = "",
|
||||||
|
): Promise<BindingRow | undefined> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(userInterfaceBindings)
|
||||||
|
.where(and(
|
||||||
|
eq(userInterfaceBindings.serverId, serverId),
|
||||||
|
eq(userInterfaceBindings.interfaceName, interfaceName),
|
||||||
|
eq(userInterfaceBindings.peerPublicKey, peerPublicKey),
|
||||||
|
))
|
||||||
|
.limit(1)
|
||||||
|
return rows[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBindingRow(values: typeof userInterfaceBindings.$inferInsert): Promise<BindingRow> {
|
||||||
|
const [inserted] = await db.insert(userInterfaceBindings).values(values).returning()
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
return inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBindingRowById(id: string): Promise<void> {
|
||||||
|
await db.delete(userInterfaceBindings).where(eq(userInterfaceBindings.id, id))
|
||||||
|
invalidateFlowCatalogCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countUserRows(): Promise<number> {
|
||||||
|
const rows = await db.select({ n: count() }).from(appUsers)
|
||||||
|
return rows[0]?.n ?? 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
import { desc, eq } from "drizzle-orm"
|
||||||
|
import type {
|
||||||
|
AppUserCreate,
|
||||||
|
AppUserRead,
|
||||||
|
AppUserUpdate,
|
||||||
|
CatalogInterface,
|
||||||
|
InterfaceType,
|
||||||
|
SectionPerm,
|
||||||
|
ServerPerm,
|
||||||
|
UserBinding,
|
||||||
|
UserBindingCreate,
|
||||||
|
} from "@mmapp/contracts/users"
|
||||||
|
import { db } from "../../../db/index.js"
|
||||||
|
import { parseJsonArray } from "../../../db/json.js"
|
||||||
|
import { decodeTrafficSampleFlags } from "../../../db/traffic-flags.js"
|
||||||
|
import { servers, trafficSamples } from "../../../db/schema.js"
|
||||||
|
import {
|
||||||
|
createBindingRow,
|
||||||
|
createUserRow,
|
||||||
|
deleteBindingRowById,
|
||||||
|
deleteUserRowById,
|
||||||
|
getBindingByServerIfacePeer,
|
||||||
|
getBindingRowById,
|
||||||
|
getUserRowById,
|
||||||
|
getUserRowByLogin,
|
||||||
|
listBindingRows,
|
||||||
|
listBindingRowsByUser,
|
||||||
|
listUserRows,
|
||||||
|
updateUserRowById,
|
||||||
|
type AppUserRow,
|
||||||
|
type BindingRow,
|
||||||
|
} from "../repository/users-repository.js"
|
||||||
|
import { getLatestSnapshot } from "../../servers/repository/servers-repository.js"
|
||||||
|
import {
|
||||||
|
isUniqueConstraintError,
|
||||||
|
mapRosInterfaceType,
|
||||||
|
parseRawInterfaces,
|
||||||
|
} from "../iface-type.js"
|
||||||
|
import {
|
||||||
|
normalizeBindingPeer,
|
||||||
|
PeerBindError,
|
||||||
|
peerDisplayName,
|
||||||
|
} from "../peer-bind.js"
|
||||||
|
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
|
||||||
|
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
|
||||||
|
|
||||||
|
export class UsersServiceError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "UsersServiceError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPermArray<T>(raw: unknown, fallback: T[]): T[] {
|
||||||
|
const arr = parseJsonArray(raw)
|
||||||
|
return arr.length ? arr as T[] : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||||
|
return parts.map((p) => p[0] ?? "").slice(0, 2).join("").toUpperCase() || "??"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverMeta(serverId: number): Promise<{ name: string; site: string; country: string }> {
|
||||||
|
const rows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||||
|
const row = rows[0]
|
||||||
|
return {
|
||||||
|
name: row?.name || row?.host || String(serverId),
|
||||||
|
site: row?.site || "—",
|
||||||
|
country: row?.country || "UN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toBindingDto(row: BindingRow): Promise<UserBinding> {
|
||||||
|
const meta = await serverMeta(row.serverId)
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
serverId: row.serverId,
|
||||||
|
serverName: meta.name,
|
||||||
|
serverSite: meta.site,
|
||||||
|
serverCountry: meta.country,
|
||||||
|
interfaceName: row.interfaceName,
|
||||||
|
interfaceType: row.interfaceType,
|
||||||
|
peerPublicKey: row.peerPublicKey ?? "",
|
||||||
|
peerName: row.peerName ?? "",
|
||||||
|
comment: row.comment,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toUserDto(row: AppUserRow, bindings: BindingRow[]): Promise<AppUserRead> {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
login: row.login,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
avatar: row.avatar,
|
||||||
|
lastSeen: row.lastSeen ?? null,
|
||||||
|
sections: asPermArray<SectionPerm>(row.sectionsJson, []),
|
||||||
|
servers: asPermArray<ServerPerm>(row.serversJson, []),
|
||||||
|
bindings: await Promise.all(bindings.map((b) => toBindingDto(b))),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<AppUserRead[]> {
|
||||||
|
const users = await listUserRows()
|
||||||
|
const allBindings = await listBindingRows()
|
||||||
|
const byUser = new Map<string, BindingRow[]>()
|
||||||
|
for (const b of allBindings) {
|
||||||
|
const arr = byUser.get(b.userId) ?? []
|
||||||
|
arr.push(b)
|
||||||
|
byUser.set(b.userId, arr)
|
||||||
|
}
|
||||||
|
return Promise.all(users.map((u) => toUserDto(u, byUser.get(u.id) ?? [])))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string): Promise<AppUserRead | undefined> {
|
||||||
|
const row = await getUserRowById(id)
|
||||||
|
if (!row) return undefined
|
||||||
|
return await toUserDto(row, await listBindingRowsByUser(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(input: AppUserCreate): Promise<AppUserRead> {
|
||||||
|
const login = input.login.trim()
|
||||||
|
if (await getUserRowByLogin(login)) {
|
||||||
|
throw new UsersServiceError("Логин уже занят", 409)
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const row = await createUserRow({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: input.name.trim(),
|
||||||
|
login,
|
||||||
|
email: input.email.trim(),
|
||||||
|
role: input.role ?? "viewer",
|
||||||
|
active: input.active ?? true,
|
||||||
|
avatar: (input.avatar ?? "").trim() || initials(input.name),
|
||||||
|
lastSeen: input.lastSeen ?? null,
|
||||||
|
sectionsJson: input.sections ?? [],
|
||||||
|
serversJson: input.servers ?? [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return await toUserDto(row, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(id: string, input: AppUserUpdate): Promise<AppUserRead> {
|
||||||
|
const existing = await getUserRowById(id)
|
||||||
|
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
if (input.login != null) {
|
||||||
|
const other = await getUserRowByLogin(input.login.trim())
|
||||||
|
if (other && other.id !== id) throw new UsersServiceError("Логин уже занят", 409)
|
||||||
|
}
|
||||||
|
const patch: Partial<AppUserRow> = { updatedAt: new Date().toISOString() }
|
||||||
|
if (input.name != null) patch.name = input.name.trim()
|
||||||
|
if (input.login != null) patch.login = input.login.trim()
|
||||||
|
if (input.email != null) patch.email = input.email.trim()
|
||||||
|
if (input.role != null) patch.role = input.role
|
||||||
|
if (input.active != null) patch.active = input.active
|
||||||
|
if (input.avatar != null) patch.avatar = input.avatar.trim() || existing.avatar
|
||||||
|
if (input.lastSeen !== undefined) patch.lastSeen = input.lastSeen
|
||||||
|
if (input.sections != null) patch.sectionsJson = input.sections
|
||||||
|
if (input.servers != null) patch.serversJson = input.servers
|
||||||
|
const updated = await updateUserRowById(id, patch)
|
||||||
|
return await toUserDto(updated, await listBindingRowsByUser(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
|
const existing = await getUserRowById(id)
|
||||||
|
if (!existing) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
await deleteUserRowById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBinding(userId: string, input: UserBindingCreate): Promise<UserBinding> {
|
||||||
|
const user = await getUserRowById(userId)
|
||||||
|
if (!user) throw new UsersServiceError("Пользователь не найден", 404)
|
||||||
|
const serverRows = await db.select().from(servers).where(eq(servers.id, input.serverId)).limit(1)
|
||||||
|
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
const ifaceName = input.interfaceName.trim()
|
||||||
|
if (!ifaceName) throw new UsersServiceError("Имя интерфейса обязательно", 400)
|
||||||
|
const type: InterfaceType = input.interfaceType ?? await inferIfaceType(input.serverId, ifaceName)
|
||||||
|
let peerPublicKey = ""
|
||||||
|
try {
|
||||||
|
peerPublicKey = normalizeBindingPeer(type, input.peerPublicKey)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PeerBindError) throw new UsersServiceError(err.message, err.status)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const peerName = type === "wg"
|
||||||
|
? peerDisplayName({
|
||||||
|
publicKey: peerPublicKey,
|
||||||
|
name: input.peerName,
|
||||||
|
})
|
||||||
|
: ""
|
||||||
|
const taken = await getBindingByServerIfacePeer(input.serverId, ifaceName, peerPublicKey)
|
||||||
|
if (taken) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
try {
|
||||||
|
const row = await createBindingRow({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
serverId: input.serverId,
|
||||||
|
interfaceName: ifaceName,
|
||||||
|
interfaceType: type,
|
||||||
|
peerPublicKey,
|
||||||
|
peerName,
|
||||||
|
comment: (input.comment ?? "").trim(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
return await toBindingDto(row)
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueConstraintError(err)) {
|
||||||
|
throw new UsersServiceError(
|
||||||
|
type === "wg"
|
||||||
|
? "Этот пир уже привязан к другому пользователю"
|
||||||
|
: "Интерфейс уже привязан к другому пользователю",
|
||||||
|
409,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBinding(userId: string, bindingId: string): Promise<void> {
|
||||||
|
const row = await getBindingRowById(bindingId)
|
||||||
|
if (!row || row.userId !== userId) {
|
||||||
|
throw new UsersServiceError("Привязка не найдена", 404)
|
||||||
|
}
|
||||||
|
await deleteBindingRowById(bindingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inferIfaceType(serverId: number, ifaceName: string): Promise<InterfaceType> {
|
||||||
|
const snap = await getLatestSnapshot(serverId)
|
||||||
|
const parsed = parseRawInterfaces(snap?.rawInterfaces)
|
||||||
|
const found = parsed.find((i) => i.name === ifaceName)
|
||||||
|
return found?.type ?? "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInterfaceCatalog(serverId: number): Promise<CatalogInterface[]> {
|
||||||
|
const serverRows = await db.select().from(servers).where(eq(servers.id, serverId)).limit(1)
|
||||||
|
if (!serverRows[0]) throw new UsersServiceError("Сервер не найден", 404)
|
||||||
|
|
||||||
|
const snap = await getLatestSnapshot(serverId)
|
||||||
|
let ifaces = parseRawInterfaces(snap?.rawInterfaces)
|
||||||
|
if (ifaces.length === 0) {
|
||||||
|
const lastRows = await db
|
||||||
|
.select({ sampledAt: trafficSamples.sampledAt })
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(eq(trafficSamples.serverId, serverId))
|
||||||
|
.orderBy(desc(trafficSamples.sampledAt))
|
||||||
|
.limit(1)
|
||||||
|
const last = lastRows[0]
|
||||||
|
if (last) {
|
||||||
|
const rows = (await db
|
||||||
|
.select({
|
||||||
|
interfaceName: trafficSamples.interfaceName,
|
||||||
|
peerPublicKey: trafficSamples.peerPublicKey,
|
||||||
|
flags: trafficSamples.flags,
|
||||||
|
})
|
||||||
|
.from(trafficSamples)
|
||||||
|
.where(eq(trafficSamples.serverId, serverId)))
|
||||||
|
.filter((r) => r.interfaceName && !/^(lo|loopback)/i.test(r.interfaceName) && !(r.peerPublicKey ?? ""))
|
||||||
|
const seen = new Set<string>()
|
||||||
|
ifaces = []
|
||||||
|
for (const r of rows) {
|
||||||
|
if (seen.has(r.interfaceName)) continue
|
||||||
|
seen.add(r.interfaceName)
|
||||||
|
const decoded = decodeTrafficSampleFlags(r.flags)
|
||||||
|
ifaces.push({
|
||||||
|
name: r.interfaceName,
|
||||||
|
type: mapRosInterfaceType("", r.interfaceName),
|
||||||
|
running: decoded.running,
|
||||||
|
disabled: decoded.disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = (await listBindingRows()).filter((b) => b.serverId === serverId)
|
||||||
|
const usersById = new Map((await listUserRows()).map((u) => [u.id, u]))
|
||||||
|
const hasWg = ifaces.some((i) => i.type === "wg")
|
||||||
|
const wgLive = hasWg
|
||||||
|
? await listWireGuardPeersForCatalog(serverId)
|
||||||
|
: { peers: [] as Awaited<ReturnType<typeof listWireGuardPeersForCatalog>>["peers"] }
|
||||||
|
const peersByIface = new Map<string, typeof wgLive.peers>()
|
||||||
|
for (const peer of wgLive.peers) {
|
||||||
|
const list = peersByIface.get(peer.interfaceName) ?? []
|
||||||
|
list.push(peer)
|
||||||
|
peersByIface.set(peer.interfaceName, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата).
|
||||||
|
const ipsecLive = await listIpsecClientsForCatalog(serverId)
|
||||||
|
|
||||||
|
const entries: CatalogInterface[] = ifaces.map((iface) => {
|
||||||
|
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
|
||||||
|
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||||
|
const base: CatalogInterface = {
|
||||||
|
name: iface.name,
|
||||||
|
type: iface.type,
|
||||||
|
running: iface.running,
|
||||||
|
disabled: iface.disabled,
|
||||||
|
boundUserId: ifaceBind?.userId ?? null,
|
||||||
|
boundUserLogin: owner?.login ?? null,
|
||||||
|
}
|
||||||
|
if (iface.type !== "wg") return base
|
||||||
|
const livePeers = peersByIface.get(iface.name) ?? []
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
peersError: wgLive.error,
|
||||||
|
peers: livePeers.map((p) => {
|
||||||
|
const bind = bindings.find((b) => b.interfaceName === iface.name && b.peerPublicKey === p.publicKey)
|
||||||
|
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||||
|
return {
|
||||||
|
publicKey: p.publicKey,
|
||||||
|
name: peerDisplayName({ publicKey: p.publicKey, name: p.name, comment: p.comment }),
|
||||||
|
comment: p.comment,
|
||||||
|
allowedIps: p.allowedIps,
|
||||||
|
latestHandshake: p.latestHandshake,
|
||||||
|
boundUserId: bind?.userId ?? null,
|
||||||
|
boundUserLogin: peerOwner?.login ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ipsecLive.clients.length > 0) {
|
||||||
|
const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? ""))
|
||||||
|
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
|
||||||
|
entries.push({
|
||||||
|
name: "ipsec-vpn",
|
||||||
|
type: "ipsec",
|
||||||
|
running: true,
|
||||||
|
disabled: false,
|
||||||
|
boundUserId: ifaceBind?.userId ?? null,
|
||||||
|
boundUserLogin: owner?.login ?? null,
|
||||||
|
peersError: ipsecLive.error,
|
||||||
|
peers: ipsecLive.clients.map((c) => {
|
||||||
|
const cn = c.commonName ?? c.name
|
||||||
|
const bind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && b.peerPublicKey === cn)
|
||||||
|
const peerOwner = bind ? usersById.get(bind.userId) : undefined
|
||||||
|
return {
|
||||||
|
publicKey: cn,
|
||||||
|
name: c.name,
|
||||||
|
comment: c.comment ?? "",
|
||||||
|
allowedIps: c.staticIp ? [c.staticIp] : [],
|
||||||
|
latestHandshake: c.activeSince,
|
||||||
|
boundUserId: bind?.userId ?? null,
|
||||||
|
boundUserLogin: peerOwner?.login ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export { parseRawInterfaces, mapRosInterfaceType }
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Smoke: AUTH_REQUIRED gate via Fastify inject.
|
||||||
|
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { SignJWT } from "jose"
|
||||||
|
|
||||||
|
process.env.AUTH_REQUIRED = "true"
|
||||||
|
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
|
||||||
|
process.env.AUTH_ISSUER = "https://auth.test.local"
|
||||||
|
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
|
||||||
|
process.env.CORS_ORIGIN = "http://localhost:3000"
|
||||||
|
process.env.NODE_ENV = "test"
|
||||||
|
|
||||||
|
// Dynamic import after env is set
|
||||||
|
const { buildApp } = await import("../index.js")
|
||||||
|
|
||||||
|
const secret = new TextEncoder().encode("test-secret-at-least-8")
|
||||||
|
|
||||||
|
async function mint(payload: Record<string, unknown>): Promise<string> {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuer("https://auth.test.local")
|
||||||
|
.setExpirationTime("1h")
|
||||||
|
.sign(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await buildApp({ logger: false, startScheduler: false })
|
||||||
|
|
||||||
|
const health = await app.inject({ method: "GET", url: "/health" })
|
||||||
|
assert.equal(health.statusCode, 200)
|
||||||
|
|
||||||
|
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
|
||||||
|
assert.equal(cfg.statusCode, 200)
|
||||||
|
assert.equal(cfg.json().required, true)
|
||||||
|
|
||||||
|
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
|
||||||
|
assert.equal(noToken.statusCode, 401)
|
||||||
|
|
||||||
|
const badApp = await mint({
|
||||||
|
sub: "u1",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "A",
|
||||||
|
apps: ["cdn"],
|
||||||
|
permissions: ["cdn:dashboard:read"],
|
||||||
|
})
|
||||||
|
const forbiddenApp = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/sidebar-counts",
|
||||||
|
headers: { authorization: `Bearer ${badApp}` },
|
||||||
|
})
|
||||||
|
assert.equal(forbiddenApp.statusCode, 403)
|
||||||
|
|
||||||
|
const okToken = await mint({
|
||||||
|
sub: "u1",
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "A",
|
||||||
|
apps: ["mm"],
|
||||||
|
permissions: ["mm:dashboard:read"],
|
||||||
|
})
|
||||||
|
const ok = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/sidebar-counts",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
})
|
||||||
|
// May be 200 or 500 if DB missing — must not be 401/403
|
||||||
|
assert.notEqual(ok.statusCode, 401)
|
||||||
|
assert.notEqual(ok.statusCode, 403)
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
console.log("auth.smoke.test.ts: ok")
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
|
||||||
|
import fp from "fastify-plugin"
|
||||||
|
import { env } from "../config.js"
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
permissionForRequest,
|
||||||
|
type AuthUser,
|
||||||
|
} from "../lib/permissions.js"
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
authUser?: AuthUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
apps?: string[]
|
||||||
|
permissions?: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
user: {
|
||||||
|
sub: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
apps?: string[]
|
||||||
|
permissions?: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authPlugin(app: FastifyInstance) {
|
||||||
|
if (env.authRequired && env.jwtSecret.length < 8) {
|
||||||
|
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.register(import("@fastify/jwt"), {
|
||||||
|
secret: env.jwtSecret,
|
||||||
|
...(env.authRequired
|
||||||
|
? {
|
||||||
|
verify: {
|
||||||
|
allowedIss: [env.authIssuer],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (env.authRequired) {
|
||||||
|
app.log.info(
|
||||||
|
{ issuer: env.authIssuer, portal: env.authPortalUrl },
|
||||||
|
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protect /api/* when AUTH_REQUIRED=true.
|
||||||
|
* Public: /health, /api/auth/config
|
||||||
|
*/
|
||||||
|
export async function requireAuth(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!env.authRequired) return
|
||||||
|
|
||||||
|
const pathname = (request.url.split("?")[0] ?? request.url)
|
||||||
|
if (pathname === "/api/auth/config") return
|
||||||
|
|
||||||
|
const authHeader = request.headers.authorization ?? ""
|
||||||
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.user
|
||||||
|
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
|
||||||
|
const permissions = Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!apps.includes("mm")) {
|
||||||
|
return reply
|
||||||
|
.code(403)
|
||||||
|
.send({ error: "Нет доступа к приложению MikrotikManager" })
|
||||||
|
}
|
||||||
|
|
||||||
|
request.authUser = {
|
||||||
|
id: String(payload.sub),
|
||||||
|
email: String(payload.email ?? ""),
|
||||||
|
name: String(payload.name ?? ""),
|
||||||
|
apps,
|
||||||
|
permissions,
|
||||||
|
isAdmin: Boolean(payload.is_admin),
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = permissionForRequest(request.method, pathname)
|
||||||
|
if (required && !hasPermission(permissions, required)) {
|
||||||
|
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default fp(authPlugin, { name: "auth" })
|
||||||
@@ -20,14 +20,14 @@ import {
|
|||||||
|
|
||||||
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/alerts", async (_req, reply) => {
|
app.get("/alerts", async (_req, reply) => {
|
||||||
const tg = getTelegramPublic()
|
const tg = await getTelegramPublic()
|
||||||
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
|
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
|
||||||
return reply.send({
|
return reply.send({
|
||||||
telegram: { ...tg, connected },
|
telegram: { ...tg, connected },
|
||||||
groups: listAlertGroups(),
|
groups: await listAlertGroups(),
|
||||||
rules: listAlertRules(),
|
rules: await listAlertRules(),
|
||||||
history: listMergedHistory(),
|
history: await listMergedHistory(),
|
||||||
meta: getAlertsMeta(),
|
meta: await getAlertsMeta(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
const groups =
|
const groups =
|
||||||
parsed.data.groups ??
|
parsed.data.groups ??
|
||||||
listAlertGroups().map((g) => ({
|
(await listAlertGroups()).map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
name: g.name,
|
name: g.name,
|
||||||
combineMode: g.combineMode,
|
combineMode: g.combineMode,
|
||||||
@@ -67,8 +67,8 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
||||||
chatId: r.chatId,
|
chatId: r.chatId,
|
||||||
}))
|
}))
|
||||||
replaceAlertsConfig({ groups, rules })
|
await replaceAlertsConfig({ groups, rules })
|
||||||
return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() })
|
return reply.send({ ok: true, groups: await listAlertGroups(), rules: await listAlertRules() })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/alerts/telegram", async (req, reply) => {
|
app.put("/alerts/telegram", async (req, reply) => {
|
||||||
@@ -76,7 +76,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const next = updateTelegramSettings({
|
const next = await updateTelegramSettings({
|
||||||
token: parsed.data.token,
|
token: parsed.data.token,
|
||||||
chatId: parsed.data.chatId,
|
chatId: parsed.data.chatId,
|
||||||
messageThreadId: parsed.data.messageThreadId,
|
messageThreadId: parsed.data.messageThreadId,
|
||||||
@@ -90,9 +90,9 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const fromDb = getTelegramBotToken()
|
const fromDb = await getTelegramBotToken()
|
||||||
const token = (parsed.data.token?.trim() || fromDb).trim()
|
const token = (parsed.data.token?.trim() || fromDb).trim()
|
||||||
const pub = getTelegramPublic()
|
const pub = await getTelegramPublic()
|
||||||
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
|
const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
|
return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" })
|
||||||
@@ -103,7 +103,7 @@ const alertsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
const threadId =
|
const threadId =
|
||||||
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
|
parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1
|
||||||
? parsed.data.messageThreadId
|
? parsed.data.messageThreadId
|
||||||
: getTelegramMessageThreadIdForApi()
|
: await getTelegramMessageThreadIdForApi()
|
||||||
const text = parsed.data.rulePreview
|
const text = parsed.data.rulePreview
|
||||||
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
|
? formatAlertRuleTestTelegramText(parsed.data.rulePreview)
|
||||||
: "MikroTik Manager — тест оповещений (Telegram)."
|
: "MikroTik Manager — тест оповещений (Telegram)."
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { readFile } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
import {
|
||||||
|
putBackupScheduleSettingsSchema,
|
||||||
|
putBackupStorageSettingsSchema,
|
||||||
|
} from "@mmapp/contracts/backups"
|
||||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
import { refreshScheduler } from "../services/scheduler.js"
|
import { refreshScheduler } from "../services/scheduler.js"
|
||||||
import {
|
import {
|
||||||
deleteBackupRecord,
|
deleteBackupRecord,
|
||||||
getBackupById,
|
getBackupById,
|
||||||
getBackupsDir,
|
|
||||||
getBackupScheduleSettings,
|
getBackupScheduleSettings,
|
||||||
|
getBackupStorageSettings,
|
||||||
listBackups,
|
listBackups,
|
||||||
|
readBackupContent,
|
||||||
|
restoreBackupToDevice,
|
||||||
runBackupForServer,
|
runBackupForServer,
|
||||||
|
syncBackupsFromS3,
|
||||||
|
testBackupStorageConnection,
|
||||||
updateBackupScheduleSettings,
|
updateBackupScheduleSettings,
|
||||||
|
updateBackupStorageSettings,
|
||||||
type BackupMeta,
|
type BackupMeta,
|
||||||
} from "../services/backup-service.js"
|
} from "../services/backup-service.js"
|
||||||
|
|
||||||
@@ -61,7 +67,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
}
|
}
|
||||||
job.status = "done"
|
job.status = "done"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
appendEvent({
|
await appendEvent({
|
||||||
level: job.failures.length > 0 ? "warning" : "info",
|
level: job.failures.length > 0 ? "warning" : "info",
|
||||||
eventType: "backups.job.done",
|
eventType: "backups.job.done",
|
||||||
sourceModule: "backups",
|
sourceModule: "backups",
|
||||||
@@ -80,11 +86,11 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
|||||||
|
|
||||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/backups", async (_req, reply) => {
|
app.get("/backups", async (_req, reply) => {
|
||||||
return reply.send(listBackups())
|
return reply.send(await listBackups())
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/backups/schedule", async (_req, reply) => {
|
app.get("/backups/schedule", async (_req, reply) => {
|
||||||
return reply.send(getBackupScheduleSettings())
|
return reply.send(await getBackupScheduleSettings())
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/backups/schedule", async (req, reply) => {
|
app.put("/backups/schedule", async (req, reply) => {
|
||||||
@@ -92,15 +98,48 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const result = updateBackupScheduleSettings(parsed.data)
|
const result = await updateBackupScheduleSettings(parsed.data)
|
||||||
refreshScheduler()
|
await refreshScheduler()
|
||||||
return reply.send(result)
|
return reply.send(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get("/backups/storage", async (_req, reply) => {
|
||||||
|
return reply.send(await getBackupStorageSettings())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/backups/storage", async (req, reply) => {
|
||||||
|
const parsed = putBackupStorageSettingsSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
return reply.send(await updateBackupStorageSettings(parsed.data))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/backups/storage/test", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
return reply.send(await testBackupStorageConnection())
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: err instanceof Error ? err.message : "Ошибка проверки S3",
|
||||||
|
settings: await getBackupStorageSettings(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/backups/storage/sync", async (_req, reply) => {
|
||||||
|
try {
|
||||||
|
return reply.send(await syncBackupsFromS3())
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: err instanceof Error ? err.message : "Ошибка синхронизации S3",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||||
const notes = req.body.notes?.trim() || undefined
|
const notes = req.body.notes?.trim() || undefined
|
||||||
const existingServers = new Set(listServersRead().map((s) => String(s.id)))
|
const existingServers = new Set((await listServersRead()).map((s) => String(s.id)))
|
||||||
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
|
const ids = [...new Set(inputIds)].filter((id) => existingServers.has(id))
|
||||||
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
|
if (ids.length === 0) return reply.status(400).send({ error: "Не выбраны валидные серверы" })
|
||||||
|
|
||||||
@@ -115,7 +154,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
failures: [],
|
failures: [],
|
||||||
}
|
}
|
||||||
backupJobs.set(jobId, job)
|
backupJobs.set(jobId, job)
|
||||||
appendEvent({
|
await appendEvent({
|
||||||
level: "info",
|
level: "info",
|
||||||
eventType: "backups.job.started",
|
eventType: "backups.job.started",
|
||||||
sourceModule: "backups",
|
sourceModule: "backups",
|
||||||
@@ -128,8 +167,8 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
notes: notes ?? null,
|
notes: notes ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
queueMicrotask(() => {
|
queueMicrotask(async () => {
|
||||||
void processBackupJob(job, ids, notes).catch((err) => {
|
void processBackupJob(job, ids, notes).catch(async (err) => {
|
||||||
job.status = "failed"
|
job.status = "failed"
|
||||||
job.finishedAt = new Date().toISOString()
|
job.finishedAt = new Date().toISOString()
|
||||||
const failure = {
|
const failure = {
|
||||||
@@ -137,7 +176,7 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
}
|
}
|
||||||
job.failures.push(failure)
|
job.failures.push(failure)
|
||||||
appendEvent({
|
await appendEvent({
|
||||||
level: "critical",
|
level: "critical",
|
||||||
eventType: "backups.job.failed",
|
eventType: "backups.job.failed",
|
||||||
sourceModule: "backups",
|
sourceModule: "backups",
|
||||||
@@ -167,14 +206,36 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
const hit = getBackupById(req.params.id)
|
const hit = await getBackupById(req.params.id)
|
||||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
try {
|
||||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
const content = await readBackupContent(hit)
|
||||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
||||||
reply.header("Content-Disposition", `attachment; filename="${hit.filename}"`)
|
return reply.send(content)
|
||||||
return reply.send(content)
|
} catch {
|
||||||
|
return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/backups/:id/restore", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const result = await restoreBackupToDevice(req.params.id)
|
||||||
|
await appendEvent({
|
||||||
|
level: "warning",
|
||||||
|
eventType: "backups.restore",
|
||||||
|
sourceModule: "backups",
|
||||||
|
title: "Восстановление бэкапа",
|
||||||
|
message: `${result.filename} → ${result.serverName}`,
|
||||||
|
entityType: "backup",
|
||||||
|
entityId: req.params.id,
|
||||||
|
})
|
||||||
|
return reply.send(result)
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: err instanceof Error ? err.message : "Не удалось восстановить бэкап",
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
|
// GET /api/bgp/sessions/raw/:id — raw RouterOS response for a single server (debug)
|
||||||
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
app.get("/bgp/sessions/raw/:id", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||||
const params = req.params as ServerIdParams
|
const params = req.params as ServerIdParams
|
||||||
const server = db.select().from(servers).where(eq(servers.id, params.id)).limit(1).all()[0]
|
const server = (await db.select().from(servers).where(eq(servers.id, params.id)).limit(1))[0]
|
||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
try {
|
try {
|
||||||
const client = MikrotikClient.fromServer(server)
|
const client = MikrotikClient.fromServer(server)
|
||||||
@@ -26,7 +26,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
// GET /api/bgp/sessions — aggregate from ALL enabled servers
|
// GET /api/bgp/sessions — aggregate from ALL enabled servers
|
||||||
app.get("/bgp/sessions", async (_req, reply) => {
|
app.get("/bgp/sessions", async (_req, reply) => {
|
||||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||||
|
|
||||||
const results: BgpSessionRead[][] = await Promise.all(
|
const results: BgpSessionRead[][] = await Promise.all(
|
||||||
allServers.map(async (server) => {
|
allServers.map(async (server) => {
|
||||||
@@ -46,10 +46,10 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
// GET /api/servers/:id/bgp/sessions — single server
|
// GET /api/servers/:id/bgp/sessions — single server
|
||||||
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
app.get("/servers/:id/bgp/sessions", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||||
const params = req.params as ServerIdParams
|
const params = req.params as ServerIdParams
|
||||||
const server = db
|
const server = (await db
|
||||||
.select().from(servers)
|
.select().from(servers)
|
||||||
.where(eq(servers.id, params.id))
|
.where(eq(servers.id, params.id))
|
||||||
.limit(1).all()[0]
|
.limit(1))[0]
|
||||||
|
|
||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get("/certificates/acme-settings", async (_req, reply) => {
|
app.get("/certificates/acme-settings", async (_req, reply) => {
|
||||||
return reply.send(getAcmeSettingsPublic())
|
return reply.send(await getAcmeSettingsPublic())
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/certificates/acme-settings", async (req, reply) => {
|
app.put("/certificates/acme-settings", async (req, reply) => {
|
||||||
@@ -40,11 +40,11 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
return reply.send(updateAcmeSettings(parsed.data))
|
return reply.send(await updateAcmeSettings(parsed.data))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get("/certificates/renew-settings", async (_req, reply) => {
|
app.get("/certificates/renew-settings", async (_req, reply) => {
|
||||||
return reply.send(getCertificateRenewSettings())
|
return reply.send(await getCertificateRenewSettings())
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/certificates/renew-settings", async (req, reply) => {
|
app.put("/certificates/renew-settings", async (req, reply) => {
|
||||||
@@ -52,8 +52,8 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const result = updateCertificateRenewSettings(parsed.data)
|
const result = await updateCertificateRenewSettings(parsed.data)
|
||||||
refreshScheduler()
|
await refreshScheduler()
|
||||||
return reply.send(result)
|
return reply.send(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
|
const effective = parsed.data.cloudflareApiToken?.trim() || await getAcmeCloudflareToken()
|
||||||
if (!effective) {
|
if (!effective) {
|
||||||
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
|
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
|
||||||
}
|
}
|
||||||
@@ -83,12 +83,12 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const serverId = String(parsed.data.serverId)
|
const serverId = String(parsed.data.serverId)
|
||||||
const server = getServerRowByIdString(serverId)
|
const server = await getServerRowByIdString(serverId)
|
||||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
|
||||||
const jobId = randomUUID()
|
const jobId = randomUUID()
|
||||||
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
|
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
|
||||||
createIssueJobRecord({
|
await createIssueJobRecord({
|
||||||
id: jobId,
|
id: jobId,
|
||||||
serverId,
|
serverId,
|
||||||
certName: parsed.data.certName.trim(),
|
certName: parsed.data.certName.trim(),
|
||||||
@@ -103,9 +103,9 @@ const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
|
|
||||||
app.get("/certificates/issue/:jobId", async (req, reply) => {
|
app.get("/certificates/issue/:jobId", async (req, reply) => {
|
||||||
const jobId = String((req.params as { jobId: string }).jobId)
|
const jobId = String((req.params as { jobId: string }).jobId)
|
||||||
const row = getIssueJobRecord(jobId)
|
const row = await getIssueJobRecord(jobId)
|
||||||
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
|
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
|
||||||
return reply.send(toIssueJobDto(row))
|
return reply.send(await toIssueJobDto(row))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import {
|
||||||
|
getEnabledServerById,
|
||||||
|
listContainers,
|
||||||
|
listContainersForServer,
|
||||||
|
removeContainer,
|
||||||
|
restartContainer,
|
||||||
|
startContainer,
|
||||||
|
stopContainer,
|
||||||
|
} from "../services/containers-live.js"
|
||||||
|
import { ServerIdParamSchema, type ServerIdParams } from "../types/server.js"
|
||||||
|
|
||||||
|
const RosIdBodySchema = z.object({
|
||||||
|
rosId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
type RosIdBody = z.infer<typeof RosIdBodySchema>
|
||||||
|
type MutateFn = typeof startContainer
|
||||||
|
|
||||||
|
const containersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/containers", async (_req, reply) => {
|
||||||
|
const containers = await listContainers()
|
||||||
|
return reply.send({ containers })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/servers/:id/containers", { schema: { params: ServerIdParamSchema } }, async (req, reply) => {
|
||||||
|
const params = req.params as ServerIdParams
|
||||||
|
const server = await getEnabledServerById(params.id)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
const containers = await listContainersForServer(server)
|
||||||
|
return reply.send({ containers })
|
||||||
|
})
|
||||||
|
|
||||||
|
function registerMutate(path: string, fn: MutateFn) {
|
||||||
|
app.post(path, { schema: { params: ServerIdParamSchema, body: RosIdBodySchema } }, async (req, reply) => {
|
||||||
|
const params = req.params as ServerIdParams
|
||||||
|
const body = req.body as RosIdBody
|
||||||
|
const server = await getEnabledServerById(params.id)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
try {
|
||||||
|
await fn(server, body.rosId)
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(502).send({
|
||||||
|
error: err instanceof Error ? err.message : "Ошибка RouterOS",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMutate("/servers/:id/containers/start", startContainer)
|
||||||
|
registerMutate("/servers/:id/containers/stop", stopContainer)
|
||||||
|
registerMutate("/servers/:id/containers/restart", restartContainer)
|
||||||
|
registerMutate("/servers/:id/containers/remove", removeContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default containersRoutes
|
||||||
@@ -8,7 +8,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректные параметры запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
return reply.send({ events: readEvents(parsed.data) })
|
return reply.send({ events: await readEvents(parsed.data) })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/events/batch", async (req, reply) => {
|
app.post("/events/batch", async (req, reply) => {
|
||||||
@@ -16,7 +16,7 @@ const eventsRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
appendEvents(parsed.data.events)
|
await appendEvents(parsed.data.events)
|
||||||
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
|
return reply.status(201).send({ ok: true, inserted: parsed.data.events.length })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ const TestEvobgpSchema = z.object({
|
|||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
function ensureEvobgpRow() {
|
async function ensureEvobgpRow() {
|
||||||
let row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
let row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
|
||||||
if (!row) {
|
if (!row) {
|
||||||
db.insert(evobgpSettings).values({ id: 1 }).run()
|
await db.insert(evobgpSettings).values({ id: 1 })
|
||||||
row = db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1).all()[0]
|
row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0]
|
||||||
}
|
}
|
||||||
return row!
|
return row!
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
|
||||||
|
function normalizeApiKey(raw: string): string {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
return trimmed.replace(/^Bearer\s+/i, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
interface EvoCatalogRaw {
|
interface EvoCatalogRaw {
|
||||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||||
domains: {
|
domains: {
|
||||||
@@ -102,11 +108,10 @@ function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, numb
|
|||||||
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
|
* Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN).
|
||||||
* Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`).
|
* Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`).
|
||||||
*/
|
*/
|
||||||
function distinctServersByFilterCommunityValue(): Map<string, number> {
|
async function distinctServersByFilterCommunityValue(): Promise<Map<string, number>> {
|
||||||
const rows = db
|
const rows = await db
|
||||||
.select({ serverId: filterRules.serverId, community: filterRules.community })
|
.select({ serverId: filterRules.serverId, community: filterRules.community })
|
||||||
.from(filterRules)
|
.from(filterRules)
|
||||||
.all()
|
|
||||||
const byVal = new Map<string, Set<number>>()
|
const byVal = new Map<string, Set<number>>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const v = (r.community ?? "").trim()
|
const v = (r.community ?? "").trim()
|
||||||
@@ -155,21 +160,21 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
|
||||||
const row = ensureEvobgpRow()
|
const row = await ensureEvobgpRow()
|
||||||
const root = normalizeBaseUrl(row.baseUrl)
|
const root = normalizeBaseUrl(row.baseUrl)
|
||||||
const apiKey = row.apiKey.trim()
|
const apiKey = normalizeApiKey(row.apiKey)
|
||||||
if (!root || !apiKey) return null
|
if (!root || !apiKey) return null
|
||||||
return { root, apiKey }
|
return { root, apiKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
app.get("/evobgp/settings", async (_req, reply) => {
|
app.get("/evobgp/settings", async (_req, reply) => {
|
||||||
const row = ensureEvobgpRow()
|
const row = await ensureEvobgpRow()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
baseUrl: row.baseUrl ?? "",
|
baseUrl: row.baseUrl ?? "",
|
||||||
enabled: row.enabled ?? false,
|
enabled: Boolean(row.enabled),
|
||||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,32 +183,36 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const cur = ensureEvobgpRow()
|
const cur = await ensureEvobgpRow()
|
||||||
let nextBase = cur.baseUrl
|
let nextBase = cur.baseUrl
|
||||||
let nextEnabled = cur.enabled
|
let nextEnabled = cur.enabled
|
||||||
let nextKey = cur.apiKey
|
let nextKey = cur.apiKey
|
||||||
|
|
||||||
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
|
if (parsed.data.baseUrl !== undefined) {
|
||||||
|
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
|
||||||
|
}
|
||||||
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
|
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
|
||||||
if (parsed.data.apiKey !== undefined) {
|
if (parsed.data.apiKey !== undefined) {
|
||||||
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
|
nextKey =
|
||||||
|
parsed.data.apiKey === null || parsed.data.apiKey === ""
|
||||||
|
? ""
|
||||||
|
: normalizeApiKey(parsed.data.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.update(evobgpSettings)
|
await db.update(evobgpSettings)
|
||||||
.set({
|
.set({
|
||||||
baseUrl: nextBase,
|
baseUrl: nextBase,
|
||||||
enabled: nextEnabled,
|
enabled: nextEnabled,
|
||||||
apiKey: nextKey,
|
apiKey: nextKey,
|
||||||
updatedAt: sql`(datetime('now'))`,
|
updatedAt: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(eq(evobgpSettings.id, 1))
|
.where(eq(evobgpSettings.id, 1))
|
||||||
.run()
|
|
||||||
|
|
||||||
const row = ensureEvobgpRow()
|
const row = await ensureEvobgpRow()
|
||||||
return reply.send({
|
return reply.send({
|
||||||
baseUrl: row.baseUrl ?? "",
|
baseUrl: row.baseUrl ?? "",
|
||||||
enabled: row.enabled ?? false,
|
enabled: Boolean(row.enabled),
|
||||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -217,13 +226,13 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
const row = ensureEvobgpRow()
|
const row = await ensureEvobgpRow()
|
||||||
const d = parsed.data
|
const d = parsed.data
|
||||||
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
|
const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl
|
||||||
const keyRaw =
|
const keyRaw =
|
||||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||||
const root = normalizeBaseUrl(urlRaw.trim())
|
const root = normalizeBaseUrl(urlRaw.trim())
|
||||||
const token = keyRaw.trim()
|
const token = normalizeApiKey(keyRaw)
|
||||||
if (!root || !token) {
|
if (!root || !token) {
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||||
@@ -242,11 +251,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
|
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
|
||||||
*/
|
*/
|
||||||
app.post("/evobgp/catalog", async (_req, reply) => {
|
app.post("/evobgp/catalog", async (_req, reply) => {
|
||||||
const row = ensureEvobgpRow()
|
const row = await ensureEvobgpRow()
|
||||||
if (!row.enabled) {
|
if (!row.enabled) {
|
||||||
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
||||||
}
|
}
|
||||||
const cred = credentialsFromDb()
|
const cred = await credentialsFromDb()
|
||||||
if (!cred) {
|
if (!cred) {
|
||||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||||
}
|
}
|
||||||
@@ -322,7 +331,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
||||||
const serversByCommunityStr = distinctServersByFilterCommunityValue()
|
const serversByCommunityStr = await distinctServersByFilterCommunityValue()
|
||||||
|
|
||||||
const communities = commItems.map((c) => {
|
const communities = commItems.map((c) => {
|
||||||
const valueStr = (c.community ?? "").trim()
|
const valueStr = (c.community ?? "").trim()
|
||||||
|
|||||||
@@ -314,10 +314,10 @@ const execRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
|
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const params = req.params as ServerIdParams
|
const params = req.params as ServerIdParams
|
||||||
const server = db
|
const server = (await db
|
||||||
.select().from(servers)
|
.select().from(servers)
|
||||||
.where(eq(servers.id, params.id))
|
.where(eq(servers.id, params.id))
|
||||||
.limit(1).all()[0]
|
.limit(1))[0]
|
||||||
|
|
||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
|
|||||||
+223
-282
@@ -1,14 +1,20 @@
|
|||||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
|
import { z } from "zod"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
import { filterRules, recursiveRoutes, servers } from "../db/schema.js"
|
||||||
import { MikrotikClient } from "../services/mikrotik.js"
|
import { MikrotikClient } from "../services/mikrotik.js"
|
||||||
import { parseDbServerId } from "../utils/server-id.js"
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||||
|
import { managedComment } from "../managed-markers.js"
|
||||||
|
import { planBgpInApply } from "../services/config-apply-plan.js"
|
||||||
import {
|
import {
|
||||||
hasManagedCommentPrefix,
|
appendRevisionIfChanged,
|
||||||
managedComment,
|
canonicalFilterRules,
|
||||||
} from "../managed-markers.js"
|
getRevisionById,
|
||||||
|
listRevisions,
|
||||||
|
type ConfigRevisionSource,
|
||||||
|
} from "../services/config-revisions.js"
|
||||||
|
|
||||||
type ServerRow = typeof servers.$inferSelect
|
type ServerRow = typeof servers.$inferSelect
|
||||||
|
|
||||||
@@ -186,12 +192,11 @@ function gatewayFromRecursiveDst(dstAddress: string): string {
|
|||||||
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
|
* Импорт с роутера даёт только `set gateway` без `set out-interface` — в таком виде не отличить от «поломанного» GRE.
|
||||||
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
|
* Сопоставляем hop с локальной таблицей recursive_routes и восстанавливаем gatewayTunnelId = rec:<id>.
|
||||||
*/
|
*/
|
||||||
function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): ApiFilterRule[] {
|
async function enrichRulesWithRecursiveGateway(serverId: number, rules: ApiFilterRule[]): Promise<ApiFilterRule[]> {
|
||||||
const rows = db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(recursiveRoutes)
|
.from(recursiveRoutes)
|
||||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
|
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.disabled, false)))
|
||||||
.all()
|
|
||||||
|
|
||||||
return rules.map(rule => {
|
return rules.map(rule => {
|
||||||
if (rule.action === "blackhole") return rule
|
if (rule.action === "blackhole") return rule
|
||||||
@@ -249,7 +254,7 @@ async function fetchServerFilters(server: ServerRow) {
|
|||||||
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
|
.filter(r => (r.chain ?? "").trim().toLowerCase() === "bgp-in")
|
||||||
.flatMap(parseFilterRule)
|
.flatMap(parseFilterRule)
|
||||||
|
|
||||||
const rules = enrichRulesWithRecursiveGateway(server.id, rulesRaw)
|
const rules = await enrichRulesWithRecursiveGateway(server.id, rulesRaw)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId: String(server.id),
|
serverId: String(server.id),
|
||||||
@@ -258,8 +263,8 @@ async function fetchServerFilters(server: ServerRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toApiRulesets(serverRows: ServerRow[]) {
|
async function toApiRulesets(serverRows: ServerRow[]) {
|
||||||
const dbRules = db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder)).all()
|
const dbRules = await db.select().from(filterRules).orderBy(asc(filterRules.serverId), asc(filterRules.sortOrder))
|
||||||
return serverRows.map(s => ({
|
return serverRows.map(s => ({
|
||||||
serverId: String(s.id),
|
serverId: String(s.id),
|
||||||
rules: dbRules
|
rules: dbRules
|
||||||
@@ -277,7 +282,7 @@ function toApiRulesets(serverRows: ServerRow[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
|
/** GRE: gatewayTunnelId = имя интерфейса; рекурсивный: rec:<id строки recursive_routes */
|
||||||
function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway: string; outIface: string } {
|
async function resolveRouteTargets(serverId: number, rule: ApiFilterRule): Promise<{ gateway: string; outIface: string }> {
|
||||||
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
|
if (rule.action === "blackhole") return { gateway: "", outIface: "" }
|
||||||
const tid = (rule.gatewayTunnelId ?? "").trim()
|
const tid = (rule.gatewayTunnelId ?? "").trim()
|
||||||
if (tid.startsWith("rec:")) {
|
if (tid.startsWith("rec:")) {
|
||||||
@@ -285,12 +290,11 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
|
|||||||
if (!Number.isFinite(rid)) {
|
if (!Number.isFinite(rid)) {
|
||||||
return { gateway: rule.gateway, outIface: "" }
|
return { gateway: rule.gateway, outIface: "" }
|
||||||
}
|
}
|
||||||
const row = db
|
const row = (await db
|
||||||
.select()
|
.select()
|
||||||
.from(recursiveRoutes)
|
.from(recursiveRoutes)
|
||||||
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
|
.where(and(eq(recursiveRoutes.serverId, serverId), eq(recursiveRoutes.id, rid)))
|
||||||
.limit(1)
|
.limit(1))[0]
|
||||||
.all()[0]
|
|
||||||
if (!row) return { gateway: rule.gateway, outIface: "" }
|
if (!row) return { gateway: rule.gateway, outIface: "" }
|
||||||
const gw = gatewayFromRecursiveDst(row.dstAddress)
|
const gw = gatewayFromRecursiveDst(row.dstAddress)
|
||||||
return { gateway: gw || rule.gateway, outIface: "" }
|
return { gateway: gw || rule.gateway, outIface: "" }
|
||||||
@@ -298,48 +302,7 @@ function resolveRouteTargets(serverId: number, rule: ApiFilterRule): { gateway:
|
|||||||
return { gateway: rule.gateway, outIface: tid }
|
return { gateway: rule.gateway, outIface: tid }
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCommunity(c: string): string {
|
async function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): Promise<string> {
|
||||||
return (c ?? "").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Одинаковый эффект на роутере при одинаковой community (blackhole vs gateway + out-interface) */
|
|
||||||
function ruleEffectSignature(serverId: number, r: ApiFilterRule): string {
|
|
||||||
if (r.action === "blackhole") return `bh:${normalizeCommunity(r.community)}`
|
|
||||||
const { gateway, outIface } = resolveRouteTargets(serverId, r)
|
|
||||||
return `rt:${normalizeCommunity(r.community)}:${gateway}:${outIface}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FilterRouterCompareStatus = "synced" | "drift" | "missing"
|
|
||||||
|
|
||||||
function compareDbRulesWithRouter(
|
|
||||||
serverId: number,
|
|
||||||
dbRules: ApiFilterRule[],
|
|
||||||
remoteRules: ApiFilterRule[],
|
|
||||||
): Record<string, FilterRouterCompareStatus> {
|
|
||||||
const remoteSigByComm = new Map<string, string>()
|
|
||||||
for (const rr of remoteRules) {
|
|
||||||
const c = normalizeCommunity(rr.community)
|
|
||||||
if (!remoteSigByComm.has(c)) {
|
|
||||||
remoteSigByComm.set(c, ruleEffectSignature(serverId, rr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out: Record<string, FilterRouterCompareStatus> = {}
|
|
||||||
for (const dr of dbRules) {
|
|
||||||
const c = normalizeCommunity(dr.community)
|
|
||||||
const sigD = ruleEffectSignature(serverId, dr)
|
|
||||||
const sigR = remoteSigByComm.get(c)
|
|
||||||
if (sigR === undefined) {
|
|
||||||
out[c] = "missing"
|
|
||||||
} else if (sigR !== sigD) {
|
|
||||||
out[c] = "drift"
|
|
||||||
} else {
|
|
||||||
out[c] = "synced"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
|
||||||
if (rules.length === 0) return ""
|
if (rules.length === 0) return ""
|
||||||
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
|
// Группируем по эффекту (action + gateway + out-interface). Communities с одним и тем же
|
||||||
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
|
// `set gw` объединяются через `||` в один if-блок — компактнее и ближе к привычному
|
||||||
@@ -361,7 +324,7 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
|||||||
const isBlackhole = rule.action === "blackhole"
|
const isBlackhole = rule.action === "blackhole"
|
||||||
const { gateway, outIface } = isBlackhole
|
const { gateway, outIface } = isBlackhole
|
||||||
? { gateway: "", outIface: "" }
|
? { gateway: "", outIface: "" }
|
||||||
: resolveRouteTargets(serverId, rule)
|
: await resolveRouteTargets(serverId, rule)
|
||||||
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
|
const key = isBlackhole ? "bh" : `rt:${gateway}:${outIface}`
|
||||||
let idx = indexByKey.get(key)
|
let idx = indexByKey.get(key)
|
||||||
if (idx === undefined) {
|
if (idx === undefined) {
|
||||||
@@ -404,10 +367,10 @@ function toRouterRuleBody(serverId: number, rules: ApiFilterRule[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
||||||
db.delete(filterRules).where(eq(filterRules.serverId, serverId)).run()
|
await db.delete(filterRules).where(eq(filterRules.serverId, serverId))
|
||||||
if (rules.length === 0) return
|
if (rules.length === 0) return
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
db.insert(filterRules).values(
|
await db.insert(filterRules).values(
|
||||||
rules.map((r, i) => ({
|
rules.map((r, i) => ({
|
||||||
serverId,
|
serverId,
|
||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
@@ -420,53 +383,88 @@ async function replaceDbRules(serverId: number, rules: ApiFilterRule[]) {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})),
|
})),
|
||||||
).run()
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
async function cacheRulesetsForServer(serverId: number): Promise<ApiFilterRule[]> {
|
||||||
/** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */
|
const rows = await db
|
||||||
app.get("/filters/router-compare", async (req, reply) => {
|
.select()
|
||||||
const q = req.query as { serverId?: string | number }
|
.from(filterRules)
|
||||||
const serverId = parseDbServerId(q.serverId)
|
.where(eq(filterRules.serverId, serverId))
|
||||||
if (serverId === null) {
|
.orderBy(asc(filterRules.sortOrder))
|
||||||
return reply.status(400).send({ error: "serverId is required" })
|
return rows.map((r) => ({
|
||||||
}
|
id: String(r.id),
|
||||||
|
community: r.community,
|
||||||
|
communityName: r.communityName ?? undefined,
|
||||||
|
action: r.action,
|
||||||
|
gateway: r.gateway,
|
||||||
|
gatewayTunnelId: r.gatewayTunnelId,
|
||||||
|
description: r.description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
async function applyFiltersToServer(
|
||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
server: ServerRow,
|
||||||
|
rules: ApiFilterRule[],
|
||||||
|
source: ConfigRevisionSource,
|
||||||
|
): Promise<{ pushed: number; action: string; conflictsRemoved: number }> {
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
|
||||||
|
const plan = planBgpInApply(existing, rules.length)
|
||||||
|
const managedCommentValue = managedComment(server.name || server.host)
|
||||||
|
|
||||||
try {
|
if (plan.action === "patch" && plan.managedId) {
|
||||||
const remote = await fetchServerFilters(server)
|
const ruleBody = await toRouterRuleBody(server.id, rules)
|
||||||
const rows = db
|
await client.patch(
|
||||||
.select()
|
`/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
|
||||||
.from(filterRules)
|
{
|
||||||
.where(eq(filterRules.serverId, serverId))
|
chain: "bgp-in",
|
||||||
.orderBy(asc(filterRules.sortOrder))
|
comment: managedCommentValue,
|
||||||
.all()
|
rule: ruleBody,
|
||||||
|
disabled: "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if (plan.action === "create") {
|
||||||
|
const ruleBody = await toRouterRuleBody(server.id, rules)
|
||||||
|
await client.post("/routing/filter/rule/add", {
|
||||||
|
chain: "bgp-in",
|
||||||
|
comment: managedCommentValue,
|
||||||
|
rule: ruleBody,
|
||||||
|
})
|
||||||
|
} else if (plan.action === "delete" && plan.managedId) {
|
||||||
|
await client.delete(
|
||||||
|
`/routing/filter/rule/${encodeURIComponent(plan.managedId)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const dbRules: ApiFilterRule[] = rows.map(r => ({
|
for (const id of plan.conflictIds) {
|
||||||
id: String(r.id),
|
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
|
||||||
community: r.community,
|
}
|
||||||
communityName: r.communityName ?? undefined,
|
|
||||||
action: r.action,
|
|
||||||
gateway: r.gateway,
|
|
||||||
gatewayTunnelId: r.gatewayTunnelId,
|
|
||||||
description: r.description,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const byCommunity = compareDbRulesWithRouter(serverId, dbRules, remote.rules)
|
await replaceDbRules(server.id, rules)
|
||||||
return reply.send({ byCommunity })
|
await appendRevisionIfChanged({
|
||||||
} catch (err) {
|
serverId: server.id,
|
||||||
app.log.error({ serverId, err: String(err) }, "filters router-compare failed")
|
section: "filters",
|
||||||
return reply.status(500).send({ error: String(err) })
|
source,
|
||||||
}
|
payload: canonicalFilterRules(rules),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
pushed: rules.length,
|
||||||
|
action: plan.action,
|
||||||
|
conflictsRemoved: plan.conflictIds.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||||||
|
|
||||||
|
const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
|
||||||
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
|
/** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */
|
||||||
app.get("/filters/gre-tunnels", async (req, reply) => {
|
app.get("/filters/gre-tunnels", async (req, reply) => {
|
||||||
const q = req.query as { serverId?: string | number }
|
const q = req.query as { serverId?: string | number }
|
||||||
const sid = parseDbServerId(q.serverId)
|
const sid = parseDbServerId(q.serverId)
|
||||||
const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||||
|
|
||||||
if (sid !== null) {
|
if (sid !== null) {
|
||||||
const server = enabledServers.find(s => s.id === sid)
|
const server = enabledServers.find(s => s.id === sid)
|
||||||
@@ -490,74 +488,103 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
return reply.send({ tunnels: results.flat() })
|
return reply.send({ tunnels: results.flat() })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Только правила фильтров из БД (без опроса MikroTik за GRE) */
|
/** Без serverId — cache для дашборда. С serverId — live с CHR, cache fallback. */
|
||||||
app.get("/filters/rules", async (_req, reply) => {
|
app.get("/filters/rules", async (req, reply) => {
|
||||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
const q = req.query as { serverId?: string | number }
|
||||||
const dbRulesets = toApiRulesets(allServers)
|
const serverId = parseDbServerId(q.serverId)
|
||||||
|
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
rulesets: dbRulesets,
|
|
||||||
greTunnels: [] as LiveGreTunnel[],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
app.put("/filters/rules", async (req, reply) => {
|
|
||||||
const body = req.body as { rulesets?: Array<{ serverId: string; rules: ApiFilterRule[] }> }
|
|
||||||
const payload = body.rulesets ?? []
|
|
||||||
const serverIds = payload.map(r => Number.parseInt(r.serverId, 10)).filter(Number.isFinite)
|
|
||||||
if (serverIds.length > 0) {
|
|
||||||
db.delete(filterRules).where(inArray(filterRules.serverId, serverIds)).run()
|
|
||||||
}
|
|
||||||
for (const rs of payload) {
|
|
||||||
const sid = Number.parseInt(rs.serverId, 10)
|
|
||||||
if (!Number.isFinite(sid)) continue
|
|
||||||
await replaceDbRules(sid, rs.rules ?? [])
|
|
||||||
}
|
|
||||||
return reply.send({ ok: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
app.post("/filters/sync/from-router", async (_req, reply) => {
|
|
||||||
const body = _req.body as { serverId?: string | number } | undefined
|
|
||||||
const rawServerId = body?.serverId
|
|
||||||
const serverId = parseDbServerId(rawServerId)
|
|
||||||
if (serverId === null) {
|
if (serverId === null) {
|
||||||
return reply.status(400).send({ error: "serverId is required" })
|
const dbRulesets = await toApiRulesets(allServers)
|
||||||
|
return reply.send({
|
||||||
|
rulesets: dbRulesets,
|
||||||
|
greTunnels: [] as LiveGreTunnel[],
|
||||||
|
live: false,
|
||||||
|
stale: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0]
|
const server = allServers.find((s) => s.id === serverId)
|
||||||
|
?? (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app.log.info({ serverId, host: server.host }, "Filters sync from router started")
|
|
||||||
appendEvent({
|
|
||||||
level: "info",
|
|
||||||
eventType: "filters.sync.from_router.started",
|
|
||||||
sourceModule: "filters",
|
|
||||||
title: "Синхронизация фильтров запущена",
|
|
||||||
message: `${server.name || server.host} → БД`,
|
|
||||||
entityType: "server",
|
|
||||||
entityId: String(serverId),
|
|
||||||
})
|
|
||||||
const remote = await fetchServerFilters(server)
|
const remote = await fetchServerFilters(server)
|
||||||
await replaceDbRules(server.id, remote.rules)
|
await replaceDbRules(server.id, remote.rules)
|
||||||
app.log.info({ serverId, totalRules: remote.rules.length }, "Filters sync from router completed")
|
await appendRevisionIfChanged({
|
||||||
appendEvent({
|
serverId: server.id,
|
||||||
|
section: "filters",
|
||||||
|
source: "observed",
|
||||||
|
payload: canonicalFilterRules(remote.rules),
|
||||||
|
})
|
||||||
|
const cached = await cacheRulesetsForServer(server.id)
|
||||||
|
return reply.send({
|
||||||
|
rulesets: [{ serverId: String(server.id), rules: cached }],
|
||||||
|
greTunnels: remote.tunnels,
|
||||||
|
live: true,
|
||||||
|
stale: false,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ serverId, err: String(err) }, "filters live GET failed, serving cache")
|
||||||
|
const cached = await cacheRulesetsForServer(server.id)
|
||||||
|
return reply.send({
|
||||||
|
rulesets: [{ serverId: String(server.id), rules: cached }],
|
||||||
|
greTunnels: [] as LiveGreTunnel[],
|
||||||
|
live: false,
|
||||||
|
stale: true,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/filters/rules", async (req, reply) => {
|
||||||
|
const body = req.body as {
|
||||||
|
serverId?: string | number
|
||||||
|
rules?: ApiFilterRule[]
|
||||||
|
source?: ConfigRevisionSource
|
||||||
|
}
|
||||||
|
const serverId = parseDbServerId(body.serverId)
|
||||||
|
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||||
|
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
|
||||||
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
|
const rules = body.rules ?? []
|
||||||
|
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
|
||||||
|
try {
|
||||||
|
await appendEvent({
|
||||||
level: "info",
|
level: "info",
|
||||||
eventType: "filters.sync.from_router.done",
|
eventType: "filters.apply.started",
|
||||||
sourceModule: "filters",
|
sourceModule: "filters",
|
||||||
title: "Синхронизация фильтров завершена",
|
title: "Применение фильтров на роутер",
|
||||||
message: `${server.name || server.host}: ${remote.rules.length} правил`,
|
message: `${server.name || server.host}: ${rules.length} правил`,
|
||||||
entityType: "server",
|
entityType: "server",
|
||||||
entityId: String(serverId),
|
entityId: String(serverId),
|
||||||
})
|
})
|
||||||
return reply.send({ ok: true, updatedServers: 1, totalRules: remote.rules.length, serverId })
|
const result = await applyFiltersToServer(server, rules, source)
|
||||||
} catch (err) {
|
const cached = await cacheRulesetsForServer(server.id)
|
||||||
app.log.error({ serverId, err: String(err) }, "Filters sync from router failed")
|
await appendEvent({
|
||||||
appendEvent({
|
level: "info",
|
||||||
level: "critical",
|
eventType: "filters.apply.done",
|
||||||
eventType: "filters.sync.from_router.failed",
|
|
||||||
sourceModule: "filters",
|
sourceModule: "filters",
|
||||||
title: "Ошибка синхронизации фильтров",
|
title: "Фильтры применены",
|
||||||
|
message: `${server.name || server.host}: ${result.pushed} правил (${result.action})`,
|
||||||
|
entityType: "server",
|
||||||
|
entityId: String(serverId),
|
||||||
|
})
|
||||||
|
return reply.send({
|
||||||
|
ok: true,
|
||||||
|
serverId,
|
||||||
|
pushedRules: result.pushed,
|
||||||
|
action: result.action,
|
||||||
|
rules: cached,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ serverId, err: String(err) }, "filters apply failed")
|
||||||
|
await appendEvent({
|
||||||
|
level: "critical",
|
||||||
|
eventType: "filters.apply.failed",
|
||||||
|
sourceModule: "filters",
|
||||||
|
title: "Ошибка применения фильтров",
|
||||||
message: `${server.name || server.host}: ${String(err)}`,
|
message: `${server.name || server.host}: ${String(err)}`,
|
||||||
entityType: "server",
|
entityType: "server",
|
||||||
entityId: String(serverId),
|
entityId: String(serverId),
|
||||||
@@ -566,146 +593,60 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/filters/sync/to-router", async (req, reply) => {
|
app.get("/filters/revisions", async (req, reply) => {
|
||||||
|
const q = req.query as { serverId?: string | number }
|
||||||
|
const serverId = parseDbServerId(q.serverId)
|
||||||
|
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||||
|
const revisions = await listRevisions(serverId, "filters")
|
||||||
|
return reply.send({ revisions })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/filters/revisions/:id/restore", {
|
||||||
|
schema: { params: RevisionIdParamSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params
|
||||||
const body = req.body as { serverId?: string | number } | undefined
|
const body = req.body as { serverId?: string | number } | undefined
|
||||||
const requestedServerId = parseDbServerId(body?.serverId)
|
const rev = await getRevisionById(id)
|
||||||
|
if (!rev) return reply.status(404).send({ error: "Revision not found" })
|
||||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
if (rev.section !== "filters") return reply.status(400).send({ error: "Revision section mismatch" })
|
||||||
const targetServers = requestedServerId !== null
|
const requested = parseDbServerId(body?.serverId)
|
||||||
? allServers.filter(s => s.id === requestedServerId)
|
if (requested !== null && requested !== rev.serverId) {
|
||||||
: allServers
|
return reply.status(400).send({ error: "Revision belongs to another server" })
|
||||||
|
|
||||||
if (requestedServerId !== null && targetServers.length === 0) {
|
|
||||||
return reply.status(404).send({ error: "Server not found" })
|
|
||||||
}
|
}
|
||||||
|
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
|
||||||
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||||
|
|
||||||
let updatedServers = 0
|
const raw = Array.isArray(rev.payload) ? rev.payload : []
|
||||||
let pushedRules = 0
|
const rules: ApiFilterRule[] = raw.map((item, idx) => {
|
||||||
const errors: Array<{ serverId: number; error: string }> = []
|
const r = item as Partial<ApiFilterRule>
|
||||||
appendEvent({
|
return {
|
||||||
level: "info",
|
id: `rev-${idx}`,
|
||||||
eventType: "filters.sync.to_router.started",
|
community: r.community ?? "",
|
||||||
sourceModule: "filters",
|
communityName: r.communityName,
|
||||||
title: "Отправка фильтров на роутеры запущена",
|
action: r.action === "blackhole" ? "blackhole" : "route",
|
||||||
message: `Целевых серверов: ${targetServers.length}`,
|
gateway: r.gateway ?? "",
|
||||||
payload: { requestedServerId },
|
gatewayTunnelId: r.gatewayTunnelId ?? "",
|
||||||
})
|
description: r.description ?? "",
|
||||||
|
|
||||||
for (const server of targetServers) {
|
|
||||||
try {
|
|
||||||
app.log.info({ serverId: server.id, host: server.host }, "Filters sync to router started")
|
|
||||||
const client = MikrotikClient.fromServer(server)
|
|
||||||
const existing = await client.get<RosFilterRule[]>("/routing/filter/rule")
|
|
||||||
|
|
||||||
const isInBgpIn = (r: RosFilterRule) =>
|
|
||||||
(r.chain ?? "").trim().toLowerCase() === "bgp-in"
|
|
||||||
const managedCommentValue = managedComment(server.name || server.host)
|
|
||||||
|
|
||||||
// Уже созданное нами правило — будем PATCH'ить, чтобы сохранить ID/позицию в цепочке.
|
|
||||||
const managedRule = existing.find(
|
|
||||||
r => isInBgpIn(r) && hasManagedCommentPrefix(r.comment ?? ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Конфликтующие легаси-правила в bgp-in (без нашего comment, но с bgp-communities) —
|
|
||||||
// удаляем после успешного upsert: иначе старое правило с `else { reject; }`
|
|
||||||
// отрабатывает первым и перебивает наш upsert.
|
|
||||||
const conflictIds = existing
|
|
||||||
.filter(r =>
|
|
||||||
isInBgpIn(r) &&
|
|
||||||
!hasManagedCommentPrefix(r.comment ?? "") &&
|
|
||||||
/bgp-communities/i.test(r.rule ?? ""),
|
|
||||||
)
|
|
||||||
.map(r => r[".id"])
|
|
||||||
.filter((id): id is string => Boolean(id))
|
|
||||||
|
|
||||||
const rows = db.select().from(filterRules)
|
|
||||||
.where(and(eq(filterRules.serverId, server.id)))
|
|
||||||
.orderBy(asc(filterRules.sortOrder))
|
|
||||||
.all()
|
|
||||||
|
|
||||||
const rules: ApiFilterRule[] = rows.map(r => ({
|
|
||||||
id: String(r.id),
|
|
||||||
community: r.community,
|
|
||||||
communityName: r.communityName ?? undefined,
|
|
||||||
action: r.action,
|
|
||||||
gateway: r.gateway,
|
|
||||||
gatewayTunnelId: r.gatewayTunnelId,
|
|
||||||
description: r.description,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Upsert: PATCH существующего managed-правила или POST /add нового.
|
|
||||||
// Если ошибка — конфликтные правила НЕ удаляем (роутер не остаётся с пустым bgp-in).
|
|
||||||
// Путь `/routing/filter/rule/add` обязателен: голый POST на коллекцию RouterOS REST
|
|
||||||
// трактует как «вызов команды» и отдаёт 400 «no such command».
|
|
||||||
// См. https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API
|
|
||||||
if (rules.length > 0) {
|
|
||||||
const ruleBody = toRouterRuleBody(server.id, rules)
|
|
||||||
if (managedRule && managedRule[".id"]) {
|
|
||||||
await client.patch(
|
|
||||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
|
||||||
{
|
|
||||||
chain: "bgp-in",
|
|
||||||
comment: managedCommentValue,
|
|
||||||
rule: ruleBody,
|
|
||||||
disabled: "no",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
app.log.info({ serverId: server.id, id: managedRule[".id"] }, "bgp-in rule updated")
|
|
||||||
} else {
|
|
||||||
await client.post("/routing/filter/rule/add", {
|
|
||||||
chain: "bgp-in",
|
|
||||||
comment: managedCommentValue,
|
|
||||||
rule: ruleBody,
|
|
||||||
})
|
|
||||||
app.log.info({ serverId: server.id }, "bgp-in rule created")
|
|
||||||
}
|
|
||||||
pushedRules += rules.length
|
|
||||||
} else if (managedRule && managedRule[".id"]) {
|
|
||||||
// В БД нет правил → удаляем наш managed-rule на роутере.
|
|
||||||
await client.delete(
|
|
||||||
`/routing/filter/rule/${encodeURIComponent(managedRule[".id"])}`,
|
|
||||||
)
|
|
||||||
app.log.info({ serverId: server.id }, "bgp-in rule removed (no rules in DB)")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of conflictIds) {
|
|
||||||
await client.delete(`/routing/filter/rule/${encodeURIComponent(id)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedServers += 1
|
|
||||||
app.log.info(
|
|
||||||
{
|
|
||||||
serverId: server.id,
|
|
||||||
mode: managedRule ? "patch" : "create",
|
|
||||||
conflictsRemoved: conflictIds.length,
|
|
||||||
pushed: rules.length,
|
|
||||||
},
|
|
||||||
"Filters sync to router completed",
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
app.log.error({ serverId: server.id, err: String(err) }, "filters sync to-router failed")
|
|
||||||
errors.push({ serverId: server.id, error: String(err) })
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
appendEvent({
|
try {
|
||||||
level: errors.length === 0 ? "info" : "warning",
|
const result = await applyFiltersToServer(server, rules, "rollback")
|
||||||
eventType: errors.length === 0 ? "filters.sync.to_router.done" : "filters.sync.to_router.partial",
|
const cached = await cacheRulesetsForServer(server.id)
|
||||||
sourceModule: "filters",
|
await appendEvent({
|
||||||
title: errors.length === 0 ? "Отправка фильтров завершена" : "Отправка фильтров завершена с ошибками",
|
level: "info",
|
||||||
message: `Успешно: ${updatedServers}, ошибок: ${errors.length}, правил: ${pushedRules}`,
|
eventType: "filters.rollback.done",
|
||||||
payload: {
|
sourceModule: "filters",
|
||||||
updatedServers,
|
title: "Откат фильтров",
|
||||||
pushedRules,
|
message: `${server.name || server.host}: ${result.pushed} правил`,
|
||||||
errors,
|
entityType: "server",
|
||||||
},
|
entityId: String(server.id),
|
||||||
})
|
})
|
||||||
return reply.send({
|
return reply.send({ ok: true, rules: cached, pushedRules: result.pushed })
|
||||||
ok: errors.length === 0,
|
} catch (err) {
|
||||||
updatedServers,
|
app.log.error({ serverId: server.id, err: String(err) }, "filters restore failed")
|
||||||
pushedRules,
|
return reply.status(500).send({ error: String(err) })
|
||||||
errors,
|
}
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
|
||||||
|
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||||
|
import {
|
||||||
|
captureFirewallSnapshot,
|
||||||
|
fetchFirewallState,
|
||||||
|
listFirewallAll,
|
||||||
|
} from "../services/firewall-live.js"
|
||||||
|
import {
|
||||||
|
captureAndAppendRevision,
|
||||||
|
listRevisions,
|
||||||
|
loadRevisionForRestore,
|
||||||
|
type ConfigRevisionSource,
|
||||||
|
} from "../services/config-revisions.js"
|
||||||
|
import { parseFirewallSnapshot, planFirewallRestore } from "../services/entity-snapshots.js"
|
||||||
|
import { executeRosOps } from "../services/ros-ops.js"
|
||||||
|
import { parseDbServerId } from "../utils/server-id.js"
|
||||||
|
import type { FirewallFamily, FirewallTable } from "../types/server.js"
|
||||||
|
|
||||||
|
const FamilySchema = z.enum(["ip", "ip6"])
|
||||||
|
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
|
||||||
|
|
||||||
|
const RuleKeySchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
table: TableSchema,
|
||||||
|
rosId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RuleWriteSchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
table: TableSchema,
|
||||||
|
rosId: z.string().min(1).optional(),
|
||||||
|
chain: z.string().min(1),
|
||||||
|
action: z.string().min(1),
|
||||||
|
protocol: z.string().optional(),
|
||||||
|
srcAddress: z.string().optional(),
|
||||||
|
dstAddress: z.string().optional(),
|
||||||
|
srcAddressList: z.string().optional(),
|
||||||
|
dstAddressList: z.string().optional(),
|
||||||
|
srcPort: z.string().optional(),
|
||||||
|
dstPort: z.string().optional(),
|
||||||
|
inInterface: z.string().optional(),
|
||||||
|
outInterface: z.string().optional(),
|
||||||
|
connectionState: z.string().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
log: z.boolean().optional(),
|
||||||
|
logPrefix: z.string().optional(),
|
||||||
|
tlsHost: z.string().optional(),
|
||||||
|
layer7Proto: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RulePatchSchema = RuleKeySchema.extend({
|
||||||
|
disabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const RuleMoveSchema = RuleKeySchema.extend({
|
||||||
|
destinationRosId: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressKeySchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
rosId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressWriteSchema = z.object({
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
family: FamilySchema,
|
||||||
|
rosId: z.string().min(1).optional(),
|
||||||
|
list: z.string().min(1),
|
||||||
|
address: z.string().min(1),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
timeout: z.string().optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const AddressPatchSchema = AddressKeySchema.extend({
|
||||||
|
disabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (v !== undefined && v !== "") out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
|
||||||
|
return toRosBody({
|
||||||
|
chain: d.chain,
|
||||||
|
action: d.action,
|
||||||
|
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
|
||||||
|
"src-address": d.srcAddress,
|
||||||
|
"dst-address": d.dstAddress,
|
||||||
|
"src-address-list": d.srcAddressList,
|
||||||
|
"dst-address-list": d.dstAddressList,
|
||||||
|
"src-port": d.srcPort,
|
||||||
|
"dst-port": d.dstPort,
|
||||||
|
"in-interface": d.inInterface,
|
||||||
|
"out-interface": d.outInterface,
|
||||||
|
"connection-state": d.connectionState,
|
||||||
|
comment: d.comment,
|
||||||
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||||
|
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
|
||||||
|
"log-prefix": d.logPrefix,
|
||||||
|
"tls-host": d.tlsHost,
|
||||||
|
"layer7-protocol": d.layer7Proto,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
|
||||||
|
return toRosBody({
|
||||||
|
list: d.list,
|
||||||
|
address: d.address,
|
||||||
|
comment: d.comment,
|
||||||
|
timeout: d.timeout,
|
||||||
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosErr(e: unknown): string {
|
||||||
|
if (e instanceof MikrotikError) return e.message
|
||||||
|
if (e instanceof Error) return e.message
|
||||||
|
return String(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireServer(serverId: string) {
|
||||||
|
return await getEnabledServerById(serverId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFirewall(
|
||||||
|
server: NonNullable<Awaited<ReturnType<typeof requireServer>>>,
|
||||||
|
source: ConfigRevisionSource,
|
||||||
|
) {
|
||||||
|
await captureAndAppendRevision({
|
||||||
|
serverId: server.id,
|
||||||
|
section: "firewall",
|
||||||
|
source,
|
||||||
|
capture: () => captureFirewallSnapshot(server),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
||||||
|
|
||||||
|
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||||
|
app.get("/firewall/all", async (_req, reply) => {
|
||||||
|
const data = await listFirewallAll()
|
||||||
|
return reply.send(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
|
||||||
|
try {
|
||||||
|
await client.put(path, ruleToRos(body))
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.status(201).send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, ruleToRos(body))
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RulePatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/firewall/rules", async (req, reply) => {
|
||||||
|
const parsed = RuleKeySchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.delete(path)
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/rules/move", async (req, reply) => {
|
||||||
|
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, body.table)}/move`
|
||||||
|
try {
|
||||||
|
await client.post(path, {
|
||||||
|
numbers: body.rosId,
|
||||||
|
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
|
||||||
|
})
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
try {
|
||||||
|
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.status(201).send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, addressToRos(body))
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete("/firewall/address-lists", async (req, reply) => {
|
||||||
|
const parsed = AddressKeySchema.safeParse(req.body ?? {})
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
|
}
|
||||||
|
const body = parsed.data
|
||||||
|
const server = await requireServer(body.serverId)
|
||||||
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||||
|
const client = MikrotikClient.fromServer(server)
|
||||||
|
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
|
||||||
|
try {
|
||||||
|
await client.delete(path)
|
||||||
|
await recordFirewall(server, "apply")
|
||||||
|
return reply.send({ ok: true })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/firewall/revisions", async (req, reply) => {
|
||||||
|
const q = req.query as { serverId?: string | number }
|
||||||
|
const serverId = parseDbServerId(q.serverId)
|
||||||
|
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
||||||
|
const revisions = await listRevisions(serverId, "firewall")
|
||||||
|
return reply.send({ revisions })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post("/firewall/revisions/:id/restore", {
|
||||||
|
schema: { params: RevisionIdParamSchema },
|
||||||
|
}, async (req, reply) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const body = req.body as { serverId?: string | number } | undefined
|
||||||
|
const loaded = await loadRevisionForRestore({
|
||||||
|
id,
|
||||||
|
section: "firewall",
|
||||||
|
requestedServerId: parseDbServerId(body?.serverId),
|
||||||
|
})
|
||||||
|
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
|
||||||
|
const client = MikrotikClient.fromServer(loaded.server)
|
||||||
|
try {
|
||||||
|
const desired = parseFirewallSnapshot(loaded.row.payload)
|
||||||
|
const state = await fetchFirewallState(loaded.server)
|
||||||
|
const ops = planFirewallRestore(desired, {
|
||||||
|
rules: state.liveRules,
|
||||||
|
addressLists: state.liveLists,
|
||||||
|
})
|
||||||
|
await executeRosOps(client, ops)
|
||||||
|
await recordFirewall(loaded.server, "rollback")
|
||||||
|
const next = await fetchFirewallState(loaded.server)
|
||||||
|
return reply.send({ ok: true, rules: next.rules, addressLists: next.addressLists })
|
||||||
|
} catch (e) {
|
||||||
|
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default firewallRoutes
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user