Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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:3333__TRACKED_VAR__)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
|
||||
"WebFetch(domain:git.shts.su)",
|
||||
"Bash(curl -s \"https://git.shts.su/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\")",
|
||||
"WebFetch(domain:git.shx.one)",
|
||||
"Bash(curl -s \"https://git.shx.one/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/OspfToolsPage.jsx\")",
|
||||
"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(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 *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -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 *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -61,7 +61,16 @@ jobs:
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
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 -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
@@ -70,6 +79,7 @@ jobs:
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -177,6 +187,7 @@ jobs:
|
||||
NEXT_PUBLIC_BACKEND_URL=same-origin
|
||||
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
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_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
|
||||
tags: |
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
|
||||
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
|
||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||
ARG NEXT_PUBLIC_RELEASE_URL=
|
||||
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
|
||||
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
|
||||
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_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
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 next.config.ts tsconfig.json postcss.config.mjs components.json ./
|
||||
COPY app app
|
||||
|
||||
@@ -135,6 +135,7 @@ sequenceDiagram
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
|
||||
@@ -219,7 +220,62 @@ npm --prefix backend run db:studio
|
||||
|
||||
## Прод-развёртывание 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` из корня).
|
||||
|
||||
### Прод-контейнеры
|
||||
|
||||
|
||||
+36
-25
@@ -8,8 +8,7 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import type { Backup, Server } from "@/lib/data"
|
||||
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 { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -28,6 +27,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { requestBlob } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Stepper,
|
||||
@@ -276,8 +276,7 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, fallbackFilename: string) {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
@@ -343,27 +342,39 @@ export default function BackupsPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
|
||||
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
|
||||
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
|
||||
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
|
||||
].map((s) => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-center 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>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка бэкапов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего бэкапов",
|
||||
value: backupList.length,
|
||||
icon: <HardDriveIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "auto",
|
||||
label: "Авто",
|
||||
value: autoCount,
|
||||
icon: <ClockIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "manual",
|
||||
label: "Вручную",
|
||||
value: manualCount,
|
||||
icon: <PlusIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов охвачено",
|
||||
value: serverCount,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Info bar */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
|
||||
|
||||
+71
-38
@@ -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 { BGP_AS_NAMES } from "@/lib/bgp/types"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -20,9 +21,10 @@ import {
|
||||
RefreshCwIcon, DownloadIcon, SearchIcon,
|
||||
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
|
||||
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
|
||||
XIcon, AlertCircleIcon,
|
||||
XIcon, AlertCircleIcon, GitMergeIcon, CheckCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -462,22 +464,39 @@ function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* summary row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
|
||||
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
|
||||
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка префиксов BGP"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "Всего префиксов",
|
||||
value: fmtNum(totalRx),
|
||||
icon: <DownloadIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "active",
|
||||
label: "Активных маршрутов",
|
||||
value: fmtNum(totalActive),
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
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">
|
||||
{/* prefixes by peer — horizontal bar chart */}
|
||||
@@ -621,11 +640,7 @@ export default function BgpPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
@@ -725,22 +740,40 @@ export default function BgpPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий всего", value: sessions.length, color: "" },
|
||||
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
|
||||
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BGP"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий всего",
|
||||
value: sessions.length,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "established",
|
||||
label: "Established",
|
||||
value: established,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
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 */}
|
||||
{notEstab > 0 && (
|
||||
|
||||
@@ -7,12 +7,13 @@ import { FileImportDialog } from "@/components/file-import-dialog"
|
||||
import { routerCertificates, servers as mockServers } from "@/lib/data"
|
||||
import type { CertStatus, Server } from "@/lib/data"
|
||||
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 { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -119,42 +120,41 @@ function CertPartKpi({
|
||||
expired: CertificateDto[]
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сертификатов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: displayCerts.length,
|
||||
icon: <ShieldCheckIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "valid",
|
||||
label: "Действующих",
|
||||
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: "Истекают",
|
||||
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: "Истёкших",
|
||||
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>
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,8 @@ export default function CertificatesPage() {
|
||||
const [issueTrustWww, setIssueTrustWww] = useState(true)
|
||||
const [issueTrustApi, setIssueTrustApi] = useState(true)
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [acmeDirectoryUrl, setAcmeDirectoryUrl] = useState(
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
)
|
||||
@@ -451,6 +453,27 @@ export default function CertificatesPage() {
|
||||
return routerCertificates.map(mockToDto)
|
||||
}, [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 map = new Map<string, Server>()
|
||||
for (const s of isLive ? serverList : mockServers) map.set(s.id, s)
|
||||
@@ -515,13 +538,13 @@ export default function CertificatesPage() {
|
||||
}, [isLive, loadLive, loadAcmeSettings])
|
||||
|
||||
const expiring = useMemo(
|
||||
() => displayCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[displayCerts],
|
||||
() => scopedCerts.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
|
||||
[scopedCerts],
|
||||
)
|
||||
const expired = useMemo(() => displayCerts.filter((c) => c.status === "expired"), [displayCerts])
|
||||
const expired = useMemo(() => scopedCerts.filter((c) => c.status === "expired"), [scopedCerts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayCerts.filter((c) => {
|
||||
return scopedCerts.filter((c) => {
|
||||
if (statusFilter !== "all" && c.status !== statusFilter) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
@@ -532,7 +555,7 @@ export default function CertificatesPage() {
|
||||
c.sans.some((s) => s.includes(q))
|
||||
)
|
||||
})
|
||||
}, [displayCerts, search, statusFilter])
|
||||
}, [scopedCerts, search, statusFilter])
|
||||
|
||||
async function handleRefresh() {
|
||||
if (!liveReady) return
|
||||
@@ -625,35 +648,52 @@ export default function CertificatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</Button>
|
||||
<Button size="sm" disabled={!liveReady || issueBusy} onClick={() => { setIssueStep(1); setIssueOpen(true) }}>
|
||||
<PlusIcon className="size-4" />
|
||||
Выпустить сертификат
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={certRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loadState === "loading" && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!liveReady || loadState === "loading"}
|
||||
onClick={() => {
|
||||
void handleRefresh()
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loadState === "loading" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
Импорт
|
||||
</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">
|
||||
{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">
|
||||
@@ -673,7 +713,7 @@ export default function CertificatesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CertPartKpi displayCerts={displayCerts} expiring={expiring} expired={expired} />
|
||||
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
|
||||
|
||||
{liveReady && (
|
||||
<CertPartAcmeSettings
|
||||
@@ -720,7 +760,7 @@ export default function CertificatesPage() {
|
||||
|
||||
<CertPartReference />
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<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">
|
||||
@@ -753,7 +793,7 @@ export default function CertificatesPage() {
|
||||
<StepperContent key={s} value={s}>
|
||||
<CertPartIssueForm
|
||||
step={s as 1 | 2 | 3 | 4}
|
||||
serverList={serverList}
|
||||
serverList={displayServers}
|
||||
issueServerId={issueServerId}
|
||||
setIssueServerId={setIssueServerId}
|
||||
issueCertName={issueCertName}
|
||||
@@ -812,6 +852,6 @@ export default function CertificatesPage() {
|
||||
toast.success(`Файл ${files[0]?.name} готов к импорту на роутер`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ACTION_COLOR,
|
||||
} from "@/components/data-grids/communities-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -160,22 +161,39 @@ export default function CommunitiesPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── summary ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Всего communities", value: String(listData.length) },
|
||||
{ label: "Активных", value: String(listData.filter(c => c.enabled).length) },
|
||||
{ label: "Стандартных", value: String(listData.filter(c => c.type === "standard").length) },
|
||||
{ label: "Использует фильтры",value: String(new Set(listData.flatMap(c => c.filterIds)).size) },
|
||||
].map(({ label, value }) => (
|
||||
<Frame key={label} className="h-full">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка communities"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего communities",
|
||||
value: String(listData.length),
|
||||
icon: <TagIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Активных",
|
||||
value: String(listData.filter((c) => c.enabled).length),
|
||||
icon: <CheckIcon className="size-4" />,
|
||||
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">
|
||||
{/* ── main table ── */}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { routerContainers, servers } from "@/lib/data"
|
||||
import type { RouterContainer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -17,13 +17,14 @@ import {
|
||||
import {
|
||||
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
|
||||
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
|
||||
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
|
||||
CodeXmlIcon, ActivityIcon, ServerIcon,
|
||||
TerminalIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,57 +105,23 @@ function generateContainerRsc(c: RouterContainer): string {
|
||||
function ExportSheet({ open, container, onClose }: {
|
||||
open: boolean; container: RouterContainer | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт Container</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</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>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт Container"
|
||||
description="RouterOS 7.4+ · /container · /interface/veth"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
filename: `${container?.name ?? "container"}.rsc`,
|
||||
code,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,27 +286,40 @@ export default function ContainersPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
|
||||
].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>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка контейнеров"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего",
|
||||
value: routerContainers.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">
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { LatencyChart } from "@/components/dashboard/latency-chart"
|
||||
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
|
||||
import { InternetPathMapCard } from "@/components/dashboard/internet-path-map"
|
||||
@@ -47,49 +45,6 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label, value, unit, delta, deltaDir, spark, sparkColor, icon,
|
||||
}: {
|
||||
label: string; value: string; unit?: string; delta?: string
|
||||
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
|
||||
icon?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full flex-col overflow-hidden">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
{icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className="size-10.5 text-muted-foreground"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{label}</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl leading-none font-bold tabular-nums tracking-tight">{value}</span>
|
||||
{unit ? <span className="text-muted-foreground text-sm">{unit}</span> : null}
|
||||
</div>
|
||||
{delta ? (
|
||||
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-[var(--status-online-fg)]" : deltaDir === "down" ? "text-[var(--status-offline-fg)]" : "text-muted-foreground"}`}>
|
||||
{delta}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{spark && spark.length > 1 ? (
|
||||
<div className="absolute right-4 bottom-4 opacity-60">
|
||||
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
|
||||
</div>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtIntRu(n: number): string {
|
||||
return n.toLocaleString("ru-RU")
|
||||
}
|
||||
@@ -725,49 +680,53 @@ export default function DashboardPage() {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Серверы онлайн"
|
||||
value={dashboardKpi.servers.value}
|
||||
unit={dashboardKpi.servers.unit}
|
||||
delta={dashboardKpi.servers.delta}
|
||||
deltaDir={dashboardKpi.servers.deltaDir}
|
||||
spark={dashboardKpi.servers.spark}
|
||||
sparkColor={dashboardKpi.servers.sparkColor}
|
||||
icon={<ServerIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные фильтры"
|
||||
value={dashboardKpi.filters.value}
|
||||
unit={dashboardKpi.filters.unit}
|
||||
delta={dashboardKpi.filters.delta}
|
||||
deltaDir={dashboardKpi.filters.deltaDir}
|
||||
spark={dashboardKpi.filters.spark}
|
||||
sparkColor={dashboardKpi.filters.sparkColor}
|
||||
icon={<FilterIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="BGP-префиксы"
|
||||
value={dashboardKpi.bgp.value}
|
||||
unit={dashboardKpi.bgp.unit}
|
||||
delta={dashboardKpi.bgp.delta}
|
||||
deltaDir={dashboardKpi.bgp.deltaDir}
|
||||
spark={dashboardKpi.bgp.spark}
|
||||
sparkColor={dashboardKpi.bgp.sparkColor}
|
||||
icon={<GitMergeIcon aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Активные алерты"
|
||||
value={dashboardKpi.alerts.value}
|
||||
unit={dashboardKpi.alerts.unit}
|
||||
delta={dashboardKpi.alerts.delta}
|
||||
deltaDir={dashboardKpi.alerts.deltaDir}
|
||||
spark={dashboardKpi.alerts.spark}
|
||||
sparkColor={dashboardKpi.alerts.sparkColor}
|
||||
icon={<BellIcon aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка дашборда"
|
||||
items={[
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы онлайн",
|
||||
value: dashboardKpi.servers.unit
|
||||
? `${dashboardKpi.servers.value} ${dashboardKpi.servers.unit}`
|
||||
: dashboardKpi.servers.value,
|
||||
hint: dashboardKpi.servers.delta,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
variant: dashboardKpi.servers.deltaDir === "down" ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Активные фильтры",
|
||||
value: dashboardKpi.filters.unit
|
||||
? `${dashboardKpi.filters.value} ${dashboardKpi.filters.unit}`
|
||||
: dashboardKpi.filters.value,
|
||||
hint: dashboardKpi.filters.delta,
|
||||
icon: <FilterIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "bgp",
|
||||
label: "BGP-префиксы",
|
||||
value: dashboardKpi.bgp.unit
|
||||
? `${dashboardKpi.bgp.value} ${dashboardKpi.bgp.unit}`
|
||||
: dashboardKpi.bgp.value,
|
||||
hint: dashboardKpi.bgp.delta,
|
||||
icon: <GitMergeIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
{
|
||||
id: "alerts",
|
||||
label: "Активные алерты",
|
||||
value: dashboardKpi.alerts.unit
|
||||
? `${dashboardKpi.alerts.value} ${dashboardKpi.alerts.unit}`
|
||||
: dashboardKpi.alerts.value,
|
||||
hint: dashboardKpi.alerts.delta,
|
||||
icon: <BellIcon className="size-4" />,
|
||||
iconClassName: dashboardKpi.alerts.deltaDir === "down" ? "text-destructive" : "text-muted-foreground",
|
||||
variant: dashboardKpi.alerts.deltaDir === "down" ? "destructive" : "default",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Latency chart + Events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
|
||||
|
||||
@@ -4,10 +4,9 @@ import Link from "next/link"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
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 { 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import {
|
||||
@@ -104,7 +103,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "traffic") {
|
||||
const t = snap as TrafficRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{t.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
|
||||
) : null}
|
||||
@@ -126,7 +125,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_resources") {
|
||||
const u = snap as ResourcesRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{u.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
|
||||
) : null}
|
||||
@@ -148,7 +147,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "servers_rest_ping") {
|
||||
const s = snap as ServersRestPingRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{s.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
|
||||
) : null}
|
||||
@@ -171,7 +170,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_ping") {
|
||||
const p = snap as PingRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{p.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
|
||||
) : null}
|
||||
@@ -196,7 +195,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "uptime_speed") {
|
||||
const s = snap as SpeedScheduledRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||||
</p>
|
||||
@@ -209,7 +208,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "gre_bgp") {
|
||||
const g = snap as GreBgpSnapshotRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{g.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
|
||||
) : null}
|
||||
@@ -235,7 +234,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</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 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) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
@@ -249,7 +248,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "internet_path") {
|
||||
const p = snap as InternetPathRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Снимок internet-path на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
@@ -276,7 +275,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "certificates_renew") {
|
||||
const c = snap as CertificatesRenewRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{c.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||
) : null}
|
||||
@@ -299,7 +298,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{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) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
@@ -311,7 +310,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "backups") {
|
||||
const b = snap as BackupsRunSnapshot
|
||||
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">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||
@@ -331,7 +330,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
</dl>
|
||||
{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) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
@@ -343,7 +342,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Снимок на{" "}
|
||||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||||
@@ -370,7 +369,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
{a.errors?.length ? (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertCircleIcon />
|
||||
<AlertDescription className="space-y-1 text-xs">
|
||||
<AlertDescription className="flex flex-col gap-1 text-xs">
|
||||
{a.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">
|
||||
{e}
|
||||
@@ -380,7 +379,7 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</Alert>
|
||||
) : null}
|
||||
{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>
|
||||
<DataPageCard className="border-0 shadow-none bg-transparent">
|
||||
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
|
||||
@@ -398,39 +397,39 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
|
||||
const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||||
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">
|
||||
<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>
|
||||
<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 className="space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
|
||||
<dd className="font-mono text-xs">{r.jobKey}</dd>
|
||||
</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>
|
||||
<dd>
|
||||
<span className="font-medium">{jobTitle}</span>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
|
||||
</dd>
|
||||
</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>
|
||||
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
|
||||
</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>
|
||||
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
|
||||
</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>
|
||||
<dd className="tabular-nums">
|
||||
<span className="font-mono">{r.durationMs}</span> мс
|
||||
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
|
||||
</dd>
|
||||
</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>
|
||||
<dd>
|
||||
<Badge
|
||||
@@ -447,7 +446,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
</div>
|
||||
</dl>
|
||||
{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>
|
||||
<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"
|
||||
@@ -461,7 +460,7 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
|
||||
<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>
|
||||
{snapshot ? (
|
||||
<SnapshotTables snap={snapshot} />
|
||||
@@ -967,13 +966,13 @@ export default function DataCollectionPage() {
|
||||
sub: uptimeCollector?.scheduler?.jobs?.length
|
||||
? "По сохранённым задачам планировщика"
|
||||
: "По переключателям на этой странице",
|
||||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <CalendarClockIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Сейчас выполняется",
|
||||
value: String(runningJobsCount),
|
||||
sub: "Фоновые прогоны планировщика",
|
||||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
||||
icon: <LoaderCircleIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Трафик — последний сбор",
|
||||
@@ -982,16 +981,16 @@ export default function DataCollectionPage() {
|
||||
: "—",
|
||||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||||
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: "Журнал (в списке)",
|
||||
value: String(schedulerRuns.length),
|
||||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||||
icon: <DatabaseIcon className="size-4 text-sky-500" />,
|
||||
icon: <DatabaseIcon className="size-4" />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -1064,22 +1063,32 @@ export default function DataCollectionPage() {
|
||||
|
||||
{isLive && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.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="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
|
||||
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка сбора данных"
|
||||
items={stats.map((s, i) => ({
|
||||
id: `dc-${i}`,
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
hint: s.sub,
|
||||
icon: s.icon,
|
||||
iconClassName:
|
||||
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
|
||||
? "text-destructive"
|
||||
: s.label === "Сейчас выполняется" && runningJobsCount > 0
|
||||
? "text-warning"
|
||||
: s.label === "Журнал (в списке)" && errorRunsInView
|
||||
? "text-destructive"
|
||||
: 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>
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
@@ -1090,7 +1099,7 @@ export default function DataCollectionPage() {
|
||||
</div>
|
||||
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
|
||||
@@ -1142,7 +1151,7 @@ export default function DataCollectionPage() {
|
||||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||||
Сохранить настройки планировщика
|
||||
</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>
|
||||
Трафик — последний сбор:{" "}
|
||||
{trafficCollector?.lastCollectedAt
|
||||
|
||||
+153
-226
@@ -35,6 +35,9 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -754,13 +757,11 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
||||
tunnelsList: GreTunnel[]
|
||||
recRoutesByServer: Record<string, RecursiveRouteLite[]>
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const singleRuleset = useMemo(
|
||||
() => rulesets.filter(r => r.serverId === 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 config = useMemo(
|
||||
@@ -771,93 +772,25 @@ function PreviewModal({ open, serverId, rulesets, onClose, serversList, tunnelsL
|
||||
[open, singleRuleset, serversList, tunnelsList, recRoutesByServer],
|
||||
)
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(config).catch(() => {})
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<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]">
|
||||
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCodeIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">RouterOS config</span>
|
||||
{server && (
|
||||
<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} />
|
||||
<span className="font-mono">{server.name}</span>
|
||||
<span>· {totalRules} правил</span>
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="RouterOS config"
|
||||
description={
|
||||
server
|
||||
? `${server.name} · ${totalRules} правил`
|
||||
: "Экспорт правил фильтрации"
|
||||
}
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "RouterOS",
|
||||
filename: `filters-${server?.name ?? "server"}.rsc`,
|
||||
code: config,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1470,6 +1403,25 @@ export default function FiltersPage() {
|
||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||
|
||||
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(
|
||||
() => rulesets.find(r => r.serverId === selectedServerId)?.rules ?? [],
|
||||
[rulesets, selectedServerId],
|
||||
@@ -1635,142 +1587,117 @@ export default function FiltersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</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} правил`
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={filterRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Фильтры" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewOpen(true)}>
|
||||
<FileCodeIcon className="size-4" />RouterOS
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── main content ── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
<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">
|
||||
|
||||
{/* RouterOS 7.x BGP extensions — только демо из lib/data (моки) */}
|
||||
@@ -1886,7 +1813,7 @@ export default function FiltersPage() {
|
||||
</DataPageCard>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<RuleSheet
|
||||
key={`${sheetMode}-${editingId ?? "new"}-${selectedServerId}`}
|
||||
@@ -1927,6 +1854,6 @@ export default function FiltersPage() {
|
||||
recRoutesByServer={recRoutesByServer}
|
||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+922
-210
File diff suppressed because it is too large
Load Diff
+155
-149
@@ -14,7 +14,7 @@ import { requestJson } from "@/shared/api/http-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -26,14 +26,16 @@ import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Flag } from "@/components/flag"
|
||||
import {
|
||||
PlusIcon, RefreshCwIcon, MoreHorizontalIcon,
|
||||
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
|
||||
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon,
|
||||
DatabaseIcon,
|
||||
} 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,11 +242,11 @@ export default function GrePage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
const [search, setSearch] = useState("")
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
|
||||
const [tunnelOpen, setTunnelOpen] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||
@@ -293,6 +295,25 @@ export default function GrePage() {
|
||||
[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(
|
||||
() => Object.fromEntries(displayServers.map((s) => [s.id, s])),
|
||||
[displayServers],
|
||||
@@ -341,7 +362,7 @@ export default function GrePage() {
|
||||
}, [dataError])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return displayTunnels.filter((t) => {
|
||||
return scopedTunnels.filter((t) => {
|
||||
if (tabFilter === "up" && t.status !== "up") return false
|
||||
if (tabFilter === "ipsec" && !t.ipsec) return false
|
||||
if (tabFilter === "plain" && t.ipsec) return false
|
||||
@@ -354,60 +375,68 @@ export default function GrePage() {
|
||||
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 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 }[] = [
|
||||
{ value: "all", label: "Все", count: displayTunnels.length },
|
||||
{ value: "up", label: "Активные", count: upCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount },
|
||||
{ value: "all", label: "Все", count: scopedTunnels.length },
|
||||
{ value: "up", label: "Активные", count: scopedUpCount },
|
||||
{ value: "ipsec", label: "С IPsec", count: scopedIpsecCount },
|
||||
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||
]
|
||||
|
||||
function handleCopy(code: string) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
toast.success("Команды скопированы")
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
const greExportCode = useMemo(
|
||||
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||
[codePreviewTunnel, serverById],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={greRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && dataLoading && displayServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void loadLive() }}
|
||||
disabled={!isLive || dataLoading}
|
||||
title={!isLive ? "Включите Live и доступный бэкенд в настройках источника данных" : "Обновить список GRE с устройств"}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* Legacy banner */}
|
||||
@@ -424,27 +453,39 @@ export default function GrePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Всего туннелей", value: displayTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "IP-пулов", value: displayPools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
|
||||
].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>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка GRE"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Всего туннелей",
|
||||
value: displayTunnels.length,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Активно",
|
||||
value: upCount,
|
||||
icon: <ShieldCheckIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "ipsec",
|
||||
label: "Защищены IPsec",
|
||||
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 */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
@@ -548,85 +589,50 @@ export default function GrePage() {
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
|
||||
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
|
||||
{codePreviewTunnel && (() => {
|
||||
const code = generateRosCommands(codePreviewTunnel, serverById)
|
||||
return (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
|
||||
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => handleCopy(code)}
|
||||
>
|
||||
{copied
|
||||
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
|
||||
: <><CopyIcon className="size-3.5" /> Копировать</>}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* meta strip */}
|
||||
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress}</span>
|
||||
{codePreviewTunnel.ipsec && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<CodeExportSheet
|
||||
open={!!codePreviewTunnel}
|
||||
onClose={() => setCodePreviewTunnel(null)}
|
||||
title={codePreviewTunnel?.name ?? "GRE"}
|
||||
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "RouterOS",
|
||||
filename: `${codePreviewTunnel?.name ?? "gre"}.rsc`,
|
||||
code: greExportCode,
|
||||
},
|
||||
]}
|
||||
beforeCode={
|
||||
codePreviewTunnel ? (
|
||||
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
|
||||
{STATUS_MAP[codePreviewTunnel.status].label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">
|
||||
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||
{" → "}
|
||||
{codePreviewTunnel.remoteAddress}
|
||||
</span>
|
||||
{codePreviewTunnel.ipsec ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LockIcon className="size-3" />
|
||||
IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
|
||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||
@@ -839,6 +845,6 @@ export default function GrePage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-17
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { CommandPalette } from "@/components/command-palette"
|
||||
import { ReleaseNotesModal } from "@/components/release-notes-modal"
|
||||
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
|
||||
|
||||
export default function MainLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
<AuthGuard>
|
||||
<DataSourceProvider>
|
||||
<EvoBGPProvider>
|
||||
<SidebarProvider
|
||||
style={{ "--sidebar-width": "240px" } as CSSProperties}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content" className="h-svh overflow-hidden">
|
||||
{children}
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
<ReleaseNotesModal />
|
||||
</SidebarProvider>
|
||||
</EvoBGPProvider>
|
||||
</DataSourceProvider>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
|
||||
+160
-142
@@ -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 { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||||
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
|
||||
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
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"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
@@ -706,7 +710,7 @@ function InterfacesTab({
|
||||
}, [grouped, isLive])
|
||||
|
||||
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 out: Record<string, number> = {}
|
||||
const byRouter: Record<string, OspfItem[]> = {}
|
||||
@@ -733,13 +737,14 @@ function InterfacesTab({
|
||||
const ra = readStoredRouteOptimizerSettings()
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json() as BackendOspfOptimizeResponse
|
||||
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||
backendUrl,
|
||||
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
},
|
||||
)
|
||||
const byKey: Record<string, number> = {}
|
||||
data.interfaces.forEach((row) => {
|
||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||
@@ -919,20 +924,33 @@ function NeighborsTab({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Всего соседей", value: neighbors.length, color: "" },
|
||||
{ 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" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка соседей OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "neighbors",
|
||||
label: "Всего соседей",
|
||||
value: neighbors.length,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "full",
|
||||
label: "Full",
|
||||
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 && (
|
||||
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
|
||||
@@ -1042,21 +1060,41 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Сессий BFD", value: sessions.length, color: "" },
|
||||
{ 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" },
|
||||
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка BFD"
|
||||
items={[
|
||||
{
|
||||
id: "sessions",
|
||||
label: "Сессий BFD",
|
||||
value: sessions.length,
|
||||
icon: <ActivityIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "up",
|
||||
label: "Up",
|
||||
value: upCount,
|
||||
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 && (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
@@ -1094,7 +1132,7 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
|
||||
|
||||
export default function OspfPage() {
|
||||
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 isLive = mode === "live"
|
||||
@@ -1120,8 +1158,7 @@ export default function OspfPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
@@ -1217,9 +1254,26 @@ export default function OspfPage() {
|
||||
}, [items, neighbors, bfdSessions])
|
||||
|
||||
// ── filtered display data ─────────────────────────────────────────────────────
|
||||
const displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
|
||||
const displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
|
||||
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
|
||||
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.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)
|
||||
// KPIs reflect the current filter
|
||||
@@ -1228,91 +1282,45 @@ export default function OspfPage() {
|
||||
const totalAreas = new Set(displayItems.map(i => i.area)).size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<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>
|
||||
|
||||
{/* ── 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)}
|
||||
<ServerRailLayout
|
||||
items={ospfRailItems}
|
||||
selectedId={filterServerId}
|
||||
onSelect={setFilterServerId}
|
||||
showAll
|
||||
allCount={ospfServers.length}
|
||||
loading={isLive && loading && ospfServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<div className="border-b bg-background shrink-0">
|
||||
<div className="flex items-center px-6">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)}
|
||||
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",
|
||||
"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",
|
||||
)}>
|
||||
{s.country ? <Flag code={s.country} size={12} /> : null}
|
||||
{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>
|
||||
)}
|
||||
{t.icon}{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* data source banner */}
|
||||
@@ -1345,21 +1353,32 @@ export default function OspfPage() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Роутеров", value: totalRouters },
|
||||
{ label: "Интерфейсов", value: totalInterfaces },
|
||||
{ label: "Зон (Area)", value: totalAreas },
|
||||
].map(s => (
|
||||
<Frame key={s.label} className="h-full">
|
||||
<FramePanel className="flex 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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка OSPF"
|
||||
items={[
|
||||
{
|
||||
id: "routers",
|
||||
label: "Роутеров",
|
||||
value: totalRouters,
|
||||
icon: <RouterIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
label: "Интерфейсов",
|
||||
value: totalInterfaces,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "areas",
|
||||
label: "Зон (Area)",
|
||||
value: totalAreas,
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{activeTab === "interfaces" && (
|
||||
<InterfacesTab
|
||||
@@ -1383,7 +1402,6 @@ export default function OspfPage() {
|
||||
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+53
-32
@@ -17,6 +17,8 @@ import {
|
||||
} from "@/components/data-grids/probes-speed-probes-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
@@ -480,19 +482,25 @@ function ScheduleTab({
|
||||
setRules,
|
||||
serverOptions,
|
||||
tunnelsForServer,
|
||||
defaultSrc,
|
||||
}: {
|
||||
rules: SchedRule[]
|
||||
setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
|
||||
serverOptions: Server[]
|
||||
tunnelsForServer: (serverId: string) => GreTunnel[]
|
||||
defaultSrc?: string
|
||||
}) {
|
||||
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 [addType, setAddType] = useState<SchedType>("ping")
|
||||
const [addMin, setAddMin] = useState(10)
|
||||
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultSrc) setAddSrc(defaultSrc)
|
||||
}, [defaultSrc])
|
||||
|
||||
useEffect(() => {
|
||||
const list = tunnelsForServer(addSrc)
|
||||
if (list.length && !list.some(t => t.id === addTun)) {
|
||||
@@ -630,6 +638,20 @@ export default function ProbesPage() {
|
||||
return 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(() => {
|
||||
if (!isLive) {
|
||||
setRosSrcV4(undefined)
|
||||
@@ -887,24 +909,33 @@ export default function ProbesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
running.length > 0
|
||||
? <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 }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<ServerRailLayout
|
||||
items={probeRailItems}
|
||||
selectedId={srcId}
|
||||
onSelect={setSrcId}
|
||||
showAll={false}
|
||||
loading={isLive && liveLoad === "loading" && allServers.length === 0}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{running.length > 0
|
||||
? <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 }
|
||||
: t)))
|
||||
}}>
|
||||
<SquareIcon className="size-4" />Остановить все
|
||||
</Button>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{isLive && liveLoad === "error" && (
|
||||
@@ -916,7 +947,7 @@ export default function ProbesPage() {
|
||||
|
||||
{isLive && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -945,16 +976,6 @@ export default function ProbesPage() {
|
||||
{/* main config row */}
|
||||
<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 */}
|
||||
{tool !== "bandwidth" && (
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
@@ -1169,13 +1190,13 @@ export default function ProbesPage() {
|
||||
setRules={setRules}
|
||||
serverOptions={allServers}
|
||||
tunnelsForServer={sid => greTunnels.filter(t => t.serverId === sid)}
|
||||
defaultSrc={srcId}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
interface BackendServer {
|
||||
id: number
|
||||
@@ -543,6 +545,18 @@ export default function RecursiveRoutesPage() {
|
||||
}
|
||||
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
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 q = search.trim().toLowerCase()
|
||||
if (!q) return rows
|
||||
@@ -574,82 +588,64 @@ export default function RecursiveRoutesPage() {
|
||||
}, [filteredRows])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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">{rows.length}</span>
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border mx-1 shrink-0" />
|
||||
{servers.map((s) => {
|
||||
const count = s.id === selectedServerId ? rows.length : 0
|
||||
const active = selectedServerId === s.id
|
||||
return (
|
||||
<button key={s.id}
|
||||
onClick={() => setSelectedServerId(s.id)}
|
||||
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>
|
||||
|
||||
<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="Dst, gateway, table, comment…"
|
||||
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>
|
||||
<>
|
||||
<ServerRailLayout
|
||||
items={rrRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
loading={isLive && !liveServerListReady}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
banner={
|
||||
<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="Dst, gateway, table, comment…"
|
||||
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">{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>
|
||||
)}
|
||||
</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 ? (
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="p-6 text-sm text-muted-foreground">
|
||||
@@ -692,7 +688,7 @@ export default function RecursiveRoutesPage() {
|
||||
</button>
|
||||
</DataPageCard>
|
||||
)}
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
|
||||
<RouteSheet
|
||||
open={sheetOpen}
|
||||
@@ -702,6 +698,6 @@ export default function RecursiveRoutesPage() {
|
||||
onClose={() => setSheetOpen(false)}
|
||||
gateways={gatewayOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { FormToggle } from "@/components/form-kit"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -589,32 +589,25 @@ export default function RouteOptimizerPage() {
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||||
icon: <MonitorIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: (
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
icon: <ZapIcon className="size-4" />,
|
||||
},
|
||||
]
|
||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||
@@ -762,23 +755,24 @@ export default function RouteOptimizerPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{statsChips.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-xl leading-none font-bold tabular-nums">{s.value}</p>
|
||||
<p className="text-[10px] text-muted-foreground">{s.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка оптимизатора"
|
||||
items={statsChips.map((s, i) => ({
|
||||
id: `ro-${i}`,
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
hint: s.sub,
|
||||
icon: s.icon,
|
||||
iconClassName: s.label === "Переключений" && totalSwitches > 0
|
||||
? "text-warning"
|
||||
: s.label === "Exit Node"
|
||||
? "text-success"
|
||||
: s.label === "JumpHost"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground",
|
||||
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{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">
|
||||
|
||||
+34
-23
@@ -27,8 +27,7 @@ import {
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 flex-col gap-5">
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ 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)]" },
|
||||
{ label: "JH + Exit Node", value: counts["jump-host"] + counts["exit-node"], icon: <NetworkIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||
{ label: "Home Router", value: counts["home-router"],icon: <HomeIcon className="size-4" />, iconClass: "text-muted-foreground" },
|
||||
].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={cn("size-10.5", s.iconClass)}>
|
||||
{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>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка серверов"
|
||||
items={[
|
||||
{
|
||||
id: "all",
|
||||
label: "Всего серверов",
|
||||
value: counts.all,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "online",
|
||||
label: "Онлайн",
|
||||
value: counts.online,
|
||||
icon: <CheckCircleIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "jh-en",
|
||||
label: "JH + Exit Node",
|
||||
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 */}
|
||||
<DataPageCard>
|
||||
|
||||
@@ -16,6 +16,17 @@ import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { servers } from "@/lib/data"
|
||||
@@ -152,7 +163,7 @@ const PERM_OPTS: { v: PermLevel; label: string }[] = [
|
||||
const PERM_COLOR: Record<PermLevel, string> = {
|
||||
none: "bg-muted text-muted-foreground",
|
||||
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
||||
write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
write: "bg-success/10 text-success",
|
||||
}
|
||||
|
||||
const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const
|
||||
@@ -188,8 +199,8 @@ function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange:
|
||||
"px-2 text-[11px] font-medium transition-colors",
|
||||
i < PERM_OPTS.length - 1 && "border-r border-input",
|
||||
value === o.v
|
||||
? o.v === "write" ? "bg-emerald-600 text-white dark:bg-emerald-500"
|
||||
: o.v === "read" ? "bg-sky-600 text-white dark:bg-sky-500"
|
||||
? o.v === "write" ? "bg-success text-white"
|
||||
: o.v === "read" ? "bg-info text-white"
|
||||
: "bg-muted-foreground/70 text-white"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
@@ -685,68 +696,79 @@ function UserSheet({ open, user, onSave, onClose }: {
|
||||
// ─── delete confirm ───────────────────────────────────────────────────────────
|
||||
|
||||
function DatabaseRestoreConfirm({
|
||||
open,
|
||||
filename,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
filename: string
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
|
||||
<AlertCircleIcon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Восстановить базу приложения?</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 break-all">{filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Текущие данные SQLite на бекенде будут полностью заменены содержимым файла. Рекомендуется сначала скачать
|
||||
актуальный бэкап.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancel} disabled={busy}>Отмена</Button>
|
||||
<Button variant="destructive" className="flex-1" onClick={onConfirm} disabled={busy}>
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Восстановить базу приложения?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Файл: {filename}. Текущие данные SQLite на бекенде будут полностью заменены.
|
||||
Рекомендуется сначала скачать актуальный бэкап.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
{busy ? "Восстановление…" : "Восстановить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteConfirm({ user, onConfirm, onCancel }: { user: User; onConfirm: () => void; onCancel: () => void }) {
|
||||
function DeleteConfirm({
|
||||
open,
|
||||
user,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
user: User
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-card rounded-xl border shadow-2xl p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-9 rounded-full bg-destructive/10 flex items-center justify-center shrink-0">
|
||||
<TrashIcon className="size-4 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Удалить пользователя?</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{user.name} · @{user.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Это действие нельзя отменить. Пользователь потеряет доступ немедленно.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancel}>Отмена</Button>
|
||||
<Button variant="destructive" className="flex-1" onClick={onConfirm}>Удалить</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<TrashIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить пользователя?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{user.name} · @{user.login}. Это действие нельзя отменить.
|
||||
Пользователь потеряет доступ немедленно.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -875,8 +897,11 @@ export default function SettingsPage() {
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
markSaved()
|
||||
toast.success("Настройки EvoBGP сохранены")
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
||||
setEvoSaveErr(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
@@ -934,7 +959,7 @@ export default function SettingsPage() {
|
||||
|
||||
// ── Общие ──
|
||||
if (section === "Общие") return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Источник данных ── */}
|
||||
<OpsPanel
|
||||
@@ -957,9 +982,7 @@ export default function SettingsPage() {
|
||||
"px-3 text-xs transition-colors border-input",
|
||||
i === 0 && "border-r",
|
||||
mode === v
|
||||
? v === "live"
|
||||
? "bg-emerald-600 text-white dark:bg-emerald-500"
|
||||
: "bg-primary text-primary-foreground"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
{l}
|
||||
@@ -969,7 +992,7 @@ export default function SettingsPage() {
|
||||
</SettingRow>
|
||||
) : (
|
||||
<SettingRow label="Режим данных" description="В Docker-образе приложение работает только с живыми данными бекенда">
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-input bg-emerald-600 px-3 text-xs text-white dark:bg-emerald-500">
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-input bg-primary px-3 text-xs text-primary-foreground">
|
||||
Живые
|
||||
</span>
|
||||
</SettingRow>
|
||||
@@ -1170,7 +1193,7 @@ export default function SettingsPage() {
|
||||
|
||||
// ── EvoBGP ──
|
||||
if (section === "EvoBGP") return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
{(mode !== "live" || backendStatus !== true) && (
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="px-4 py-4">
|
||||
@@ -1354,7 +1377,7 @@ export default function SettingsPage() {
|
||||
|
||||
// ── Уведомления ──
|
||||
if (section === "Уведомления") return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<OpsPanel title="Каналы уведомлений" contentClassName="divide-y px-5">
|
||||
<SettingRow label="Email" description="Отправка уведомлений на admin@routerlists.io">
|
||||
<FormToggle checked={notifEmail} onChange={setNotifEmail} />
|
||||
@@ -1476,11 +1499,11 @@ export default function SettingsPage() {
|
||||
|
||||
// ── API-ключи ──
|
||||
if (section === "API-ключи") return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm"><PlusIcon className="size-4" />Создать ключ</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{apiKeys.map(k => (
|
||||
<Frame key={k.id} dense className="w-full">
|
||||
<FramePanel className="pt-4 pb-3 px-4">
|
||||
@@ -1543,7 +1566,7 @@ export default function SettingsPage() {
|
||||
|
||||
// ── Безопасность ──
|
||||
if (section === "Безопасность") return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<OpsPanel title="Аутентификация" contentClassName="divide-y px-5">
|
||||
<SettingRow label="Двухфакторная аутентификация (MFA)"
|
||||
description="TOTP / Authenticator app для всех администраторов">
|
||||
@@ -1644,6 +1667,7 @@ export default function SettingsPage() {
|
||||
{/* delete confirm */}
|
||||
{dbRestoreFile && (
|
||||
<DatabaseRestoreConfirm
|
||||
open={!!dbRestoreFile}
|
||||
filename={dbRestoreFile.name}
|
||||
busy={dbRestoreBusy}
|
||||
onConfirm={() => { void handleSystemDatabaseRestoreConfirm() }}
|
||||
@@ -1655,6 +1679,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirm
|
||||
open={!!deleteTarget}
|
||||
user={deleteTarget}
|
||||
onConfirm={() => { setUsers(p => p.filter(u => u.id !== deleteTarget.id)); setDeleteTarget(null) }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
|
||||
+121
-119
@@ -4,8 +4,22 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { 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 {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
@@ -259,12 +273,14 @@ function Terminal({
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const data = await requestJson<{ output?: string; error?: string }>(
|
||||
backendUrl,
|
||||
`/api/servers/${server.backendId}/exec`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
},
|
||||
)
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
@@ -427,7 +443,7 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
@@ -437,14 +453,13 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !prefsHydrated) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
@@ -462,7 +477,7 @@ export default function TerminalPage() {
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
@@ -490,6 +505,64 @@ export default function TerminalPage() {
|
||||
|
||||
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) {
|
||||
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
|
||||
if (!el) return
|
||||
@@ -500,108 +573,39 @@ export default function TerminalPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}>
|
||||
<RefreshCwIcon className="size-4" />Переподключить
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
|
||||
|
||||
{/* ── sidebar ── */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
{/* server picker */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
|
||||
{isLive && serversLoading && (
|
||||
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{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">
|
||||
<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 ── */}
|
||||
<ServerRailLayout
|
||||
items={railItems}
|
||||
selectedId={selected?.uid ?? selectedUid}
|
||||
onSelect={handleSelectServer}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
showType={false}
|
||||
headerRight={railHeaderRight}
|
||||
loading={serversLoading}
|
||||
extra={<QuickCmds />}
|
||||
contentClassName="overflow-hidden p-3 md:p-4"
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRefreshKey((k) => k + 1)
|
||||
if (isLive) setSelectedUid("")
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Переподключить
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
<Terminal
|
||||
key={termKey}
|
||||
@@ -610,12 +614,10 @@ export default function TerminalPage() {
|
||||
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 ? "Загрузка серверов…" : "Выберите сервер"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ServerRailLayout>
|
||||
)
|
||||
}
|
||||
|
||||
+128
-134
@@ -3,7 +3,8 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -32,16 +35,6 @@ function addSeries(a: number[], b: number[]): number[] {
|
||||
return a.map((v, i) => v + (b[i] ?? 0))
|
||||
}
|
||||
|
||||
function fmtMbps(v: number) {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
|
||||
return `${v} Мбит/с`
|
||||
}
|
||||
|
||||
function fmtGB(v: number) {
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
|
||||
return `${v.toFixed(1)} ГБ`
|
||||
}
|
||||
|
||||
// ─── data model ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface GreClientTraffic {
|
||||
@@ -318,63 +311,10 @@ function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; he
|
||||
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
|
||||
<path d={area(rx)} fill="hsl(var(--primary))" fillOpacity={0.12} />
|
||||
<polyline points={line(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="#3b82f6" fillOpacity={0.10} />
|
||||
<polyline points={line(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BigChart({ rx, tx }: { rx: number[]; tx: number[] }) {
|
||||
const W = 900, H = 220
|
||||
const pad = { l: 56, r: 16, t: 14, b: 32 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
const maxVal = Math.max(...rx, ...tx, 1) * 1.15
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
const area = (arr: number[]) => {
|
||||
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||||
return `M ${pad.l},${pad.t + ih} L ${pts} L ${pad.l + iw},${pad.t + ih} Z`
|
||||
}
|
||||
const poly = (arr: number[]) => arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const gridVals = [0, 0.25, 0.5, 0.75, 1]
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: H, display: "block" }}>
|
||||
<defs>
|
||||
<linearGradient id="rx-big" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.22" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tx-big" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#3b82f6" stopOpacity="0.18" />
|
||||
<stop offset="100%" stopColor="#3b82f6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{gridVals.map((p, i) => {
|
||||
const y = pad.t + ih * (1 - p)
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={pad.l} x2={W - pad.r} y1={y} y2={y}
|
||||
stroke="hsl(var(--border))" strokeDasharray={p === 0 ? "0" : "2 4"} />
|
||||
<text x={pad.l - 8} y={y + 4} textAnchor="end" fontSize="10"
|
||||
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
|
||||
{fmtMbps(Math.round(maxVal * p))}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<path d={area(rx)} fill="url(#rx-big)" />
|
||||
<polyline points={poly(rx)} fill="none" stroke="hsl(var(--primary))" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="url(#tx-big)" />
|
||||
<polyline points={poly(tx)} fill="none" stroke="#3b82f6" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
{[0, 12, 24, 36, 48, 59].map(i => (
|
||||
<text key={i} x={xAt(i, 60)} y={H - 8} textAnchor="middle" fontSize="10"
|
||||
fill="hsl(var(--muted-foreground))" fontFamily="monospace">
|
||||
-{60 - i}м
|
||||
</text>
|
||||
))}
|
||||
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
|
||||
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
|
||||
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -401,11 +341,11 @@ function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: bool
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(s.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(s.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(s.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
|
||||
</div>
|
||||
{s.greClients.length > 0 && (
|
||||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
@@ -445,11 +385,11 @@ function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean;
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(u.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(u.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(u.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(u.txNow)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -483,11 +423,11 @@ function GreCard({ c, selected, onClick }: { c: GreClientTraffic; selected: bool
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtMbps(c.rxNow)}</span>
|
||||
<span className="font-mono font-medium text-emerald-500">{fmtRate(c.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||||
<span className="font-mono font-medium text-blue-500">{fmtMbps(c.txNow)}</span>
|
||||
<span className="font-mono font-medium text-blue-500">{fmtRate(c.txNow)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -525,10 +465,10 @@ function GreClientRow({ c, showServer = false }: { c: GreClientTraffic; showServ
|
||||
{/* live RX / TX */}
|
||||
<div className="shrink-0 text-right leading-tight">
|
||||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<ArrowDownIcon className="size-3" />{fmtMbps(c.rxNow)}
|
||||
<ArrowDownIcon className="size-3" />{fmtRate(c.rxNow)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
|
||||
<ArrowUpIcon className="size-3" />{fmtMbps(c.txNow)}
|
||||
<ArrowUpIcon className="size-3" />{fmtRate(c.txNow)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -581,19 +521,6 @@ function DetailHeader({ range, setRange, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ChartLegend() {
|
||||
return (
|
||||
<div className="flex gap-5 mb-2">
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full bg-primary/70" />RX (входящий)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full bg-blue-500/70" />TX (исходящий)
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
|
||||
@@ -611,18 +538,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Получено сегодня</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||
</p>
|
||||
<Sparkline data={rxSeries} width={180} height={28} color="hsl(var(--primary))" filled />
|
||||
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Отправлено сегодня</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||||
</p>
|
||||
<Sparkline data={txSeries} width={180} height={28} color="#3b82f6" filled />
|
||||
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -630,7 +557,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||||
|
||||
// ─── detail panels ────────────────────────────────────────────────────────────
|
||||
|
||||
function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Range; setRange: (r: Range) => void }) {
|
||||
function ServerDetail({
|
||||
sel, range, setRange, liveRx, liveTx, liveHint,
|
||||
}: {
|
||||
sel: ServerTraffic
|
||||
range: Range
|
||||
setRange: (r: Range) => void
|
||||
liveRx?: number
|
||||
liveTx?: number
|
||||
liveHint?: string
|
||||
}) {
|
||||
const rxNow = liveRx ?? sel.rxNow
|
||||
const txNow = liveTx ?? sel.txNow
|
||||
return (
|
||||
<>
|
||||
<DetailHeader range={range} setRange={setRange}>
|
||||
@@ -640,13 +578,43 @@ function ServerDetail({ sel, range, setRange }: { sel: ServerTraffic; range: Ran
|
||||
<Flag code={sel.country} />{sel.site}
|
||||
</span>
|
||||
</DetailHeader>
|
||||
<ChartLegend />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingDownIcon className="size-3.5 text-purple-500" />} label="Сессий" value={`${sel.sessions}`} />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<KpiStatGrid
|
||||
aria-label="Скорость выбранного сервера"
|
||||
items={[
|
||||
{
|
||||
id: "rx-now",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(rxNow),
|
||||
hint: liveHint,
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx-now",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(txNow),
|
||||
hint: liveHint,
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "rx-peak",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(sel.rxPeak),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "tx-peak",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(sel.txPeak),
|
||||
icon: <TrendingDownIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
{sel.greClients.length > 0 && (
|
||||
@@ -680,12 +648,11 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
|
||||
<OfflinePlaceholder text="Нет GRE-клиентов у этого пользователя" />
|
||||
) : (
|
||||
<>
|
||||
<ChartLegend />
|
||||
<BigChart rx={sel.rxSeries} tx={sel.txSeries} />
|
||||
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||||
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="GRE-клиентов" value={`${sel.greClients.length}`} />
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
@@ -716,13 +683,12 @@ function GreDetail({ sel, range, setRange }: { sel: GreClientTraffic; range: Ran
|
||||
<Flag code={sel.serverCountry} />{sel.serverSite}
|
||||
</span>
|
||||
</DetailHeader>
|
||||
<ChartLegend />
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <BigChart rx={sel.rxSeries} tx={sel.txSeries} />}
|
||||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtMbps(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtMbps(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtMbps(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtMbps(sel.txPeak)} />
|
||||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
|
||||
</div>
|
||||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
@@ -778,6 +744,12 @@ export default function TrafficPage() {
|
||||
const [detailBusy, setDetailBusy] = useState(false)
|
||||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||||
const effectiveMode: GroupMode = isLive ? "servers" : groupMode
|
||||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||||
backendUrl,
|
||||
serverId: selectedId,
|
||||
iface: selectedIface,
|
||||
})
|
||||
|
||||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||||
return {
|
||||
@@ -917,6 +889,7 @@ export default function TrafficPage() {
|
||||
}, [sortField, sortDir, q])
|
||||
|
||||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||||
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
||||
const selUser = useMemo(() => userTraffic.find(u => u.id === selectedId) ?? userTraffic[0], [selectedId])
|
||||
const selGre = useMemo(() => greClients.find(c => c.id === selectedId) ?? greClients[0], [selectedId])
|
||||
|
||||
@@ -965,27 +938,39 @@ export default function TrafficPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* ── summary stat cards ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ icon: <ArrowDownIcon className="size-3.5 text-emerald-500" />, label: "RX сейчас", value: fmtMbps(totalRx) },
|
||||
{ icon: <ArrowUpIcon className="size-3.5 text-blue-500" />, label: "TX сейчас", value: fmtMbps(totalTx) },
|
||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик RX", value: fmtMbps(peakRx) },
|
||||
{ icon: <TrendingUpIcon className="size-3.5 text-amber-500" />, label: "Пик TX", value: fmtMbps(peakTx) },
|
||||
].map(({ icon, label, value }) => (
|
||||
<Frame key={label} className="h-full overflow-hidden">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 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 tracking-tight">{value}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
items={[
|
||||
{
|
||||
id: "rx",
|
||||
label: "RX сейчас",
|
||||
value: fmtRate(totalRx),
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "tx",
|
||||
label: "TX сейчас",
|
||||
value: fmtRate(totalTx),
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "peak-rx",
|
||||
label: "Пик RX",
|
||||
value: fmtRate(peakRx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "peak-tx",
|
||||
label: "Пик TX",
|
||||
value: fmtRate(peakTx),
|
||||
icon: <TrendingUpIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||||
|
||||
@@ -1091,7 +1076,16 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{effectiveMode === "servers" && (liveDetailServer ?? selServer) && <ServerDetail sel={(liveDetailServer ?? selServer)!} range={range} setRange={setRange} />}
|
||||
{effectiveMode === "servers" && detailServer && (
|
||||
<ServerDetail
|
||||
sel={detailServer}
|
||||
range={range}
|
||||
setRange={setRange}
|
||||
liveRx={liveSample?.rxMbps}
|
||||
liveTx={liveSample?.txMbps}
|
||||
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
|
||||
/>
|
||||
)}
|
||||
{effectiveMode === "users" && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||||
{effectiveMode === "gre" && <GreDetail sel={selGre} range={range} setRange={setRange} />}
|
||||
</FramePanel>
|
||||
|
||||
+89
-46
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
@@ -961,29 +961,58 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── KPI summary ───────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ 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) },
|
||||
{ icon: <HardDriveIcon className="size-4" />, label: `${avgRam}%`, sub: "средний RAM", color: resPctColor(avgRam) },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highCpu), sub: "CPU > 85%", color: highCpu > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highRam), sub: "RAM > 85%", color: highRam > 0 ? "text-red-500" : "text-muted-foreground" },
|
||||
{ icon: <AlertCircleIcon className="size-4" />, label: String(highHdd), sub: "Диск > 85%", color: highHdd > 0 ? "text-amber-500" : "text-muted-foreground" },
|
||||
].map(kpi => (
|
||||
<Frame key={kpi.sub} className="h-full">
|
||||
<FramePanel className="relative isolate flex h-full items-start gap-3">
|
||||
<IconTile variant="elevated" aria-hidden="true" className={cn("size-10.5", kpi.color)}>
|
||||
{kpi.icon}
|
||||
</IconTile>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<p className={cn("text-xl leading-none font-bold tabular-nums", kpi.color)}>{kpi.label}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{kpi.sub}</p>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка ресурсов"
|
||||
items={[
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверов всего",
|
||||
value: rows.length,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
label: "Средний CPU",
|
||||
value: `${avgCpu}%`,
|
||||
icon: <CpuIcon className="size-4" />,
|
||||
iconClassName: avgCpu >= 85 ? "text-destructive" : avgCpu >= 70 ? "text-warning" : "text-success",
|
||||
variant: avgCpu >= 85 ? "destructive" : avgCpu >= 70 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
label: "Средний RAM",
|
||||
value: `${avgRam}%`,
|
||||
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 ─────────────────────────────────────────────────────────── */}
|
||||
<DataPageCard>
|
||||
@@ -2022,28 +2051,42 @@ export default function UptimePage() {
|
||||
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null
|
||||
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||
{[
|
||||
{ label: "Speed-пробы", value: speedProbes.length, unit: "шт", color: "" },
|
||||
{ label: "Тестов выполнено", value: doneRuns.length, unit: "run", color: "" },
|
||||
{ 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)]" },
|
||||
].map(k => (
|
||||
<Frame key={k.label} className="h-full">
|
||||
<FramePanel className="flex flex-col gap-0.5">
|
||||
<p className="text-muted-foreground text-sm font-medium">{k.label}</p>
|
||||
<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>}
|
||||
</div>
|
||||
{runningCnt > 0 && k.label === "Тестов выполнено" && (
|
||||
<p className="text-[11px] text-[var(--status-degraded-fg)] flex items-center gap-1 mt-0.5">
|
||||
<RefreshCwIcon className="size-2.5 animate-spin" />{runningCnt} выполняется
|
||||
</p>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка speed-проб"
|
||||
items={[
|
||||
{
|
||||
id: "probes",
|
||||
label: "Speed-пробы",
|
||||
value: `${speedProbes.length} шт`,
|
||||
icon: <ArrowUpDownIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
label: "Тестов выполнено",
|
||||
value: `${doneRuns.length} run`,
|
||||
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
|
||||
icon: <PlayIcon className="size-4" />,
|
||||
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
|
||||
variant: runningCnt > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
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>
|
||||
)
|
||||
})()}
|
||||
|
||||
+50
-78
@@ -4,22 +4,16 @@ import { useMemo, useState } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { vxlanTunnels, servers } from "@/lib/data"
|
||||
import type { VxlanTunnel } from "@/lib/data"
|
||||
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 { DataPageCard } from "@/components/data-page-card"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
NetworkIcon, PlusIcon, CopyIcon, CheckIcon,
|
||||
CodeXmlIcon, LayersIcon,
|
||||
NetworkIcon, PlusIcon, CodeXmlIcon, LayersIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,57 +64,23 @@ function generateVxlanRsc(t: VxlanTunnel): string {
|
||||
function ExportSheet({ open, tunnel, onClose }: {
|
||||
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true); setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<SheetTitle>Экспорт VXLAN</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
|
||||
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
|
||||
</Button>
|
||||
</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>
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт VXLAN"
|
||||
description="RouterOS 7.x · /interface/vxlan + vteps"
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
filename: `${tunnel?.name ?? "vxlan"}.rsc`,
|
||||
code,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,27 +117,39 @@ export default function VxlanPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* KPI */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
|
||||
{ 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" /> },
|
||||
].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>
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка VXLAN"
|
||||
items={[
|
||||
{
|
||||
id: "tunnels",
|
||||
label: "Туннелей",
|
||||
value: vxlanTunnels.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(vxlanTunnels.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">
|
||||
|
||||
+774
-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>
|
||||
)
|
||||
}
|
||||
@@ -6,3 +6,10 @@ PORT=8000
|
||||
|
||||
# Allowed CORS origin (Next.js frontend)
|
||||
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
|
||||
|
||||
+19
-2
@@ -5,10 +5,23 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
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 packages/contracts/package.json packages/contracts/
|
||||
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
|
||||
|
||||
FROM deps AS build
|
||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& 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
|
||||
WORKDIR /app
|
||||
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
# 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 mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
@@ -21,7 +25,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
@@ -29,6 +33,8 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
+46
-2
@@ -3,17 +3,61 @@ import { z } from "zod"
|
||||
|
||||
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({
|
||||
DATABASE_PATH: z.string().default("./mikrotik.db"),
|
||||
PORT: z.coerce.number().int().positive().default(8000),
|
||||
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_PATH: process.env.DATABASE_PATH,
|
||||
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) {
|
||||
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
|
||||
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_PATH: parsed.data.DATABASE_PATH,
|
||||
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,
|
||||
}
|
||||
|
||||
+105
-62
@@ -1,11 +1,12 @@
|
||||
import Fastify from "fastify"
|
||||
import Fastify, { type FastifyInstance } from "fastify"
|
||||
import cors from "@fastify/cors"
|
||||
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
||||
import { env } from "./config.js"
|
||||
import authPlugin, { requireAuth } from "./plugins/auth.js"
|
||||
import serversRoutes from "./routes/servers.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import bgpRoutes from "./routes/bgp.js"
|
||||
import ospfRoutes from "./routes/ospf.js"
|
||||
import execRoutes from "./routes/exec.js"
|
||||
import filtersRoutes from "./routes/filters.js"
|
||||
import recursiveRoutes from "./routes/recursive-routes.js"
|
||||
import trafficRoutes from "./routes/traffic.js"
|
||||
@@ -22,73 +23,115 @@ import backupsRoutes from "./routes/backups.js"
|
||||
import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import wireguardRoutes from "./routes/wireguard.js"
|
||||
import firewallRoutes from "./routes/firewall.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
|
||||
// ── app factory ────────────────────────────────────────────────────────────────
|
||||
export async function buildApp(opts?: {
|
||||
logger?: boolean
|
||||
startScheduler?: boolean
|
||||
}): Promise<FastifyInstance> {
|
||||
const usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 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,
|
||||
})
|
||||
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger: {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
},
|
||||
})
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
// Use Zod for request validation and response serialization
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
|
||||
// CORS — allow Next.js frontend
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
})
|
||||
await app.register(authPlugin)
|
||||
|
||||
// ── routes ─────────────────────────────────────────────────────────────────────
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
}))
|
||||
|
||||
app.get("/health", async () => ({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.APP_VERSION ?? "dev",
|
||||
}))
|
||||
app.get("/api/auth/config", async () => ({
|
||||
required: env.authRequired,
|
||||
portal_url: env.authPortalUrl,
|
||||
issuer: env.authIssuer,
|
||||
}))
|
||||
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
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" })
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
refreshScheduler()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
})
|
||||
await app.register(serversRoutes, { prefix: "/api/servers" })
|
||||
await app.register(bgpRoutes, { prefix: "/api" })
|
||||
await app.register(ospfRoutes, { prefix: "/api" })
|
||||
await app.register(execRoutes, { prefix: "/api" })
|
||||
await app.register(filtersRoutes, { prefix: "/api" })
|
||||
await app.register(recursiveRoutes, { prefix: "/api" })
|
||||
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" })
|
||||
await app.register(wireguardRoutes, { prefix: "/api" })
|
||||
await app.register(firewallRoutes, { prefix: "/api" })
|
||||
|
||||
// ── start ──────────────────────────────────────────────────────────────────────
|
||||
if (opts?.startScheduler !== false) {
|
||||
refreshScheduler()
|
||||
app.addHook("onClose", async () => {
|
||||
stopScheduler()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
return app
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
const app = await buildApp()
|
||||
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,45 @@
|
||||
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/traffic/servers/1/live"),
|
||||
"mm:traffic:read",
|
||||
)
|
||||
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("POST", "/api/firewall/rules"),
|
||||
"mm:network:write",
|
||||
)
|
||||
|
||||
console.log("permissions.test.ts: ok")
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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
|
||||
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/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"),
|
||||
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/firewall"),
|
||||
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/firewall"),
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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.DATABASE_PATH = ":memory:"
|
||||
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: "a@b.c",
|
||||
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: "a@b.c",
|
||||
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" })
|
||||
@@ -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 {
|
||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||
domains: {
|
||||
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
const row = ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = row.apiKey.trim()
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
if (!root || !apiKey) return null
|
||||
return { root, apiKey }
|
||||
}
|
||||
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let nextEnabled = cur.enabled
|
||||
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.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)
|
||||
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const keyRaw =
|
||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||
const root = normalizeBaseUrl(urlRaw.trim())
|
||||
const token = keyRaw.trim()
|
||||
const token = normalizeApiKey(keyRaw)
|
||||
if (!root || !token) {
|
||||
return reply.status(400).send({
|
||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
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 { listFirewallAll } from "../services/firewall-live.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)
|
||||
}
|
||||
|
||||
function requireServer(serverId: string) {
|
||||
return getEnabledServerById(serverId)
|
||||
}
|
||||
|
||||
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 = 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))
|
||||
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 = 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))
|
||||
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 = 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" })
|
||||
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 = 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)
|
||||
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 = 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 } : {}),
|
||||
})
|
||||
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 = 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))
|
||||
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 = 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))
|
||||
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 = 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" })
|
||||
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 = 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)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default firewallRoutes
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -18,6 +19,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
certificatesTotal,
|
||||
wireguardTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
@@ -25,6 +27,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
countWireGuardInterfaces().catch(() => 0),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
@@ -35,6 +38,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||
recursiveRoutes: recursiveRoutesTotal,
|
||||
certificates: certificatesTotal,
|
||||
wireguard: wireguardTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
+215
-95
@@ -1,3 +1,4 @@
|
||||
import { env } from "../config.js"
|
||||
import { desc, eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { db } from "../db/index.js"
|
||||
@@ -12,6 +13,15 @@ import {
|
||||
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { getEnabledServerById } from "../services/wireguard-live.js"
|
||||
import {
|
||||
bpsToMbps,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
} from "../services/traffic-rate.js"
|
||||
|
||||
type SnapshotRow = typeof serverSnapshots.$inferSelect
|
||||
|
||||
@@ -40,6 +50,9 @@ interface TrafficInterfaceDto {
|
||||
txNow: number
|
||||
}
|
||||
|
||||
const LIVE_TICK_MS = 1500
|
||||
const LIVE_ROS_TIMEOUT_MS = 4000
|
||||
|
||||
function latestSnapshot(serverId: number): SnapshotRow | undefined {
|
||||
return db
|
||||
.select()
|
||||
@@ -61,14 +74,6 @@ function rangeToMinutes(range: string | undefined): number {
|
||||
}
|
||||
}
|
||||
|
||||
function toSeries(values: number[], target = 60): number[] {
|
||||
if (values.length === 0) return Array(target).fill(0)
|
||||
if (values.length === target) return values
|
||||
if (values.length > target) return values.slice(values.length - target)
|
||||
const head = Array(target - values.length).fill(values[0] ?? 0)
|
||||
return [...head, ...values]
|
||||
}
|
||||
|
||||
function buildServerTraffic(
|
||||
s: typeof servers.$inferSelect,
|
||||
status: TrafficServerDto["status"],
|
||||
@@ -82,88 +87,135 @@ function buildServerTraffic(
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
): TrafficServerDto {
|
||||
const filteredRows = onlyInterface
|
||||
? rows.filter((r) => r.interfaceName === onlyInterface)
|
||||
: rows
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotal: 0,
|
||||
txTotal: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array(60).fill(0),
|
||||
txSeries: Array(60).fill(0),
|
||||
}
|
||||
}
|
||||
|
||||
const bySampleTs = new Map<string, { rx: number; tx: number }>()
|
||||
const byIface = new Map<string, typeof filteredRows>()
|
||||
for (const r of filteredRows) {
|
||||
const ts = r.sampledAt
|
||||
const cur = bySampleTs.get(ts) ?? { rx: 0, tx: 0 }
|
||||
cur.rx += Math.max(0, r.rxBps) / 1_000_000
|
||||
cur.tx += Math.max(0, r.txBps) / 1_000_000
|
||||
bySampleTs.set(ts, cur)
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const seriesPoints = [...bySampleTs.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([, v]) => ({ rx: Math.round(v.rx), tx: Math.round(v.tx) }))
|
||||
const rxSeries = toSeries(seriesPoints.map((p) => p.rx))
|
||||
const txSeries = toSeries(seriesPoints.map((p) => p.tx))
|
||||
const rxNow = rxSeries[rxSeries.length - 1] ?? 0
|
||||
const txNow = txSeries[txSeries.length - 1] ?? 0
|
||||
const rxPeak = rxSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
const txPeak = txSeries.reduce((m, v) => Math.max(m, v), 0)
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
for (const arr of byIface.values()) {
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const first = sorted[0]
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (first && last) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
}
|
||||
}
|
||||
|
||||
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface)
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
site: s.site || "—",
|
||||
country: s.country || "UN",
|
||||
status,
|
||||
rxNow,
|
||||
txNow,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotal: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotal: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
rxNow: built.rxNow,
|
||||
txNow: built.txNow,
|
||||
rxPeak: built.rxPeak,
|
||||
txPeak: built.txPeak,
|
||||
rxTotal: built.rxTotalGiB,
|
||||
txTotal: built.txTotalGiB,
|
||||
sessions: built.sessions,
|
||||
rxSeries: built.rxSeries,
|
||||
txSeries: built.txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotStatus(serverId: number): TrafficServerDto["status"] {
|
||||
const snap = latestSnapshot(serverId)
|
||||
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
}
|
||||
|
||||
function ifaceNowMbps(
|
||||
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
|
||||
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
|
||||
): { rxNow: number; txNow: number } {
|
||||
if (!prev) {
|
||||
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
|
||||
}
|
||||
const t0 = Date.parse(prev.sampledAt)
|
||||
const t1 = Date.parse(last.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
|
||||
return {
|
||||
rxNow: bpsToMbps(rxBps ?? last.rxBps),
|
||||
txNow: bpsToMbps(txBps ?? last.txBps),
|
||||
}
|
||||
}
|
||||
|
||||
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
|
||||
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error("aborted"))
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error("aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function flattenMonitor(raw: unknown): unknown[] {
|
||||
if (Array.isArray(raw)) return raw
|
||||
if (raw != null) return [raw]
|
||||
return []
|
||||
}
|
||||
|
||||
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
|
||||
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
|
||||
"/interface",
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
)
|
||||
return ifaces
|
||||
.filter((i) => (i.running ?? "false") === "true"
|
||||
&& (i.disabled ?? "false") !== "true"
|
||||
&& !isLoopbackName(i.name ?? ""))
|
||||
.map((i) => i.name ?? "")
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function monitorTrafficOnce(
|
||||
client: MikrotikClient,
|
||||
onlyInterface: string | undefined,
|
||||
cache: { names: string[]; joinedFailed: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
if (onlyInterface) {
|
||||
return client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: onlyInterface, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
if (cache.names.length === 0) {
|
||||
cache.names = await listRunningIfaceNames(client)
|
||||
}
|
||||
if (cache.names.length === 0) return []
|
||||
if (!cache.joinedFailed) {
|
||||
try {
|
||||
return await client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: cache.names.join(","), once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
)
|
||||
} catch {
|
||||
cache.joinedFailed = true
|
||||
}
|
||||
}
|
||||
const chunks = await Promise.all(
|
||||
cache.names.map((name) =>
|
||||
client.post(
|
||||
"/interface/monitor-traffic",
|
||||
{ interface: name, once: "" },
|
||||
LIVE_ROS_TIMEOUT_MS,
|
||||
signal,
|
||||
).then(flattenMonitor).catch(() => [] as unknown[]),
|
||||
),
|
||||
)
|
||||
return chunks.flat()
|
||||
}
|
||||
|
||||
const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/settings", async (_req, reply) => {
|
||||
const settings = getTrafficSettings()
|
||||
@@ -243,20 +295,77 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/traffic/servers", async (req, reply) => {
|
||||
const q = req.query as { range?: string }
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const data = allServers.map((s): TrafficServerDto => {
|
||||
const snap = latestSnapshot(s.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
|
||||
const rows = readServerSamplesInRange(s.id, sinceIso)
|
||||
return buildServerTraffic(s, status, rows)
|
||||
return buildServerTraffic(s, snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
|
||||
})
|
||||
|
||||
return reply.send({ servers: data })
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/live", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const q = req.query as { iface?: string }
|
||||
const server = getEnabledServerById(p.id ?? "")
|
||||
if (!server || !server.enabled) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const onlyInterface = q.iface && q.iface !== "__all__" ? q.iface : undefined
|
||||
const abort = new AbortController()
|
||||
const onClose = () => abort.abort()
|
||||
req.raw.on("close", onClose)
|
||||
|
||||
reply.hijack()
|
||||
req.raw.setTimeout(0)
|
||||
reply.raw.setTimeout(0)
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : ""
|
||||
const allowed = env.CORS_ORIGIN
|
||||
const sseHeaders: Record<string, string> = {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
if (origin && (allowed === "*" || allowed === origin)) {
|
||||
sseHeaders["Access-Control-Allow-Origin"] = origin
|
||||
sseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||
sseHeaders["Access-Control-Allow-Headers"] = "Authorization, Accept"
|
||||
sseHeaders.Vary = "Origin"
|
||||
}
|
||||
reply.raw.writeHead(200, sseHeaders)
|
||||
reply.raw.write(":\n\n")
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const cache = { names: onlyInterface ? [onlyInterface] : [] as string[], joinedFailed: false }
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
|
||||
const sample = parseMonitorTraffic(raw, { onlyInterface })
|
||||
writeSse(reply.raw, "sample", sample)
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) break
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
writeSse(reply.raw, "error", { error: msg })
|
||||
}
|
||||
await sleep(LIVE_TICK_MS, abort.signal)
|
||||
}
|
||||
} catch {
|
||||
/* abort / disconnect */
|
||||
} finally {
|
||||
req.raw.off("close", onClose)
|
||||
try {
|
||||
reply.raw.end()
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/traffic/servers/:id/interfaces", async (req, reply) => {
|
||||
const p = req.params as { id?: string | number }
|
||||
const serverId = Number.parseInt(String(p.id ?? ""), 10)
|
||||
@@ -270,6 +379,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const rows = readServerSamplesInRange(serverId, sinceIso)
|
||||
const byIface = new Map<string, typeof rows>()
|
||||
for (const r of rows) {
|
||||
if (isLoopbackName(r.interfaceName)) continue
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
@@ -277,12 +387,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
|
||||
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
const prev = sorted[sorted.length - 2]
|
||||
if (!last) {
|
||||
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
|
||||
}
|
||||
const now = ifaceNowMbps(prev, last)
|
||||
return {
|
||||
name,
|
||||
running: Boolean(last?.running),
|
||||
disabled: Boolean(last?.disabled),
|
||||
rxNow: Math.round((last?.rxBps ?? 0) / 1_000_000),
|
||||
txNow: Math.round((last?.txBps ?? 0) / 1_000_000),
|
||||
running: Boolean(last.running),
|
||||
disabled: Boolean(last.disabled),
|
||||
rxNow: now.rxNow,
|
||||
txNow: now.txNow,
|
||||
}
|
||||
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
|
||||
|
||||
@@ -298,15 +413,20 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
|
||||
const minutes = rangeToMinutes(q.range)
|
||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
||||
const rangeEndMs = Date.now()
|
||||
const rangeStartMs = rangeEndMs - minutes * 60_000
|
||||
const sinceIso = new Date(rangeStartMs).toISOString()
|
||||
const rows = readServerSamplesInRange(server.id, sinceIso)
|
||||
const snap = latestSnapshot(server.id)
|
||||
const status: TrafficServerDto["status"] =
|
||||
snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
|
||||
const data = buildServerTraffic(server, status, rows, q.iface && q.iface !== "__all__" ? q.iface : undefined)
|
||||
const data = buildServerTraffic(
|
||||
server,
|
||||
snapshotStatus(server.id),
|
||||
rows,
|
||||
rangeStartMs,
|
||||
rangeEndMs,
|
||||
q.iface && q.iface !== "__all__" ? q.iface : undefined,
|
||||
)
|
||||
return reply.send({ server: data })
|
||||
})
|
||||
}
|
||||
|
||||
export default trafficRoutes
|
||||
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
wgCreateInterfaceSchema,
|
||||
wgCreatePeerRequestSchema,
|
||||
wgExportRequestSchema,
|
||||
wgImportRequestSchema,
|
||||
wgPatchInterfaceSchema,
|
||||
wgPatchPeerSchema,
|
||||
type WgCreatePeerRequest,
|
||||
type WgIfaceDto,
|
||||
} from "@mmapp/contracts/wireguard"
|
||||
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
||||
import {
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
generatePeerClientConf,
|
||||
parseWgConfig,
|
||||
type WgParsedConfig,
|
||||
} from "../services/wireguard-config.js"
|
||||
import {
|
||||
getEnabledServerById,
|
||||
listWireGuardInterfaces,
|
||||
} from "../services/wireguard-live.js"
|
||||
|
||||
function serverIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
function rosIdParam(v: string): string {
|
||||
return decodeURIComponent(v)
|
||||
}
|
||||
|
||||
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 peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
||||
return toRosBody({
|
||||
interface: p.interfaceName,
|
||||
"public-key": p.publicKey,
|
||||
"allowed-address": p.allowedAddresses.join(","),
|
||||
"endpoint-address": p.endpointAddress,
|
||||
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
|
||||
"persistent-keepalive":
|
||||
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
|
||||
"client-address": p.clientAddress,
|
||||
"client-dns": p.clientDns,
|
||||
"client-endpoint": p.clientEndpoint,
|
||||
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function previewFromParsed(parsed: WgParsedConfig) {
|
||||
return {
|
||||
format: parsed.format,
|
||||
interface: {
|
||||
name: parsed.interface.name,
|
||||
listenPort: parsed.interface.listenPort,
|
||||
mtu: parsed.interface.mtu,
|
||||
privateKey: parsed.interface.privateKey,
|
||||
comment: parsed.interface.comment,
|
||||
address: parsed.interface.address,
|
||||
disabled: parsed.interface.disabled,
|
||||
},
|
||||
peers: parsed.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedAddresses: p.allowedAddresses,
|
||||
endpointAddress: p.endpointAddress,
|
||||
endpointPort: p.endpointPort,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
privateKey: p.privateKey,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
disabled: p.disabled,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function applyParsedConfig(
|
||||
client: MikrotikClient,
|
||||
parsed: WgParsedConfig,
|
||||
): Promise<{ interfaceName: string; peersCreated: number }> {
|
||||
const name = parsed.interface.name
|
||||
const ifaceBody = toRosBody({
|
||||
name,
|
||||
"listen-port": String(parsed.interface.listenPort ?? 13231),
|
||||
mtu: String(parsed.interface.mtu ?? 1420),
|
||||
"private-key": parsed.interface.privateKey,
|
||||
comment: parsed.interface.comment,
|
||||
disabled: parsed.interface.disabled ? "yes" : undefined,
|
||||
})
|
||||
await client.put("/interface/wireguard", ifaceBody)
|
||||
|
||||
if (parsed.interface.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: parsed.interface.address,
|
||||
interface: name,
|
||||
})
|
||||
}
|
||||
|
||||
let peersCreated = 0
|
||||
for (const p of parsed.peers) {
|
||||
if (!p.publicKey) continue
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
peerToRosBody({
|
||||
interfaceName: name,
|
||||
publicKey: p.publicKey,
|
||||
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
|
||||
endpointAddress: p.endpointAddress,
|
||||
endpointPort: p.endpointPort,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
privateKey: p.privateKey,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
disabled: p.disabled,
|
||||
}),
|
||||
)
|
||||
peersCreated += 1
|
||||
}
|
||||
return { interfaceName: name, peersCreated }
|
||||
}
|
||||
|
||||
function findIface(
|
||||
list: WgIfaceDto[],
|
||||
serverId: string,
|
||||
interfaceName: string,
|
||||
): WgIfaceDto | undefined {
|
||||
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
|
||||
}
|
||||
|
||||
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/wireguard", async (req, reply) => {
|
||||
const q = req.query as { serverId?: string; includePrivateKey?: string }
|
||||
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
|
||||
const result = await listWireGuardInterfaces({
|
||||
serverId: q.serverId,
|
||||
includePrivateKey,
|
||||
})
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/wireguard/interfaces", async (req, reply) => {
|
||||
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put(
|
||||
"/interface/wireguard",
|
||||
toRosBody({
|
||||
name: body.name,
|
||||
"listen-port": String(body.listenPort),
|
||||
mtu: String(body.mtu),
|
||||
comment: body.comment,
|
||||
"private-key": body.privateKey,
|
||||
disabled: body.disabled ? "yes" : undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
if (body.address) {
|
||||
await client.put("/ip/address", {
|
||||
address: body.address,
|
||||
interface: body.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.peer) {
|
||||
await client.put(
|
||||
"/interface/wireguard/peers",
|
||||
peerToRosBody({ ...body.peer, interfaceName: body.name }),
|
||||
)
|
||||
}
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
serverId: String(server.id),
|
||||
includePrivateKey: true,
|
||||
})
|
||||
const created = list.interfaces.find((i) => i.name === body.name)
|
||||
return reply.status(201).send(created ?? { ok: true, name: body.name })
|
||||
} catch (e) {
|
||||
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const d = parsed.data
|
||||
try {
|
||||
await client.patch(
|
||||
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
|
||||
toRosBody({
|
||||
name: d.name,
|
||||
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
|
||||
mtu: d.mtu != null ? String(d.mtu) : undefined,
|
||||
comment: d.comment,
|
||||
"private-key": d.privateKey,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
}),
|
||||
)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/peers", async (req, reply) => {
|
||||
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.put("/interface/wireguard/peers", peerToRosBody(body))
|
||||
return reply.status(201).send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const d = parsed.data
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.patch(
|
||||
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
|
||||
toRosBody({
|
||||
"public-key": d.publicKey,
|
||||
"allowed-address": d.allowedAddresses?.join(","),
|
||||
"endpoint-address": d.endpointAddress,
|
||||
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
|
||||
"persistent-keepalive":
|
||||
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
|
||||
comment: d.comment,
|
||||
name: d.name,
|
||||
"client-address": d.clientAddress,
|
||||
"client-dns": d.clientDns,
|
||||
"client-endpoint": d.clientEndpoint,
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
}),
|
||||
)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
||||
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
||||
const server = getEnabledServerById(serverIdParam(serverId))
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/import", async (req, reply) => {
|
||||
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
let config: WgParsedConfig
|
||||
try {
|
||||
config = parseWgConfig(body.content, body.format)
|
||||
} catch (e) {
|
||||
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
|
||||
}
|
||||
const preview = previewFromParsed(config)
|
||||
if (body.dryRun) {
|
||||
return reply.send({ dryRun: true, preview })
|
||||
}
|
||||
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
try {
|
||||
const applied = await applyParsedConfig(client, config)
|
||||
return reply.send({ dryRun: false, preview, applied })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/wireguard/export", async (req, reply) => {
|
||||
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const body = parsed.data
|
||||
const server = getEnabledServerById(body.serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const list = await listWireGuardInterfaces({
|
||||
serverId: String(server.id),
|
||||
includePrivateKey: body.includePrivateKey === true,
|
||||
})
|
||||
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
|
||||
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
|
||||
|
||||
if (body.format === "rsc") {
|
||||
const content = generateMikrotikRsc({
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
})),
|
||||
})
|
||||
return reply.send({
|
||||
format: "rsc",
|
||||
filename: `${iface.name}.rsc`,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
if (body.format === "conf") {
|
||||
const content = generateNativeConf(
|
||||
{
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: iface.privateKey,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
})),
|
||||
},
|
||||
{ includePrivateKey: body.includePrivateKey === true },
|
||||
)
|
||||
return reply.send({
|
||||
format: "conf",
|
||||
filename: `${iface.name}.conf`,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
// peer-conf
|
||||
const peer = body.peerId
|
||||
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
|
||||
: iface.peers[0]
|
||||
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
|
||||
if (!iface.publicKey) {
|
||||
return reply.status(400).send({ error: "У интерфейса нет public-key" })
|
||||
}
|
||||
const endpoint =
|
||||
peer.clientEndpoint ||
|
||||
(peer.endpoint
|
||||
? peer.endpoint
|
||||
: undefined)
|
||||
const content = generatePeerClientConf({
|
||||
peerAddress: peer.clientAddress,
|
||||
peerDns: peer.clientDns,
|
||||
serverPublicKey: iface.publicKey,
|
||||
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
|
||||
endpoint:
|
||||
endpoint ||
|
||||
(peer.clientEndpoint
|
||||
? peer.clientEndpoint.includes(":")
|
||||
? peer.clientEndpoint
|
||||
: `${peer.clientEndpoint}:${iface.listenPort}`
|
||||
: undefined),
|
||||
persistentKeepalive: peer.persistentKeepalive ?? 25,
|
||||
})
|
||||
return reply.send({
|
||||
format: "peer-conf",
|
||||
filename: `${iface.name}-peer.conf`,
|
||||
content,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default wireguardRoutes
|
||||
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
|
||||
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||
const existingA = records.find((record) => record.type === "A")
|
||||
if (existingA) {
|
||||
if (existingA.content === ip) return
|
||||
if (existingA.content === ip) return "updated"
|
||||
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return
|
||||
return "updated"
|
||||
}
|
||||
|
||||
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
|
||||
if (records.some((record) => record.type === "CNAME")) {
|
||||
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
|
||||
return "skipped_cname"
|
||||
}
|
||||
|
||||
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
||||
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return "created"
|
||||
}
|
||||
|
||||
async function syncCertificateDomainRecords(
|
||||
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
|
||||
domains: string[],
|
||||
serverIp: string,
|
||||
defaultZoneId?: string,
|
||||
): Promise<void> {
|
||||
): Promise<{ skippedCname: string[] }> {
|
||||
const skippedCname: string[] = []
|
||||
for (const domain of domains) {
|
||||
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
||||
await upsertARecord(token, zoneId, domain, serverIp)
|
||||
const result = await upsertARecord(token, zoneId, domain, serverIp)
|
||||
if (result === "skipped_cname") skippedCname.push(domain)
|
||||
}
|
||||
return { skippedCname }
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
|
||||
const finalized = await client.finalizeOrder(order, csr)
|
||||
const certPem = await client.getCertificate(finalized)
|
||||
|
||||
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
|
||||
const clientRos = MikrotikClient.fromServer(params.server)
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
|
||||
try {
|
||||
params.onStep?.("dns_a_sync")
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
const { skippedCname } = await syncCertificateDomainRecords(
|
||||
token,
|
||||
domains,
|
||||
serverIp,
|
||||
settings.defaultZoneId,
|
||||
)
|
||||
if (skippedCname.length > 0) {
|
||||
params.onStep?.(
|
||||
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
|
||||
params.onStep?.(`dns_a_sync_warn:${msg}`)
|
||||
}
|
||||
|
||||
const trustStores = params.trustStore.filter(Boolean)
|
||||
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import {
|
||||
MikrotikClient,
|
||||
firewallRestPath,
|
||||
} from "./mikrotik.js"
|
||||
import type {
|
||||
FirewallFamily,
|
||||
FirewallTable,
|
||||
RosFirewallAddressList,
|
||||
RosFirewallFilter,
|
||||
} from "../types/server.js"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
export interface FirewallRuleDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
table: FirewallTable
|
||||
chain: string
|
||||
action: string
|
||||
proto: string
|
||||
src: string
|
||||
dst: string
|
||||
port: string
|
||||
iface: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
hits: number
|
||||
log: boolean
|
||||
logPrefix: string
|
||||
tlsHost?: string
|
||||
layer7Proto?: string
|
||||
}
|
||||
|
||||
export interface FirewallAddressListDto {
|
||||
id: string
|
||||
rosId: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
family: FirewallFamily
|
||||
list: string
|
||||
address: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
|
||||
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
|
||||
|
||||
function dash(v: string | undefined): string {
|
||||
const s = v?.trim() ?? ""
|
||||
return s.length > 0 ? s : "—"
|
||||
}
|
||||
|
||||
function rosDisabled(v: string | undefined): boolean {
|
||||
return v === "true" || v === "yes"
|
||||
}
|
||||
|
||||
function parseHits(raw: RosFirewallFilter): number {
|
||||
const n = Number.parseInt(raw.packets ?? "0", 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
export function ruleUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:${table}:${rosId}`
|
||||
}
|
||||
|
||||
export function addressUiId(
|
||||
serverId: string | number,
|
||||
family: FirewallFamily,
|
||||
rosId: string,
|
||||
): string {
|
||||
return `${serverId}:${family}:address-list:${rosId}`
|
||||
}
|
||||
|
||||
export function mapFirewallRule(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable,
|
||||
raw: RosFirewallFilter,
|
||||
idx: number,
|
||||
): FirewallRuleDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
const src = raw["src-address"] || raw["src-address-list"]
|
||||
const dst = raw["dst-address"] || raw["dst-address-list"]
|
||||
const port = raw["dst-port"] || raw["src-port"]
|
||||
const iface = raw["in-interface"] || raw["out-interface"]
|
||||
return {
|
||||
id: ruleUiId(server.id, family, table, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
table,
|
||||
chain: raw.chain || "",
|
||||
action: raw.action || "",
|
||||
proto: raw.protocol || "all",
|
||||
src: dash(src),
|
||||
dst: dash(dst),
|
||||
port: dash(port),
|
||||
iface: dash(iface),
|
||||
comment: raw.comment ?? "",
|
||||
enabled: !rosDisabled(raw.disabled),
|
||||
hits: parseHits(raw),
|
||||
log: raw.log === "true" || raw.log === "yes",
|
||||
logPrefix: raw["log-prefix"] ?? "",
|
||||
tlsHost: raw["tls-host"],
|
||||
layer7Proto: raw["layer7-protocol"],
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAddressList(
|
||||
server: ServerRow,
|
||||
family: FirewallFamily,
|
||||
raw: RosFirewallAddressList,
|
||||
idx: number,
|
||||
): FirewallAddressListDto {
|
||||
const rosId = raw[".id"] || `*${idx}`
|
||||
return {
|
||||
id: addressUiId(server.id, family, rosId),
|
||||
rosId,
|
||||
serverId: String(server.id),
|
||||
serverName: server.name || server.host,
|
||||
family,
|
||||
list: raw.list || "",
|
||||
address: raw.address || "",
|
||||
comment: raw.comment ?? "",
|
||||
disabled: rosDisabled(raw.disabled),
|
||||
timeout: raw.timeout,
|
||||
}
|
||||
}
|
||||
|
||||
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
|
||||
try {
|
||||
const rows = await fn()
|
||||
return Array.isArray(rows) ? rows : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchServerFirewall(server: ServerRow): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const ruleJobs = FAMILIES.flatMap((family) =>
|
||||
TABLES.map(async (table) => {
|
||||
const raw = await safeGet(() => client.getFirewallRules(family, table))
|
||||
return raw.map((row, idx) => mapFirewallRule(server, family, table, row, idx))
|
||||
}),
|
||||
)
|
||||
const listJobs = FAMILIES.map(async (family) => {
|
||||
const raw = await safeGet(() => client.getFirewallAddressList(family))
|
||||
return raw.map((row, idx) => mapAddressList(server, family, row, idx))
|
||||
})
|
||||
const [ruleChunks, listChunks] = await Promise.all([
|
||||
Promise.all(ruleJobs),
|
||||
Promise.all(listJobs),
|
||||
])
|
||||
return {
|
||||
rules: ruleChunks.flat(),
|
||||
addressLists: listChunks.flat(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFirewallAll(): Promise<{
|
||||
rules: FirewallRuleDto[]
|
||||
addressLists: FirewallAddressListDto[]
|
||||
}> {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const perServer = await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
return await fetchServerFirewall(server)
|
||||
} catch {
|
||||
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
return {
|
||||
rules: perServer.flatMap((r) => r.rules),
|
||||
addressLists: perServer.flatMap((r) => r.addressLists),
|
||||
}
|
||||
}
|
||||
|
||||
export { firewallRestPath, FAMILIES, TABLES }
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
RosBgpSession,
|
||||
RosOspfNeighbor, RosOspfArea, RosOspfInterfaceTemplate, RosOspfInstance,
|
||||
RosBfdSession,
|
||||
RosIpRoute, RosFirewallFilter, RosLogEntry, RosPingResult,
|
||||
RosIpRoute, RosFirewallFilter, RosFirewallAddressList, RosLogEntry, RosPingResult,
|
||||
FirewallFamily, FirewallTable,
|
||||
} from "../types/server.js"
|
||||
|
||||
// ── connection params ─────────────────────────────────────────────────────────
|
||||
@@ -338,6 +339,19 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
export function firewallRestPath(
|
||||
family: FirewallFamily,
|
||||
table: FirewallTable | "address-list",
|
||||
): string {
|
||||
const root = family === "ip6" ? "/ipv6/firewall" : "/ip/firewall"
|
||||
return `${root}/${table}`
|
||||
}
|
||||
|
||||
export function encodeRosId(rosId: string): string {
|
||||
const id = rosId.startsWith("*") ? rosId : `*${rosId.replace(/^\*/, "")}`
|
||||
return encodeURIComponent(id)
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
@@ -461,7 +475,15 @@ export class MikrotikClient {
|
||||
}
|
||||
|
||||
async getFirewallFilters(): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>("/ip/firewall/filter")
|
||||
return this.getFirewallRules("ip", "filter")
|
||||
}
|
||||
|
||||
async getFirewallRules(family: FirewallFamily, table: FirewallTable): Promise<RosFirewallFilter[]> {
|
||||
return this.get<RosFirewallFilter[]>(firewallRestPath(family, table))
|
||||
}
|
||||
|
||||
async getFirewallAddressList(family: FirewallFamily): Promise<RosFirewallAddressList[]> {
|
||||
return this.get<RosFirewallAddressList[]>(firewallRestPath(family, "address-list"))
|
||||
}
|
||||
|
||||
async getLogs(limit = 50): Promise<RosLogEntry[]> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { and, asc, eq, gte, lt } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, lt } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
|
||||
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import { bpsToMbps, rateBpsFromDelta, shouldIncludeIface } from "./traffic-rate.js"
|
||||
|
||||
interface RosIfaceTraffic {
|
||||
name?: string
|
||||
@@ -50,6 +51,31 @@ function cleanupOldSamples(retentionDays: number) {
|
||||
.run()
|
||||
}
|
||||
|
||||
function readPreviousWave(serverId: number): Map<string, { rxBytes: number; txBytes: number; sampledAt: string }> {
|
||||
const last = db
|
||||
.select({ sampledAt: trafficSamples.sampledAt })
|
||||
.from(trafficSamples)
|
||||
.where(eq(trafficSamples.serverId, serverId))
|
||||
.orderBy(desc(trafficSamples.sampledAt))
|
||||
.limit(1)
|
||||
.all()[0]
|
||||
if (!last) return new Map()
|
||||
const rows = db
|
||||
.select({
|
||||
interfaceName: trafficSamples.interfaceName,
|
||||
rxBytes: trafficSamples.rxBytes,
|
||||
txBytes: trafficSamples.txBytes,
|
||||
sampledAt: trafficSamples.sampledAt,
|
||||
})
|
||||
.from(trafficSamples)
|
||||
.where(and(
|
||||
eq(trafficSamples.serverId, serverId),
|
||||
eq(trafficSamples.sampledAt, last.sampledAt),
|
||||
))
|
||||
.all()
|
||||
return new Map(rows.map((r) => [r.interfaceName, r]))
|
||||
}
|
||||
|
||||
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
if (collecting) {
|
||||
@@ -80,26 +106,42 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(srv)
|
||||
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
|
||||
let sumRx = 0
|
||||
let sumTx = 0
|
||||
for (const i of ifaces) {
|
||||
sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000
|
||||
sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000
|
||||
}
|
||||
if (ifaces.length > 0) {
|
||||
db.insert(trafficSamples).values(
|
||||
ifaces.map((i) => ({
|
||||
serverId: srv.id,
|
||||
interfaceName: i.name ?? "unknown",
|
||||
sampledAt: now,
|
||||
rxBytes: toNum(i["rx-byte"]),
|
||||
txBytes: toNum(i["tx-byte"]),
|
||||
rxBps: toNum(i["rx-bits-per-second"]),
|
||||
txBps: toNum(i["tx-bits-per-second"]),
|
||||
running: (i.running ?? "false") === "true",
|
||||
disabled: (i.disabled ?? "false") === "true",
|
||||
})),
|
||||
).run()
|
||||
const prevWave = readPreviousWave(srv.id)
|
||||
const nowMs = Date.parse(now)
|
||||
let sumRxMbps = 0
|
||||
let sumTxMbps = 0
|
||||
const rows = ifaces.map((i) => {
|
||||
const interfaceName = i.name ?? "unknown"
|
||||
const rxBytes = toNum(i["rx-byte"])
|
||||
const txBytes = toNum(i["tx-byte"])
|
||||
const running = (i.running ?? "false") === "true"
|
||||
const disabled = (i.disabled ?? "false") === "true"
|
||||
const prev = prevWave.get(interfaceName)
|
||||
const prevMs = prev ? Date.parse(prev.sampledAt) : NaN
|
||||
const rxBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.rxBytes, rxBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
const txBps = prev && Number.isFinite(prevMs)
|
||||
? (rateBpsFromDelta(prev.txBytes, txBytes, prevMs, nowMs) ?? 0)
|
||||
: 0
|
||||
if (shouldIncludeIface(interfaceName, running, disabled)) {
|
||||
sumRxMbps += bpsToMbps(rxBps)
|
||||
sumTxMbps += bpsToMbps(txBps)
|
||||
}
|
||||
return {
|
||||
serverId: srv.id,
|
||||
interfaceName,
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
running,
|
||||
disabled,
|
||||
}
|
||||
})
|
||||
if (rows.length > 0) {
|
||||
db.insert(trafficSamples).values(rows).run()
|
||||
}
|
||||
snapshot.servers.push({
|
||||
serverId: srv.id,
|
||||
@@ -107,8 +149,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
host: srv.host,
|
||||
ok: true,
|
||||
interfaces: ifaces.length,
|
||||
sumRxMbps: Math.round(sumRx),
|
||||
sumTxMbps: Math.round(sumTx),
|
||||
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
|
||||
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
|
||||
})
|
||||
} catch (err) {
|
||||
snapshot.servers.push({
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
bpsToMbps,
|
||||
bucketAvg,
|
||||
buildTrafficFromSamples,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
rateBpsFromDelta,
|
||||
shouldIncludeIface,
|
||||
type TrafficSampleLike,
|
||||
} from "./traffic-rate.js"
|
||||
|
||||
assert.equal(isLoopbackName("lo"), true)
|
||||
assert.equal(isLoopbackName("loopback"), true)
|
||||
assert.equal(isLoopbackName("ether1"), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, false), true)
|
||||
assert.equal(shouldIncludeIface("ether1", false, false), false)
|
||||
assert.equal(shouldIncludeIface("ether1", true, true), false)
|
||||
assert.equal(shouldIncludeIface("lo", true, false, "lo"), true)
|
||||
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 0, 1000), 8000)
|
||||
assert.equal(rateBpsFromDelta(1000, 500, 0, 1000), null)
|
||||
assert.equal(rateBpsFromDelta(1000, 2000, 1000, 1000), null)
|
||||
assert.equal(bpsToMbps(1_500_000), 1.5)
|
||||
assert.equal(bpsToMbps(400_000), 0.4)
|
||||
|
||||
const buckets = bucketAvg(
|
||||
[
|
||||
{ t: 0, v: 10 },
|
||||
{ t: 1000, v: 20 },
|
||||
],
|
||||
0,
|
||||
1000,
|
||||
4,
|
||||
)
|
||||
assert.equal(buckets.length, 4)
|
||||
assert.equal(buckets[0], 10)
|
||||
assert.equal(buckets[3], 20)
|
||||
assert.equal(buckets[1], 0)
|
||||
assert.equal(buckets[2], 0)
|
||||
|
||||
const t0 = "2026-09-06T10:00:00.000Z"
|
||||
const t1 = "2026-09-06T10:00:30.000Z"
|
||||
const t2 = "2026-09-06T10:01:00.000Z"
|
||||
const start = Date.parse(t0)
|
||||
const end = Date.parse(t2)
|
||||
|
||||
const samples: TrafficSampleLike[] = [
|
||||
{ interfaceName: "ether1", sampledAt: t0, rxBytes: 1_000_000, txBytes: 500_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t1, rxBytes: 1_000_000 + 3_750_000, txBytes: 500_000 + 1_875_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "ether1", sampledAt: t2, rxBytes: 100, txBytes: 50, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t0, rxBytes: 0, txBytes: 0, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
{ interfaceName: "lo", sampledAt: t1, rxBytes: 9_000_000, txBytes: 9_000_000, rxBps: 0, txBps: 0, running: true, disabled: false },
|
||||
]
|
||||
|
||||
const built = buildTrafficFromSamples(samples, start, end)
|
||||
assert.ok(built.rxPeak > 0, "peak RX from delta")
|
||||
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
|
||||
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
|
||||
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
|
||||
|
||||
const live = parseMonitorTraffic([
|
||||
{ name: "ether1", "rx-bits-per-second": "2000000", "tx-bits-per-second": "500000" },
|
||||
{ name: "lo", "rx-bits-per-second": "8000000", "tx-bits-per-second": "8000000" },
|
||||
])
|
||||
assert.equal(live.rxMbps, 2)
|
||||
assert.equal(live.txMbps, 0.5)
|
||||
|
||||
const onceOnly = parseMonitorTraffic(
|
||||
{ name: "ether1", "rx-bits-per-second": "1000000", "tx-bits-per-second": "0" },
|
||||
{ onlyInterface: "ether1" },
|
||||
)
|
||||
assert.equal(onceOnly.rxMbps, 1)
|
||||
|
||||
console.log("traffic-rate tests ok")
|
||||
@@ -0,0 +1,221 @@
|
||||
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
|
||||
|
||||
export const SERIES_POINTS = 60
|
||||
|
||||
export interface TrafficSampleLike {
|
||||
interfaceName: string
|
||||
sampledAt: string
|
||||
rxBytes: number
|
||||
txBytes: number
|
||||
rxBps: number
|
||||
txBps: number
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export interface RatePoint {
|
||||
t: number
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
}
|
||||
|
||||
export interface BuiltTrafficSeries {
|
||||
rxNow: number
|
||||
txNow: number
|
||||
rxPeak: number
|
||||
txPeak: number
|
||||
rxTotalGiB: number
|
||||
txTotalGiB: number
|
||||
sessions: number
|
||||
rxSeries: number[]
|
||||
txSeries: number[]
|
||||
}
|
||||
|
||||
export interface MonitorLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
}
|
||||
|
||||
export function isLoopbackName(name: string): boolean {
|
||||
return /^(lo|loopback)(\d+)?$/i.test(name.trim())
|
||||
}
|
||||
|
||||
export function shouldIncludeIface(
|
||||
name: string,
|
||||
running: boolean,
|
||||
disabled: boolean,
|
||||
onlyInterface?: string,
|
||||
): boolean {
|
||||
if (onlyInterface) return name === onlyInterface
|
||||
if (isLoopbackName(name)) return false
|
||||
return running && !disabled
|
||||
}
|
||||
|
||||
export function bpsToMbps(bps: number): number {
|
||||
if (!Number.isFinite(bps) || bps <= 0) return 0
|
||||
return Math.round((bps / 1_000_000) * 1000) / 1000
|
||||
}
|
||||
|
||||
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
|
||||
export function rateBpsFromDelta(
|
||||
prevBytes: number,
|
||||
nextBytes: number,
|
||||
prevAtMs: number,
|
||||
nextAtMs: number,
|
||||
): number | null {
|
||||
const dtSec = (nextAtMs - prevAtMs) / 1000
|
||||
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
|
||||
if (nextBytes < prevBytes) return null
|
||||
return Math.round(((nextBytes - prevBytes) * 8) / dtSec)
|
||||
}
|
||||
|
||||
export function bucketAvg(
|
||||
points: Array<{ t: number; v: number }>,
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
target = SERIES_POINTS,
|
||||
): number[] {
|
||||
const buckets = Array.from({ length: target }, () => 0)
|
||||
const counts = Array.from({ length: target }, () => 0)
|
||||
const span = rangeEndMs - rangeStartMs
|
||||
if (span <= 0 || points.length === 0) return buckets
|
||||
for (const p of points) {
|
||||
const ratio = (p.t - rangeStartMs) / span
|
||||
const i = Math.min(target - 1, Math.max(0, Math.floor(ratio * target)))
|
||||
buckets[i] += p.v
|
||||
counts[i] += 1
|
||||
}
|
||||
return buckets.map((sum, i) => (counts[i] > 0 ? sum / counts[i] : 0))
|
||||
}
|
||||
|
||||
function parseIsoMs(iso: string): number {
|
||||
const t = Date.parse(iso)
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
|
||||
export function buildTrafficFromSamples(
|
||||
rows: TrafficSampleLike[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number,
|
||||
onlyInterface?: string,
|
||||
): BuiltTrafficSeries {
|
||||
const empty: BuiltTrafficSeries = {
|
||||
rxNow: 0,
|
||||
txNow: 0,
|
||||
rxPeak: 0,
|
||||
txPeak: 0,
|
||||
rxTotalGiB: 0,
|
||||
txTotalGiB: 0,
|
||||
sessions: 0,
|
||||
rxSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
txSeries: Array.from({ length: SERIES_POINTS }, () => 0),
|
||||
}
|
||||
if (rows.length === 0) return empty
|
||||
|
||||
const byIface = new Map<string, TrafficSampleLike[]>()
|
||||
for (const r of rows) {
|
||||
const arr = byIface.get(r.interfaceName) ?? []
|
||||
arr.push(r)
|
||||
byIface.set(r.interfaceName, arr)
|
||||
}
|
||||
|
||||
const rxPoints: Array<{ t: number; v: number }> = []
|
||||
const txPoints: Array<{ t: number; v: number }> = []
|
||||
const byTs = new Map<number, { rx: number; tx: number }>()
|
||||
|
||||
let rxBytesDelta = 0
|
||||
let txBytesDelta = 0
|
||||
let sessions = 0
|
||||
|
||||
for (const [name, arr] of byIface) {
|
||||
if (onlyInterface) {
|
||||
if (name !== onlyInterface) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
|
||||
const last = sorted[sorted.length - 1]
|
||||
if (!last) continue
|
||||
if (!onlyInterface && (!last.running || last.disabled)) continue
|
||||
|
||||
if (last.running && !last.disabled) sessions += 1
|
||||
|
||||
const first = sorted[0]
|
||||
if (first) {
|
||||
const dRx = last.rxBytes - first.rxBytes
|
||||
const dTx = last.txBytes - first.txBytes
|
||||
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
|
||||
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
|
||||
}
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const cur = sorted[i]
|
||||
if (!prev || !cur) continue
|
||||
if (!onlyInterface && (!cur.running || cur.disabled)) continue
|
||||
const t0 = parseIsoMs(prev.sampledAt)
|
||||
const t1 = parseIsoMs(cur.sampledAt)
|
||||
const rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
|
||||
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
|
||||
if (rxBps == null && txBps == null) continue
|
||||
const rxMbps = bpsToMbps(rxBps ?? 0)
|
||||
const txMbps = bpsToMbps(txBps ?? 0)
|
||||
const acc = byTs.get(t1) ?? { rx: 0, tx: 0 }
|
||||
acc.rx += rxMbps
|
||||
acc.tx += txMbps
|
||||
byTs.set(t1, acc)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [t, v] of byTs) {
|
||||
rxPoints.push({ t, v: v.rx })
|
||||
txPoints.push({ t, v: v.tx })
|
||||
}
|
||||
|
||||
const rxSeries = bucketAvg(rxPoints, rangeStartMs, rangeEndMs)
|
||||
const txSeries = bucketAvg(txPoints, rangeStartMs, rangeEndMs)
|
||||
const lastTs = [...byTs.keys()].sort((a, b) => a - b).at(-1)
|
||||
const last = lastTs != null ? byTs.get(lastTs) : undefined
|
||||
const rxPeak = rxPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
const txPeak = txPoints.reduce((m, p) => Math.max(m, p.v), 0)
|
||||
|
||||
return {
|
||||
rxNow: last?.rx ?? 0,
|
||||
txNow: last?.tx ?? 0,
|
||||
rxPeak,
|
||||
txPeak,
|
||||
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
|
||||
sessions,
|
||||
rxSeries,
|
||||
txSeries,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMonitorTraffic(
|
||||
raw: unknown,
|
||||
opts?: { onlyInterface?: string },
|
||||
): MonitorLiveSample {
|
||||
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
|
||||
let rxBps = 0
|
||||
let txBps = 0
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") continue
|
||||
const rec = item as Record<string, unknown>
|
||||
const name = String(rec.name ?? rec.interface ?? "")
|
||||
if (opts?.onlyInterface) {
|
||||
if (name && name !== opts.onlyInterface) continue
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
|
||||
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
|
||||
}
|
||||
return {
|
||||
rxMbps: bpsToMbps(rxBps),
|
||||
txMbps: bpsToMbps(txBps),
|
||||
at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
detectWgConfigFormat,
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
parseMikrotikRsc,
|
||||
parseNativeConf,
|
||||
parseWgConfig,
|
||||
} from "./wireguard-config.js"
|
||||
|
||||
const sampleConf = `[Interface]
|
||||
PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
|
||||
Address = 10.210.0.1/30
|
||||
ListenPort = 13231
|
||||
MTU = 1420
|
||||
|
||||
[Peer]
|
||||
PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
|
||||
AllowedIPs = 10.210.0.2/32, 192.168.20.0/24
|
||||
Endpoint = 10.0.1.1:13231
|
||||
PersistentKeepalive = 25
|
||||
`
|
||||
|
||||
const parsedConf = parseNativeConf(sampleConf)
|
||||
assert.equal(parsedConf.format, "conf")
|
||||
assert.equal(parsedConf.interface.listenPort, 13231)
|
||||
assert.equal(parsedConf.interface.address, "10.210.0.1/30")
|
||||
assert.equal(parsedConf.peers.length, 1)
|
||||
assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1")
|
||||
assert.equal(parsedConf.peers[0]?.endpointPort, 13231)
|
||||
assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"])
|
||||
|
||||
const roundConf = generateNativeConf({
|
||||
name: "wg0",
|
||||
listenPort: parsedConf.interface.listenPort ?? 13231,
|
||||
mtu: parsedConf.interface.mtu ?? 1420,
|
||||
privateKey: parsedConf.interface.privateKey,
|
||||
address: parsedConf.interface.address,
|
||||
peers: parsedConf.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedAddresses,
|
||||
endpoint: p.endpointAddress
|
||||
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
|
||||
: undefined,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
})),
|
||||
})
|
||||
const reparsed = parseNativeConf(roundConf)
|
||||
assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey)
|
||||
assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey)
|
||||
|
||||
const sampleRsc = `# WireGuard
|
||||
/interface wireguard add \\
|
||||
name=wg-msk-spb \\
|
||||
listen-port=13231 \\
|
||||
mtu=1420 \\
|
||||
comment="MSK → SPB"
|
||||
|
||||
/ip address add \\
|
||||
address=10.210.0.1/30 \\
|
||||
interface=wg-msk-spb
|
||||
|
||||
/interface wireguard peers add \\
|
||||
interface=wg-msk-spb \\
|
||||
public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\
|
||||
allowed-address=10.210.0.2/32,192.168.20.0/24 \\
|
||||
endpoint-address=10.0.1.1 \\
|
||||
endpoint-port=13231 \\
|
||||
persistent-keepalive=25
|
||||
`
|
||||
|
||||
assert.equal(detectWgConfigFormat(sampleRsc), "rsc")
|
||||
assert.equal(detectWgConfigFormat(sampleConf), "conf")
|
||||
|
||||
const parsedRsc = parseMikrotikRsc(sampleRsc)
|
||||
assert.equal(parsedRsc.interface.name, "wg-msk-spb")
|
||||
assert.equal(parsedRsc.interface.address, "10.210.0.1/30")
|
||||
assert.equal(parsedRsc.peers.length, 1)
|
||||
assert.equal(parsedRsc.peers[0]?.endpointPort, 13231)
|
||||
|
||||
const generatedRsc = generateMikrotikRsc({
|
||||
name: parsedRsc.interface.name,
|
||||
listenPort: parsedRsc.interface.listenPort ?? 13231,
|
||||
mtu: parsedRsc.interface.mtu ?? 1420,
|
||||
comment: parsedRsc.interface.comment,
|
||||
address: parsedRsc.interface.address,
|
||||
peers: parsedRsc.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedAddresses,
|
||||
endpoint: p.endpointAddress
|
||||
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
|
||||
: undefined,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
})),
|
||||
})
|
||||
const rscAgain = parseWgConfig(generatedRsc, "rsc")
|
||||
assert.equal(rscAgain.interface.name, "wg-msk-spb")
|
||||
assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey)
|
||||
|
||||
console.log("wireguard-config tests ok")
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* WireGuard config codecs: native .conf ↔ MikroTik .rsc
|
||||
*/
|
||||
|
||||
export type WgParsedPeer = {
|
||||
publicKey: string
|
||||
allowedAddresses: string[]
|
||||
endpointAddress?: string
|
||||
endpointPort?: number
|
||||
persistentKeepalive?: number
|
||||
comment?: string
|
||||
name?: string
|
||||
privateKey?: "auto" | "none" | string
|
||||
clientAddress?: string
|
||||
clientDns?: string
|
||||
clientEndpoint?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type WgParsedInterface = {
|
||||
name: string
|
||||
listenPort?: number
|
||||
mtu?: number
|
||||
privateKey?: string
|
||||
comment?: string
|
||||
address?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type WgParsedConfig = {
|
||||
format: "rsc" | "conf"
|
||||
interface: WgParsedInterface
|
||||
peers: WgParsedPeer[]
|
||||
}
|
||||
|
||||
export type WgExportIface = {
|
||||
name: string
|
||||
listenPort: number
|
||||
mtu: number
|
||||
comment?: string
|
||||
enabled?: boolean
|
||||
privateKey?: string
|
||||
publicKey?: string
|
||||
address?: string
|
||||
serverName?: string
|
||||
peers: Array<{
|
||||
publicKey: string
|
||||
allowedIps: string[]
|
||||
endpoint?: string
|
||||
persistentKeepalive?: number
|
||||
persistent?: boolean
|
||||
comment?: string
|
||||
name?: string
|
||||
clientAddress?: string
|
||||
clientDns?: string
|
||||
clientEndpoint?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function stripQuotes(v: string): string {
|
||||
const t = v.trim()
|
||||
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
||||
return t.slice(1, -1)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
function parseKvLine(line: string): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
// Match key=value pairs; values may be quoted
|
||||
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
out[m[1]] = stripQuotes(m[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function joinContinuedLines(text: string): string[] {
|
||||
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
|
||||
const lines: string[] = []
|
||||
let buf = ""
|
||||
for (const line of raw) {
|
||||
const trimmedEnd = line.replace(/\s+$/, "")
|
||||
if (trimmedEnd.endsWith("\\")) {
|
||||
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
|
||||
continue
|
||||
}
|
||||
buf += trimmedEnd
|
||||
if (buf.trim()) lines.push(buf.trim())
|
||||
buf = ""
|
||||
}
|
||||
if (buf.trim()) lines.push(buf.trim())
|
||||
return lines
|
||||
}
|
||||
|
||||
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
|
||||
const t = content.trim()
|
||||
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
|
||||
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
|
||||
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
|
||||
return "rsc"
|
||||
}
|
||||
|
||||
export function parseNativeConf(content: string): WgParsedConfig {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n")
|
||||
let section: "interface" | "peer" | null = null
|
||||
const iface: WgParsedInterface = { name: "wg0" }
|
||||
const peers: WgParsedPeer[] = []
|
||||
let currentPeer: WgParsedPeer | null = null
|
||||
|
||||
const flushPeer = () => {
|
||||
if (currentPeer?.publicKey) peers.push(currentPeer)
|
||||
currentPeer = null
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line || line.startsWith("#") || line.startsWith(";")) continue
|
||||
if (/^\[Interface\]$/i.test(line)) {
|
||||
flushPeer()
|
||||
section = "interface"
|
||||
continue
|
||||
}
|
||||
if (/^\[Peer\]$/i.test(line)) {
|
||||
flushPeer()
|
||||
section = "peer"
|
||||
currentPeer = { publicKey: "", allowedAddresses: [] }
|
||||
continue
|
||||
}
|
||||
const eq = line.indexOf("=")
|
||||
if (eq < 0) continue
|
||||
const key = line.slice(0, eq).trim().toLowerCase()
|
||||
const value = line.slice(eq + 1).trim()
|
||||
|
||||
if (section === "interface") {
|
||||
if (key === "privatekey") iface.privateKey = value
|
||||
else if (key === "address") iface.address = value.split(",")[0]?.trim()
|
||||
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
|
||||
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
|
||||
else if (key === "name") iface.name = value || iface.name
|
||||
} else if (section === "peer" && currentPeer) {
|
||||
if (key === "publickey") currentPeer.publicKey = value
|
||||
else if (key === "allowedips") {
|
||||
currentPeer.allowedAddresses = value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
} else if (key === "endpoint") {
|
||||
const lastColon = value.lastIndexOf(":")
|
||||
if (lastColon > 0 && !value.includes("]:")) {
|
||||
currentPeer.endpointAddress = value.slice(0, lastColon)
|
||||
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
|
||||
} else if (value.startsWith("[") && value.includes("]:")) {
|
||||
const idx = value.indexOf("]:")
|
||||
currentPeer.endpointAddress = value.slice(1, idx)
|
||||
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
|
||||
} else {
|
||||
currentPeer.endpointAddress = value
|
||||
}
|
||||
} else if (key === "persistentkeepalive") {
|
||||
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
|
||||
} else if (key === "presharedkey") {
|
||||
// ignore PSK for ROS import for now
|
||||
}
|
||||
}
|
||||
}
|
||||
flushPeer()
|
||||
|
||||
if (!iface.name) iface.name = "wg0"
|
||||
return { format: "conf", interface: iface, peers }
|
||||
}
|
||||
|
||||
export function parseMikrotikRsc(content: string): WgParsedConfig {
|
||||
const lines = joinContinuedLines(content)
|
||||
const iface: WgParsedInterface = { name: "wg0" }
|
||||
const peers: WgParsedPeer[] = []
|
||||
let foundIface = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#")) continue
|
||||
const lower = line.toLowerCase()
|
||||
|
||||
if (
|
||||
lower.startsWith("/interface wireguard add") ||
|
||||
lower.startsWith("/interface/wireguard add")
|
||||
) {
|
||||
const kv = parseKvLine(line)
|
||||
if (kv.name) iface.name = kv.name
|
||||
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
|
||||
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
|
||||
if (kv["private-key"]) iface.privateKey = kv["private-key"]
|
||||
if (kv.comment) iface.comment = kv.comment
|
||||
if (kv.disabled === "yes") iface.disabled = true
|
||||
foundIface = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
lower.startsWith("/interface wireguard peers add") ||
|
||||
lower.startsWith("/interface/wireguard/peers add")
|
||||
) {
|
||||
const kv = parseKvLine(line)
|
||||
const allowed = (kv["allowed-address"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
peers.push({
|
||||
publicKey: kv["public-key"] ?? "",
|
||||
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
|
||||
endpointAddress: kv["endpoint-address"],
|
||||
endpointPort: kv["endpoint-port"]
|
||||
? Number.parseInt(kv["endpoint-port"], 10) || undefined
|
||||
: undefined,
|
||||
persistentKeepalive: kv["persistent-keepalive"]
|
||||
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
|
||||
: undefined,
|
||||
comment: kv.comment,
|
||||
name: kv.name,
|
||||
clientAddress: kv["client-address"],
|
||||
clientDns: kv["client-dns"],
|
||||
clientEndpoint: kv["client-endpoint"],
|
||||
disabled: kv.disabled === "yes",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
|
||||
const kv = parseKvLine(line)
|
||||
if (kv.address) iface.address = kv.address
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundIface && peers.length === 0) {
|
||||
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
|
||||
}
|
||||
return { format: "rsc", interface: iface, peers }
|
||||
}
|
||||
|
||||
export function parseWgConfig(
|
||||
content: string,
|
||||
format: "auto" | "rsc" | "conf" = "auto",
|
||||
): WgParsedConfig {
|
||||
const detected = format === "auto" ? detectWgConfigFormat(content) : format
|
||||
if (detected === "conf") return parseNativeConf(content)
|
||||
return parseMikrotikRsc(content)
|
||||
}
|
||||
|
||||
export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`[Interface]`)
|
||||
if (opts?.includePrivateKey && iface.privateKey) {
|
||||
lines.push(`PrivateKey = ${iface.privateKey}`)
|
||||
} else if (iface.privateKey) {
|
||||
lines.push(`PrivateKey = ${iface.privateKey}`)
|
||||
} else {
|
||||
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
|
||||
}
|
||||
if (iface.address) lines.push(`Address = ${iface.address}`)
|
||||
lines.push(`ListenPort = ${iface.listenPort}`)
|
||||
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
|
||||
lines.push(``)
|
||||
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`[Peer]`)
|
||||
lines.push(`PublicKey = ${p.publicKey}`)
|
||||
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
|
||||
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
|
||||
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
|
||||
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
|
||||
if (p.comment) lines.push(`# ${p.comment}`)
|
||||
lines.push(``)
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n"
|
||||
}
|
||||
|
||||
export function generatePeerClientConf(args: {
|
||||
peerPrivateKey?: string
|
||||
peerAddress?: string
|
||||
peerDns?: string
|
||||
serverPublicKey: string
|
||||
allowedIps?: string[]
|
||||
endpoint?: string
|
||||
persistentKeepalive?: number
|
||||
}): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`[Interface]`)
|
||||
lines.push(
|
||||
args.peerPrivateKey
|
||||
? `PrivateKey = ${args.peerPrivateKey}`
|
||||
: `# PrivateKey = <ключ клиента>`,
|
||||
)
|
||||
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
|
||||
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
|
||||
lines.push(``)
|
||||
lines.push(`[Peer]`)
|
||||
lines.push(`PublicKey = ${args.serverPublicKey}`)
|
||||
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
|
||||
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
|
||||
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
|
||||
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
|
||||
}
|
||||
lines.push(``)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function generateMikrotikRsc(iface: WgExportIface): string {
|
||||
const lines: string[] = []
|
||||
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
|
||||
lines.push(`# RouterOS 7.x · MikrotikManager`)
|
||||
lines.push(``)
|
||||
lines.push(`/interface wireguard add \\`)
|
||||
lines.push(` name=${iface.name} \\`)
|
||||
lines.push(` listen-port=${iface.listenPort} \\`)
|
||||
lines.push(` mtu=${iface.mtu} \\`)
|
||||
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
|
||||
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
|
||||
if (iface.enabled === false) lines.push(` disabled=yes \\`)
|
||||
// remove trailing backslash on last iface param by rewriting last line
|
||||
if (lines[lines.length - 1]?.endsWith(" \\")) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
|
||||
}
|
||||
lines.push(``)
|
||||
|
||||
if (iface.address) {
|
||||
lines.push(`/ip address add \\`)
|
||||
lines.push(` address=${iface.address} \\`)
|
||||
lines.push(` interface=${iface.name}`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
for (const p of iface.peers) {
|
||||
lines.push(`/interface wireguard peers add \\`)
|
||||
lines.push(` interface=${iface.name} \\`)
|
||||
lines.push(` public-key="${p.publicKey}" \\`)
|
||||
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
|
||||
if (p.endpoint) {
|
||||
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
|
||||
const port = p.endpoint.includes(":")
|
||||
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
|
||||
: "13231"
|
||||
lines.push(` endpoint-address=${host} \\`)
|
||||
lines.push(` endpoint-port=${port} \\`)
|
||||
}
|
||||
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
|
||||
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
|
||||
if (p.name) lines.push(` name=${p.name} \\`)
|
||||
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
|
||||
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
|
||||
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
|
||||
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
|
||||
if (lines[lines.length - 1]?.endsWith(" \\")) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers } from "../db/schema.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
|
||||
|
||||
type ServerRow = typeof servers.$inferSelect
|
||||
|
||||
interface RosWireGuard {
|
||||
".id"?: string
|
||||
name?: string
|
||||
"listen-port"?: string
|
||||
mtu?: string
|
||||
"public-key"?: string
|
||||
"private-key"?: string
|
||||
running?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
interface RosWireGuardPeer {
|
||||
".id"?: string
|
||||
interface?: string
|
||||
name?: string
|
||||
"public-key"?: string
|
||||
"endpoint-address"?: string
|
||||
"endpoint-port"?: string
|
||||
"allowed-address"?: string
|
||||
"last-handshake"?: string
|
||||
rx?: string
|
||||
tx?: string
|
||||
disabled?: string
|
||||
comment?: string
|
||||
"persistent-keepalive"?: string
|
||||
"client-address"?: string
|
||||
"client-dns"?: string
|
||||
"client-endpoint"?: string
|
||||
}
|
||||
|
||||
interface RosIpAddress {
|
||||
".id"?: string
|
||||
address?: string
|
||||
interface?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
function parseBytes(v: string | undefined): number | undefined {
|
||||
if (v == null || v === "") return undefined
|
||||
const n = Number.parseInt(v, 10)
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
|
||||
const rosId = String(p[".id"] ?? `peer-${idx}`)
|
||||
const allowed = (p["allowed-address"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
const epAddr = (p["endpoint-address"] ?? "").trim()
|
||||
const epPort = (p["endpoint-port"] ?? "").trim()
|
||||
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
|
||||
const ka = p["persistent-keepalive"]
|
||||
? Number.parseInt(p["persistent-keepalive"], 10)
|
||||
: undefined
|
||||
return {
|
||||
id: rosId,
|
||||
rosId,
|
||||
publicKey: p["public-key"] ?? "",
|
||||
allowedIps: allowed,
|
||||
endpoint,
|
||||
latestHandshake: p["last-handshake"]?.trim() || undefined,
|
||||
transferRx: parseBytes(p.rx),
|
||||
transferTx: parseBytes(p.tx),
|
||||
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
|
||||
persistent: Number.isFinite(ka) && (ka as number) > 0,
|
||||
comment: p.comment ?? undefined,
|
||||
disabled: p.disabled === "true" || p.disabled === "yes",
|
||||
name: p.name,
|
||||
clientAddress: p["client-address"],
|
||||
clientDns: p["client-dns"],
|
||||
clientEndpoint: p["client-endpoint"],
|
||||
}
|
||||
}
|
||||
|
||||
function mapIface(
|
||||
server: ServerRow,
|
||||
w: RosWireGuard,
|
||||
peers: WgPeerDto[],
|
||||
address: string | undefined,
|
||||
includePrivateKey: boolean,
|
||||
): WgIfaceDto {
|
||||
const rosId = String(w[".id"] ?? w.name ?? "wg")
|
||||
const name = (w.name ?? "").trim() || rosId
|
||||
const disabled = w.disabled === "true" || w.disabled === "yes"
|
||||
const running = w.running === "true" || w.running === "yes"
|
||||
return {
|
||||
id: `${server.id}:${rosId}`,
|
||||
rosId,
|
||||
name,
|
||||
serverId: String(server.id),
|
||||
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
|
||||
serverCountry: server.country ?? undefined,
|
||||
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
|
||||
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
|
||||
publicKey: w["public-key"] || undefined,
|
||||
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
|
||||
address,
|
||||
peers,
|
||||
comment: w.comment ?? "",
|
||||
enabled: !disabled,
|
||||
status: disabled ? "down" : running ? "up" : "down",
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchForServer(
|
||||
server: ServerRow,
|
||||
includePrivateKey: boolean,
|
||||
): Promise<WgIfaceDto[]> {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
|
||||
client.get<RosWireGuard[]>("/interface/wireguard"),
|
||||
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
|
||||
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
|
||||
])
|
||||
|
||||
const peersByIface = new Map<string, WgPeerDto[]>()
|
||||
peersRaw.forEach((p, idx) => {
|
||||
const ifaceName = (p.interface ?? "").trim()
|
||||
if (!ifaceName) return
|
||||
const list = peersByIface.get(ifaceName) ?? []
|
||||
list.push(mapPeer(p, idx))
|
||||
peersByIface.set(ifaceName, list)
|
||||
})
|
||||
|
||||
const addrByIface = new Map<string, string>()
|
||||
for (const a of addrsRaw) {
|
||||
if (a.disabled === "true" || a.disabled === "yes") continue
|
||||
const iface = (a.interface ?? "").trim()
|
||||
const addr = (a.address ?? "").trim()
|
||||
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
|
||||
}
|
||||
|
||||
return ifacesRaw.map((w) => {
|
||||
const name = (w.name ?? "").trim()
|
||||
return mapIface(
|
||||
server,
|
||||
w,
|
||||
peersByIface.get(name) ?? [],
|
||||
addrByIface.get(name),
|
||||
includePrivateKey,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export type WgListResult = {
|
||||
interfaces: WgIfaceDto[]
|
||||
failures: Array<{ serverId: string; serverName?: string; error: string }>
|
||||
}
|
||||
|
||||
export async function listWireGuardInterfaces(opts?: {
|
||||
serverId?: string
|
||||
includePrivateKey?: boolean
|
||||
}): Promise<WgListResult> {
|
||||
const includePrivateKey = opts?.includePrivateKey === true
|
||||
let serverRows: ServerRow[]
|
||||
if (opts?.serverId) {
|
||||
const id = Number.parseInt(String(opts.serverId), 10)
|
||||
if (!Number.isFinite(id)) {
|
||||
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
|
||||
}
|
||||
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
|
||||
serverRows = row ? [row] : []
|
||||
} else {
|
||||
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
}
|
||||
|
||||
const failures: WgListResult["failures"] = []
|
||||
const results = await Promise.all(
|
||||
serverRows.map(async (server) => {
|
||||
try {
|
||||
return await fetchForServer(server, includePrivateKey)
|
||||
} catch (e) {
|
||||
failures.push({
|
||||
serverId: String(server.id),
|
||||
serverName: server.name ?? undefined,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
return [] as WgIfaceDto[]
|
||||
}
|
||||
}),
|
||||
)
|
||||
return { interfaces: results.flat(), failures }
|
||||
}
|
||||
|
||||
export async function countWireGuardInterfaces(): Promise<number> {
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
listWireGuardInterfaces({ includePrivateKey: false }),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
|
||||
])
|
||||
if (!result) return 0
|
||||
return result.interfaces.length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnabledServerById(serverId: string | number): ServerRow | null {
|
||||
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
|
||||
if (!Number.isFinite(id)) return null
|
||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||
}
|
||||
|
||||
export { type RosWireGuard, type RosWireGuardPeer }
|
||||
@@ -345,8 +345,24 @@ export interface RosFirewallFilter {
|
||||
"dynamic"?: string
|
||||
"bytes"?: string
|
||||
"packets"?: string
|
||||
"log"?: string
|
||||
"log-prefix"?: string
|
||||
}
|
||||
|
||||
export interface RosFirewallAddressList {
|
||||
".id": string
|
||||
list: string
|
||||
address: string
|
||||
comment?: string
|
||||
disabled?: string
|
||||
timeout?: string
|
||||
dynamic?: string
|
||||
"creation-time"?: string
|
||||
}
|
||||
|
||||
export type FirewallFamily = "ip" | "ip6"
|
||||
export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
|
||||
|
||||
export interface RosLogEntry {
|
||||
".id": string
|
||||
"time": string
|
||||
|
||||
+14
-21
@@ -38,6 +38,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
formatSidebarBadgeCount,
|
||||
mockSidebarBadgesByUrl,
|
||||
@@ -101,10 +102,10 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== "live") {
|
||||
setLiveCounts(null)
|
||||
if (!prefsHydrated || mode !== "live") {
|
||||
if (mode !== "live") setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const base = backendUrl.replace(/\/$/, "")
|
||||
const [cRes, gRes] = await Promise.all([
|
||||
fetch(`${base}/api/sidebar-counts`),
|
||||
fetch(`${base}/api/filters/gre-tunnels`),
|
||||
const [cJson, gJson] = await Promise.all([
|
||||
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
|
||||
() => ({ tunnels: [] as unknown[] }),
|
||||
),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (!cRes.ok) {
|
||||
setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
const cJson = (await cRes.json()) as SidebarCountsDto
|
||||
let greN = 0
|
||||
if (gRes.ok) {
|
||||
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
|
||||
greN = (gJson.tunnels ?? []).length
|
||||
}
|
||||
setLiveCounts({ ...cJson, greTunnels: greN })
|
||||
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
|
||||
} catch {
|
||||
if (!cancelled) setLiveCounts(null)
|
||||
}
|
||||
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendUrl])
|
||||
}, [mode, backendUrl, prefsHydrated])
|
||||
|
||||
const navGroups = React.useMemo((): NavGroup[] => {
|
||||
function badgeFor(url: string): string | undefined {
|
||||
@@ -173,8 +165,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
|
||||
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
|
||||
|
||||
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
|
||||
if (url === "/containers" || url === "/bgp") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
getClaims,
|
||||
getToken,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
if (!isAuthEnabled()) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
const claims = getClaims()
|
||||
if (!getToken() || !claims) {
|
||||
const ok = redirectToPortalLogin()
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes("mm")) {
|
||||
window.location.assign("/access-denied")
|
||||
return
|
||||
}
|
||||
setReady(true)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||
Проверка сессии…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@tanstack/react-table"
|
||||
import type { SchedulerJobStatusDto } from "@/lib/scheduler-settings"
|
||||
import { FormToggle } from "@/components/form-kit"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
|
||||
@@ -23,8 +23,11 @@ import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DATA_GRID_CONTAINER_CLASS,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { DataGridContainer, DataGridTableDndRowHandle, DataGridTableDndRows } from "@/components/reui/data-grid"
|
||||
import type { DragEndEvent } from "@dnd-kit/core"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
CopyIcon,
|
||||
@@ -89,16 +92,41 @@ interface FirewallRulesDataGridProps {
|
||||
rules: FirewallRule[]
|
||||
onToggle: (id: string) => void
|
||||
onEdit: (rule: FirewallRule) => void
|
||||
onDelete?: (rule: FirewallRule) => void
|
||||
onReorder?: (activeId: string, overId: string) => void
|
||||
showServer?: boolean
|
||||
}
|
||||
|
||||
function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGridProps) {
|
||||
function FirewallRulesDataGrid({
|
||||
rules,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReorder,
|
||||
showServer = false,
|
||||
}: FirewallRulesDataGridProps) {
|
||||
const indexedRules = useMemo(
|
||||
() => rules.map((rule, index) => ({ ...rule, _index: index + 1 })),
|
||||
[rules],
|
||||
)
|
||||
const reorderable = Boolean(onReorder)
|
||||
const indexPad = reorderable ? DATA_GRID_CELL_PAD : DATA_GRID_CELL_PAD_FIRST
|
||||
|
||||
const columns = useMemo<ColumnDef<FirewallRule & { _index: number }>[]>(
|
||||
() => [
|
||||
...(reorderable
|
||||
? [{
|
||||
id: "drag",
|
||||
header: () => <span className="sr-only">Порядок</span>,
|
||||
enableSorting: false,
|
||||
cell: () => <DataGridTableDndRowHandle />,
|
||||
size: 40,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "index",
|
||||
accessorKey: "_index",
|
||||
@@ -114,10 +142,23 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
),
|
||||
size: 48,
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
headerClassName: indexPad,
|
||||
cellClassName: indexPad,
|
||||
},
|
||||
},
|
||||
...(showServer
|
||||
? [{
|
||||
id: "server",
|
||||
accessorFn: (row: FirewallRule & { _index: number }) => row.serverName ?? row.serverId ?? "",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||
{row.original.serverName || row.original.serverId || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: "Сервер", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
} satisfies ColumnDef<FirewallRule & { _index: number }>]
|
||||
: []),
|
||||
{
|
||||
id: "chain",
|
||||
accessorKey: "chain",
|
||||
@@ -261,7 +302,7 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
{r.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete?.(r)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить правило
|
||||
</DropdownMenuItem>
|
||||
@@ -274,17 +315,24 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[onEdit, onToggle],
|
||||
[onEdit, onToggle, onDelete, showServer, reorderable, indexPad],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: indexedRules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
...(reorderable ? {} : { getSortedRowModel: getSortedRowModel() }),
|
||||
enableSorting: !reorderable,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id || !onReorder) return
|
||||
onReorder(String(active.id), String(over.id))
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -304,7 +352,16 @@ function FirewallRulesDataGrid({ rules, onToggle, onEdit }: FirewallRulesDataGri
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row", "[&:has([data-rule-disabled=true])]:opacity-40"),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{reorderable ? (
|
||||
<DataGridContainer border={false} className={DATA_GRID_CONTAINER_CLASS}>
|
||||
<DataGridTableDndRows
|
||||
dataIds={indexedRules.map((r) => r.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DataGridContainer>
|
||||
) : undefined}
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CompactDataGrid,
|
||||
@@ -35,11 +35,11 @@ function SnapshotOkBadge({
|
||||
errLabel?: string
|
||||
}) {
|
||||
return ok ? (
|
||||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||||
<Badge variant="success-outline" size="sm" className="text-[10px]">
|
||||
{okLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||||
<Badge variant="destructive-outline" size="sm" className="text-[10px]">
|
||||
{errLabel}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { WireGuardInterface } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
ChevronRightIcon,
|
||||
CodeXmlIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PowerIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -48,17 +48,38 @@ export interface WgIfaceWithServer extends WireGuardInterface {
|
||||
|
||||
interface WireguardDataGridProps {
|
||||
interfaces: WgIfaceWithServer[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onExport: (iface: WgIfaceWithServer) => void
|
||||
onAddPeer?: (iface: WgIfaceWithServer) => void
|
||||
onToggle?: (iface: WgIfaceWithServer) => void
|
||||
onDelete?: (iface: WgIfaceWithServer) => void
|
||||
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
|
||||
}
|
||||
|
||||
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
function WireguardDataGrid({
|
||||
interfaces,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
onExport,
|
||||
onAddPeer,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Интерфейс / Сервер" className="ml-1" />
|
||||
<DataGridSortHeader
|
||||
column={column}
|
||||
title={compactServer ? "Интерфейс" : "Интерфейс / Сервер"}
|
||||
className="ml-1"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const iface = row.original
|
||||
@@ -76,15 +97,17 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
<span
|
||||
className={cn(
|
||||
"size-2 rounded-full shrink-0",
|
||||
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
|
||||
iface.status === "up" ? "bg-success animate-pulse" : "bg-destructive",
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono font-semibold text-sm">{iface.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
{!compactServer ? (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
|
||||
<Flag code={iface.serverCountry || "UN"} size={12} />
|
||||
{iface.serverName}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="sr-only">
|
||||
{onlinePeers}/{iface.peers.length} пиров
|
||||
</p>
|
||||
@@ -93,11 +116,15 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Интерфейс / Сервер",
|
||||
headerTitle: compactServer ? "Интерфейс" : "Интерфейс / Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
expandedContent: (row: WgIfaceWithServer) => (
|
||||
<WireGuardPeersDetail peers={row.peers} />
|
||||
<WireGuardPeersDetail
|
||||
peers={row.peers}
|
||||
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
|
||||
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -136,7 +163,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
|
||||
return (
|
||||
<span className="font-mono text-sm text-center block">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
|
||||
<span className="text-success">{onlinePeers}</span>
|
||||
<span className="text-muted-foreground">/{iface.peers.length}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -152,16 +179,13 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-mono px-2 py-0.5 rounded border",
|
||||
row.original.status === "up"
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
)}
|
||||
<Badge
|
||||
variant={row.original.status === "up" ? "success-light" : "destructive-light"}
|
||||
size="sm"
|
||||
className="font-mono"
|
||||
>
|
||||
{row.original.status === "up" ? "UP" : "DOWN"}
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Статус",
|
||||
@@ -203,26 +227,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem onClick={() => onExport(iface)}>
|
||||
<CodeXmlIcon className="size-4" />
|
||||
Экспорт .rsc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PencilIcon className="size-4" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<PowerIcon className="size-4" />
|
||||
{iface.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
Экспорт
|
||||
</DropdownMenuItem>
|
||||
{onAddPeer && (
|
||||
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить пира
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onToggle && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onToggle(iface)}>
|
||||
<PowerIcon className="size-4" />
|
||||
{iface.enabled ? "Отключить" : "Включить"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
|
||||
<Trash2Icon className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -236,7 +266,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
[onExport],
|
||||
[compactServer, onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -254,7 +284,8 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
|
||||
<EmptyState
|
||||
icon={<ShieldCheckIcon className="size-4" />}
|
||||
title="Нет WireGuard интерфейсов"
|
||||
description="Добавьте первый интерфейс или проверьте поиск"
|
||||
description="Добавьте первый интерфейс или сбросьте фильтры"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
@@ -21,7 +24,19 @@ function truncKey(key: string): string {
|
||||
return `${key.slice(0, 8)}…${key.slice(-8)}`
|
||||
}
|
||||
|
||||
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
|
||||
function peerKey(peer: WireGuardPeer, index: number): string {
|
||||
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
|
||||
}
|
||||
|
||||
function WireGuardPeersDetail({
|
||||
peers,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: {
|
||||
peers: WireGuardPeer[]
|
||||
onDeletePeer?: (peerId: string) => void
|
||||
onExportPeer?: (peerId: string) => void
|
||||
}) {
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
|
||||
@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<span>Public Key</span>
|
||||
<span>Allowed IPs</span>
|
||||
<span>Последнее рукопожатие</span>
|
||||
<span>RX / TX</span>
|
||||
<span>Endpoint</span>
|
||||
<span className="sr-only">Действия</span>
|
||||
</div>
|
||||
{peers.map((peer) => (
|
||||
<div
|
||||
key={peer.publicKey}
|
||||
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
|
||||
)}
|
||||
{peers.map((peer, index) => {
|
||||
const id = peerKey(peer, index)
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
|
||||
>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||||
{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-blue-400" />
|
||||
{fmtBytes(peer.transferTx)}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-mono text-muted-foreground truncate">
|
||||
{peer.allowedIps.join(", ")}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] whitespace-nowrap",
|
||||
peer.latestHandshake ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{peer.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-success" />
|
||||
{fmtBytes(peer.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-info" />
|
||||
{fmtBytes(peer.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{onExportPeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExportPeer(id)
|
||||
}}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{onDeletePeer && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeletePeer(id)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import type { WireGuardPeer } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { fmtBytes, truncKey } from "@/components/data-grids/wireguard-peers-detail"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CodeXmlIcon,
|
||||
KeyRoundIcon,
|
||||
Trash2Icon,
|
||||
UsersIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface WgPeerRow extends WireGuardPeer {
|
||||
id: string
|
||||
ifaceId: string
|
||||
ifaceName: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverCountry: string
|
||||
}
|
||||
|
||||
interface WireguardPeersGridProps {
|
||||
peers: WgPeerRow[]
|
||||
compactServer?: boolean
|
||||
emptyAction?: ReactNode
|
||||
onDeletePeer?: (row: WgPeerRow) => void
|
||||
onExportPeer?: (row: WgPeerRow) => void
|
||||
}
|
||||
|
||||
function WireguardPeersGrid({
|
||||
peers,
|
||||
compactServer = false,
|
||||
emptyAction,
|
||||
onDeletePeer,
|
||||
onExportPeer,
|
||||
}: WireguardPeersGridProps) {
|
||||
const columns = useMemo<ColumnDef<WgPeerRow>[]>(() => {
|
||||
const cols: ColumnDef<WgPeerRow>[] = [
|
||||
{
|
||||
id: "peer",
|
||||
accessorKey: "publicKey",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пир" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<KeyRoundIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs" title={peer.publicKey}>
|
||||
{peer.name || truncKey(peer.publicKey)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Пир",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "iface",
|
||||
accessorKey: "ifaceName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейс" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.ifaceName}</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Интерфейс",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!compactServer) {
|
||||
cols.push({
|
||||
id: "server",
|
||||
accessorKey: "serverName",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Сервер" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground">
|
||||
<Flag code={row.original.serverCountry || "UN"} size={12} />
|
||||
{row.original.serverName}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cols.push(
|
||||
{
|
||||
id: "allowedIps",
|
||||
accessorFn: (row) => row.allowedIps.join(", "),
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Allowed IPs" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{row.original.allowedIps.join(", ") || "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Allowed IPs",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "handshake",
|
||||
accessorKey: "latestHandshake",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Handshake" />,
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap font-mono text-[11px]",
|
||||
row.original.latestHandshake ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.original.latestHandshake ?? "нет рукопожатия"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Handshake",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "transfer",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground">RX / TX</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownIcon className="size-3 text-success" />
|
||||
{fmtBytes(row.original.transferRx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpIcon className="size-3 text-info" />
|
||||
{fmtBytes(row.original.transferTx)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "RX / TX",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endpoint",
|
||||
accessorKey: "endpoint",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="Endpoint" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.original.endpoint ?? "—"}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
headerTitle: "Endpoint",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
enableSorting: false,
|
||||
size: 72,
|
||||
cell: ({ row }) => {
|
||||
const peer = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-0.5">
|
||||
{onExportPeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label="Экспорт peer .conf"
|
||||
onClick={() => onExportPeer(peer)}
|
||||
>
|
||||
<CodeXmlIcon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{onDeletePeer ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
aria-label="Удалить пира"
|
||||
onClick={() => onDeletePeer(peer)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return cols
|
||||
}, [compactServer, onDeletePeer, onExportPeer])
|
||||
|
||||
const table = useReactTable({
|
||||
data: peers,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
if (peers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon className="size-4" />}
|
||||
title="Нет пиров"
|
||||
description="Добавьте пира к интерфейсу или сбросьте поиск"
|
||||
action={emptyAction}
|
||||
className="border-0 py-16"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={peers.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border",
|
||||
bodyRow: cn("group/row"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { WireguardPeersGrid, type WireguardPeersGridProps }
|
||||
@@ -31,6 +31,7 @@ function DataPageToolbarFrame({
|
||||
}
|
||||
|
||||
interface DataPageToolbarProps<T extends string = string> {
|
||||
leading?: ReactNode
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
@@ -48,6 +49,7 @@ interface DataPageToolbarProps<T extends string = string> {
|
||||
}
|
||||
|
||||
function DataPageToolbar<T extends string = string>({
|
||||
leading,
|
||||
search,
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Поиск…",
|
||||
@@ -61,6 +63,7 @@ function DataPageToolbar<T extends string = string>({
|
||||
}: DataPageToolbarProps<T>) {
|
||||
return (
|
||||
<DataPageToolbarFrame className={className}>
|
||||
{leading}
|
||||
{segmented && (
|
||||
<SegmentedControl
|
||||
value={segmented.value}
|
||||
|
||||
+42
-3
@@ -5,6 +5,7 @@ import Link from "next/link"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
LogOutIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
@@ -13,6 +14,12 @@ import {
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
getClaims,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogout,
|
||||
} from "@/lib/auth"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -96,11 +103,37 @@ function ThemeSegmentedToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string, email: string): string {
|
||||
const base = (name || email || "?").trim()
|
||||
const parts = base.split(/\s+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
|
||||
}
|
||||
return base.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const name = "Оператор"
|
||||
const email = "локальный доступ"
|
||||
const fallback = "ОП"
|
||||
const [name, setName] = useState("Оператор")
|
||||
const [email, setEmail] = useState("локальный доступ")
|
||||
const [showLogout, setShowLogout] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void ensureAuthConfig().then(() => {
|
||||
const claims = getClaims()
|
||||
if (claims) {
|
||||
setName(claims.name || claims.email || "Пользователь")
|
||||
setEmail(claims.email || "")
|
||||
setShowLogout(isAuthEnabled())
|
||||
} else if (isAuthEnabled()) {
|
||||
setName("Сессия")
|
||||
setEmail("требуется вход")
|
||||
setShowLogout(true)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const fallback = initials(name, email)
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -155,6 +188,12 @@ export function NavUser() {
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{showLogout ? (
|
||||
<DropdownMenuItem onClick={() => redirectToPortalLogout()}>
|
||||
<LogOutIcon aria-hidden />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
LoaderCircleIcon,
|
||||
RefreshCwIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type CodeExportFormat = {
|
||||
id: string
|
||||
label: string
|
||||
filename: string
|
||||
code: string
|
||||
/** Empty / unavailable — show warning instead of code */
|
||||
emptyMessage?: string
|
||||
/** Content came from live device fetch */
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function highlightLine(line: string): string {
|
||||
const trimmed = line.trimStart()
|
||||
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
|
||||
if (trimmed.startsWith("[")) return "text-info"
|
||||
if (trimmed.startsWith("/")) return "text-info"
|
||||
if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
|
||||
return "text-foreground/90"
|
||||
}
|
||||
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const lines = code.length ? code.split("\n") : [""]
|
||||
return (
|
||||
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all">
|
||||
{lines.map((line, i) => (
|
||||
<span key={i} className={cn("block", highlightLine(line))}>
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CodeExportSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
description?: ReactNode
|
||||
formats: CodeExportFormat[]
|
||||
/** Initial format id when sheet opens */
|
||||
initialFormatId?: string
|
||||
/** Optional live fetch from device */
|
||||
onLiveFetch?: (formatId: string) => void
|
||||
liveBusy?: boolean
|
||||
/** Short CTA — keep laconic */
|
||||
liveLabel?: string
|
||||
className?: string
|
||||
/** Extra content under format tabs (e.g. meta strip) */
|
||||
beforeCode?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared code export Sheet — ReUI Frame + sticky footer.
|
||||
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
|
||||
* Alert: https://reui.io/docs/components/base/alert
|
||||
*/
|
||||
function CodeExportSheet({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
formats,
|
||||
initialFormatId,
|
||||
onLiveFetch,
|
||||
liveBusy,
|
||||
liveLabel = "С роутера",
|
||||
className,
|
||||
beforeCode,
|
||||
}: CodeExportSheetProps) {
|
||||
const firstId = formats[0]?.id ?? "default"
|
||||
const [tab, setTab] = useState(initialFormatId ?? firstId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTab(
|
||||
initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||
? initialFormatId
|
||||
: firstId,
|
||||
)
|
||||
setCopied(false)
|
||||
}, [open, initialFormatId, firstId, formats])
|
||||
|
||||
const active = useMemo(
|
||||
() => formats.find((f) => f.id === tab) ?? formats[0],
|
||||
[formats, tab],
|
||||
)
|
||||
|
||||
const code = active?.code ?? ""
|
||||
const emptyMessage = active?.emptyMessage
|
||||
const filename = active?.filename ?? "export.txt"
|
||||
const canCopy = Boolean(code) && !emptyMessage
|
||||
const isLive = Boolean(active?.isLive)
|
||||
const showTabs = formats.length > 1
|
||||
|
||||
function handleCopy() {
|
||||
if (!canCopy) return
|
||||
void navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||
{title}
|
||||
</SheetTitle>
|
||||
{description ? (
|
||||
<SheetDescription className="font-mono text-xs">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(String(v))}
|
||||
className="shrink-0 gap-0"
|
||||
>
|
||||
<TabsList className="h-9 w-full">
|
||||
{formats.map((f) => (
|
||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||
{f.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
{onLiveFetch || beforeCode ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{onLiveFetch ? (
|
||||
<Badge
|
||||
variant={isLive ? "success-light" : "secondary"}
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{isLive ? "С роутера" : "Локально"}
|
||||
</Badge>
|
||||
) : null}
|
||||
{beforeCode}
|
||||
</div>
|
||||
{onLiveFetch ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={liveBusy}
|
||||
onClick={() => onLiveFetch(tab)}
|
||||
aria-label="Подтянуть конфиг с роутера с private-key"
|
||||
>
|
||||
{liveBusy ? (
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
)}
|
||||
{liveBusy ? "Загрузка…" : liveLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Frame dense className="flex min-h-0 flex-1 flex-col">
|
||||
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
|
||||
{emptyMessage ? (
|
||||
<div className="flex flex-1 items-center p-4">
|
||||
<Alert variant="warning" className="w-full">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Нет данных</AlertTitle>
|
||||
<AlertDescription>{emptyMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full min-h-0 flex-1">
|
||||
<div className="min-h-[min(52vh,22rem)]">
|
||||
<CodeBlock code={code} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
{onLiveFetch && !isLive ? (
|
||||
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
|
||||
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||
<SheetClose
|
||||
render={
|
||||
<Button type="button" variant="ghost" className="shrink-0 px-3" />
|
||||
}
|
||||
>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={!canCopy}
|
||||
onClick={() => downloadText(filename, code)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
Файл
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
disabled={!canCopy}
|
||||
onClick={handleCopy}
|
||||
className="min-w-28"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeExportSheet, downloadText }
|
||||
@@ -0,0 +1,5 @@
|
||||
export { CodeExportSheet, downloadText } from "./code-export-sheet"
|
||||
export type { CodeExportFormat, CodeExportSheetProps } from "./code-export-sheet"
|
||||
export { KpiStatGrid, KpiStatCardTile, kpiStatItemKey } from "./kpi-stat-grid"
|
||||
export type { KpiStatItem, KpiStatCardData, KpiStatVariant } from "./kpi-stat-grid"
|
||||
export { kpiCols } from "./kpi-cols"
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared grid column classes for hybrid KPI tiles.
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function kpiCols(count: number): string {
|
||||
if (count <= 1) return "grid-cols-1"
|
||||
if (count === 2) return "grid-cols-1 @xl:grid-cols-2"
|
||||
if (count === 3) return "grid-cols-1 @3xl:grid-cols-3"
|
||||
if (count === 4) return "grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
|
||||
if (count <= 6) return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3"
|
||||
return "grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 @6xl:grid-cols-4"
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client"
|
||||
|
||||
import type { KeyboardEvent, ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { kpiCols } from "./kpi-cols"
|
||||
|
||||
export type KpiStatVariant = "default" | "warning" | "destructive"
|
||||
|
||||
/**
|
||||
* KPI tile — horizontal compact hybrid (icon left + label/Badge + value).
|
||||
* @see https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
hint?: ReactNode
|
||||
href?: string
|
||||
onSelect?: () => void
|
||||
onClick?: () => void
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
variant?: KpiStatVariant
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||
|
||||
const DEFAULT_ICON_CLASS = "text-muted-foreground [&_svg]:text-current"
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||
default: "text-foreground",
|
||||
warning: "text-warning",
|
||||
destructive: "text-destructive",
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
onActivate()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||
return item.onClick ?? item.onSelect
|
||||
}
|
||||
|
||||
function isSelected(item: KpiStatItem): boolean {
|
||||
return Boolean(item.selected ?? item.active)
|
||||
}
|
||||
|
||||
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||
if (item.footer) return item.footer
|
||||
if (typeof item.hint === "string") {
|
||||
return (
|
||||
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||
{item.hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (item.hint) return item.hint
|
||||
return null
|
||||
}
|
||||
|
||||
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
const footer = resolveFooter(item)
|
||||
const valueVariant = item.variant ?? "default"
|
||||
|
||||
return (
|
||||
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||
{item.icon ? (
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn("size-10.5 shrink-0", item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||
{item.label}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 break-all text-2xl leading-none font-bold tabular-nums",
|
||||
VALUE_VARIANT_CLASS[valueVariant],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function panelClassName(item: KpiStatItem, className?: string) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const clickable = Boolean(item.href || onActivate)
|
||||
const selected = isSelected(item)
|
||||
|
||||
return cn(
|
||||
"relative isolate flex h-full min-w-0 flex-col",
|
||||
clickable &&
|
||||
"hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2",
|
||||
selected && "ring-primary/30 bg-muted/30 ring-1",
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
export function KpiStatCardTile({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item, className)
|
||||
|
||||
let panel: ReactNode
|
||||
|
||||
if (item.href) {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link href={item.href} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
} else if (onActivate) {
|
||||
panel = (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
} else {
|
||||
panel = (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||
return (
|
||||
<Frame className="@container w-full">
|
||||
<div className={cn("grid gap-2", kpiCols(count))}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<FramePanel key={index} className="flex items-start gap-3">
|
||||
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
interface KpiStatGridProps {
|
||||
items?: KpiStatItem[]
|
||||
cards?: KpiStatCardData[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
emptyIcon?: ReactNode
|
||||
className?: string
|
||||
skeletonCount?: number
|
||||
embedded?: boolean
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||
const onActivate = resolveActivate(item)
|
||||
const panelClass = panelClassName(item)
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link href={item.href} className="focus-visible:outline-none">
|
||||
<KpiStatCardBody item={item} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
if (onActivate) {
|
||||
return (
|
||||
<FramePanel
|
||||
className={panelClass}
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||
>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<KpiStatCardBody item={item} />
|
||||
</FramePanel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid KPI — horizontal compact layout (icon left).
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
cards,
|
||||
isLoading = false,
|
||||
emptyMessage,
|
||||
emptyIcon,
|
||||
className,
|
||||
skeletonCount = 4,
|
||||
embedded = false,
|
||||
"aria-label": ariaLabel,
|
||||
}: KpiStatGridProps) {
|
||||
if (isLoading) {
|
||||
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||
}
|
||||
|
||||
const list = items ?? cards ?? []
|
||||
|
||||
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn("w-full", className)}>
|
||||
<FramePanel className="flex items-center gap-3 p-4">
|
||||
{emptyIcon}
|
||||
{emptyMessage ? (
|
||||
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn("@container w-full", className)}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className={cn("@container w-full min-w-0", className)} aria-label={ariaLabel}>
|
||||
<div className={cn("grid gap-2", kpiCols(list.length || 1))}>
|
||||
{list.map((item, index) => (
|
||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === "string") return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
|
||||
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
|
||||
|
||||
/**
|
||||
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
|
||||
* @see https://reui.io/preview/base/chart-23
|
||||
* @see https://reui.io/blocks
|
||||
*/
|
||||
|
||||
const chartConfig = {
|
||||
rx: { label: "RX", color: "var(--chart-rx)" },
|
||||
tx: { label: "TX", color: "var(--chart-tx)" },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function ChartLegendItem({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean
|
||||
payload?: {
|
||||
dataKey: string
|
||||
color: string
|
||||
value: number
|
||||
}[]
|
||||
}) {
|
||||
if (!active || !payload?.length) return null
|
||||
return (
|
||||
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
|
||||
{payload.map((item) => (
|
||||
<div key={item.dataKey}>
|
||||
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
|
||||
{item.dataKey === "rx" ? "RX" : "TX"}:
|
||||
</div>
|
||||
<div className="text-sm font-semibold tabular-nums">
|
||||
{fmtRate(item.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toChartData(rx: number[], tx: number[], rangeMinutes: number) {
|
||||
const n = Math.max(rx.length, tx.length, 1)
|
||||
const denom = Math.max(n - 1, 1)
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
|
||||
return {
|
||||
time: formatRangeAgo(minutesAgo),
|
||||
rx: rx[i] ?? 0,
|
||||
tx: tx[i] ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function TrafficRxTxChart({
|
||||
rx,
|
||||
tx,
|
||||
range = "1h",
|
||||
}: {
|
||||
rx: number[]
|
||||
tx: number[]
|
||||
range?: string
|
||||
}) {
|
||||
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
||||
const data = toChartData(rx, tx, rangeMinutes)
|
||||
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
||||
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
||||
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="4 8"
|
||||
vertical={false}
|
||||
stroke="var(--border)"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickMargin={10}
|
||||
interval={tickEvery - 1}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v: number) => fmtRate(Number(v))}
|
||||
tickMargin={8}
|
||||
width={72}
|
||||
/>
|
||||
<ChartTooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
dataKey="rx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-rx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="tx"
|
||||
type="monotone"
|
||||
stroke="var(--chart-tx)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<div className="mb-1 flex items-center justify-center gap-6">
|
||||
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
||||
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { PanelLeftCloseIcon, ServerIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
ALL_SERVERS_ID,
|
||||
ServerTileRail,
|
||||
hostnameOf,
|
||||
type ServerTileItem,
|
||||
} from "@/components/server-tile-rail"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/list-9 */
|
||||
|
||||
const STORAGE_KEY = "mm:server-rail-collapsed"
|
||||
const DESKTOP_MQ = "(min-width: 768px)"
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(value: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, value ? "1" : "0")
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
interface ServerRailContextValue {
|
||||
openRail: () => void
|
||||
expandRail: () => void
|
||||
collapsed: boolean
|
||||
selectedLabel: string
|
||||
}
|
||||
|
||||
const ServerRailContext = createContext<ServerRailContextValue | null>(null)
|
||||
|
||||
function useServerRail() {
|
||||
const ctx = useContext(ServerRailContext)
|
||||
if (!ctx) {
|
||||
throw new Error("ServerRailMobileButton must be used inside ServerRailLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function ServerRailMobileButton() {
|
||||
const { openRail, expandRail, collapsed, selectedLabel } = useServerRail()
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={collapsed ? undefined : "md:hidden"}
|
||||
aria-label={collapsed ? "Показать список серверов" : "Серверы"}
|
||||
onClick={() => {
|
||||
if (window.matchMedia(DESKTOP_MQ).matches) {
|
||||
expandRail()
|
||||
return
|
||||
}
|
||||
openRail()
|
||||
}}
|
||||
>
|
||||
<ServerIcon data-icon="inline-start" />
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerRailLayout({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
showAll = true,
|
||||
allCount = 0,
|
||||
showCount = true,
|
||||
showType = true,
|
||||
headerRight,
|
||||
loading = false,
|
||||
extra,
|
||||
header,
|
||||
banner,
|
||||
contentClassName,
|
||||
children,
|
||||
}: {
|
||||
items: ServerTileItem[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
showAll?: boolean
|
||||
allCount?: number
|
||||
showCount?: boolean
|
||||
showType?: boolean
|
||||
headerRight?: ReactNode
|
||||
loading?: boolean
|
||||
extra?: ReactNode
|
||||
header?: ReactNode
|
||||
banner?: ReactNode
|
||||
contentClassName?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [railOpen, setRailOpen] = useState(false)
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(readCollapsed())
|
||||
setHydrated(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
writeCollapsed(collapsed)
|
||||
}, [collapsed, hydrated])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id)
|
||||
setRailOpen(false)
|
||||
},
|
||||
[onSelect],
|
||||
)
|
||||
|
||||
const expandRail = useCallback(() => {
|
||||
setCollapsed(false)
|
||||
}, [])
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (showAll && selectedId === ALL_SERVERS_ID) return "Все серверы"
|
||||
const item = items.find((entry) => entry.id === selectedId)
|
||||
return item ? hostnameOf(item) : "Сервер"
|
||||
}, [items, selectedId, showAll])
|
||||
|
||||
const ctx = useMemo<ServerRailContextValue>(
|
||||
() => ({
|
||||
openRail: () => setRailOpen(true),
|
||||
expandRail,
|
||||
collapsed,
|
||||
selectedLabel,
|
||||
}),
|
||||
[collapsed, expandRail, selectedLabel],
|
||||
)
|
||||
|
||||
const railHeaderRight = (
|
||||
<>
|
||||
{headerRight}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="hidden md:inline-flex"
|
||||
aria-label="Свернуть список серверов"
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<PanelLeftCloseIcon />
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
const rail = (
|
||||
<ServerTileRail
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
showAll={showAll}
|
||||
allCount={allCount}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
headerRight={railHeaderRight}
|
||||
loading={loading}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServerRailContext.Provider value={ctx}>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{header}
|
||||
{banner}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{!collapsed ? (
|
||||
<aside className="hidden min-h-0 w-60 shrink-0 flex-col gap-3 p-3 pr-0 md:flex">
|
||||
{rail}
|
||||
{extra}
|
||||
</aside>
|
||||
) : null}
|
||||
<div className={cn("min-w-0 flex-1 overflow-y-auto p-4 md:p-6", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Sheet open={railOpen} onOpenChange={setRailOpen}>
|
||||
<SheetContent side="left" className="flex w-72 flex-col gap-3 p-3" showCloseButton>
|
||||
<SheetHeader className="px-1 pt-1">
|
||||
<SheetTitle>Серверы</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<ServerTileRail
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
showAll={showAll}
|
||||
allCount={allCount}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
showHeader={false}
|
||||
headerRight={headerRight}
|
||||
loading={loading}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
{extra}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</ServerRailContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerRailLayout, ServerRailMobileButton }
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
||||
import type { ServerStatus, ServerType } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { IconTile } from "@/components/reui/icon-tile"
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LayersIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||
|
||||
/** Preview: https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/badge */
|
||||
export const ALL_SERVERS_ID = "all"
|
||||
|
||||
export interface ServerTileItem {
|
||||
id: string
|
||||
name: string
|
||||
country?: string
|
||||
status?: ServerStatus
|
||||
type?: ServerType
|
||||
count?: number
|
||||
enabled?: boolean
|
||||
selectable?: boolean
|
||||
title?: string
|
||||
site?: string
|
||||
host?: string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
function typeBadgeVariant(
|
||||
type: ServerType,
|
||||
): "focus-light" | "info-light" | "success-light" {
|
||||
if (type === "jump-host") return "focus-light"
|
||||
if (type === "home-router") return "success-light"
|
||||
return "info-light"
|
||||
}
|
||||
|
||||
function typeBadgeLabel(type: ServerType): string {
|
||||
if (type === "jump-host") return "JH"
|
||||
if (type === "home-router") return "HR"
|
||||
return "EN"
|
||||
}
|
||||
|
||||
function ServerTypeBadge({ type }: { type: ServerType }) {
|
||||
return (
|
||||
<Badge variant={typeBadgeVariant(type)} size="xs" className="font-mono">
|
||||
{typeBadgeLabel(type)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function hostnameOf(item: ServerTileItem): string {
|
||||
return item.name || item.host || ""
|
||||
}
|
||||
|
||||
function matchesQuery(item: ServerTileItem, q: string): boolean {
|
||||
if (!q) return true
|
||||
const hay = [item.name, item.title ?? "", item.host ?? "", item.site ?? "", item.country ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
return hay.includes(q)
|
||||
}
|
||||
|
||||
function isSelectable(item: ServerTileItem): boolean {
|
||||
return item.selectable !== false
|
||||
}
|
||||
|
||||
function ServerTileRail({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
allCount = 0,
|
||||
showHeader = true,
|
||||
showAll = true,
|
||||
showCount = true,
|
||||
showType = true,
|
||||
headerRight,
|
||||
loading = false,
|
||||
className,
|
||||
}: {
|
||||
items: ServerTileItem[]
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
allCount?: number
|
||||
showHeader?: boolean
|
||||
showAll?: boolean
|
||||
showCount?: boolean
|
||||
showType?: boolean
|
||||
headerRight?: ReactNode
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
const q = query.trim().toLowerCase()
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => items.filter((item) => matchesQuery(item, q)),
|
||||
[items, q],
|
||||
)
|
||||
|
||||
const uniqueSites = useMemo(() => {
|
||||
const sites = new Set(
|
||||
filtered.map((item) => item.site?.trim()).filter((site): site is string => Boolean(site)),
|
||||
)
|
||||
return sites.size
|
||||
}, [filtered])
|
||||
|
||||
const groupBySite = uniqueSites >= 2 && !q
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!groupBySite) {
|
||||
return [{ site: "", items: filtered }]
|
||||
}
|
||||
const bySite = new Map<string, ServerTileItem[]>()
|
||||
for (const item of filtered) {
|
||||
const site = item.site?.trim() || "—"
|
||||
const list = bySite.get(site)
|
||||
if (list) list.push(item)
|
||||
else bySite.set(site, [item])
|
||||
}
|
||||
return [...bySite.entries()].map(([site, siteItems]) => ({ site, items: siteItems }))
|
||||
}, [filtered, groupBySite])
|
||||
|
||||
const showAllTile = showAll && !q
|
||||
|
||||
const resolvedId = useMemo(() => {
|
||||
if (showAll && selectedId === ALL_SERVERS_ID) return ALL_SERVERS_ID
|
||||
if (items.some((item) => item.id === selectedId)) return selectedId
|
||||
if (showAll) return ALL_SERVERS_ID
|
||||
return items.find(isSelectable)?.id ?? items[0]?.id ?? ""
|
||||
}, [items, selectedId, showAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedId && resolvedId !== selectedId) onSelect(resolvedId)
|
||||
}, [onSelect, resolvedId, selectedId])
|
||||
|
||||
const selectableIds = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
if (showAllTile) ids.push(ALL_SERVERS_ID)
|
||||
for (const item of filtered) {
|
||||
if (isSelectable(item)) ids.push(item.id)
|
||||
}
|
||||
return ids
|
||||
}, [filtered, showAllTile])
|
||||
|
||||
function moveSelection(delta: number) {
|
||||
if (selectableIds.length === 0) return
|
||||
const idx = selectableIds.indexOf(resolvedId)
|
||||
const nextIdx =
|
||||
idx < 0
|
||||
? delta > 0 ? 0 : selectableIds.length - 1
|
||||
: Math.min(selectableIds.length - 1, Math.max(0, idx + delta))
|
||||
const nextId = selectableIds[nextIdx]
|
||||
if (nextId) onSelect(nextId)
|
||||
}
|
||||
|
||||
function handleListKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (loading) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveSelection(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveSelection(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
if (selectableIds[0]) onSelect(selectableIds[0])
|
||||
return
|
||||
}
|
||||
if (event.key === "End") {
|
||||
event.preventDefault()
|
||||
const last = selectableIds[selectableIds.length - 1]
|
||||
if (last) onSelect(last)
|
||||
}
|
||||
}
|
||||
|
||||
const allItem: ServerTileItem = {
|
||||
id: ALL_SERVERS_ID,
|
||||
name: "Все серверы",
|
||||
title: "Все серверы",
|
||||
count: allCount,
|
||||
meta: String(allCount),
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn("flex h-full min-h-0 w-full flex-col", className)}>
|
||||
<FramePanel className="flex min-h-0 flex-1 flex-col gap-0 p-0">
|
||||
<FrameHeader className="flex flex-col gap-2 border-b px-3 py-2">
|
||||
{showHeader ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FrameTitle className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Серверы
|
||||
</FrameTitle>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||
{headerRight}
|
||||
</span>
|
||||
</div>
|
||||
) : headerRight || loading ? (
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
|
||||
{headerRight}
|
||||
</div>
|
||||
) : null}
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск…"
|
||||
aria-label="Поиск сервера"
|
||||
disabled={loading}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FrameHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div
|
||||
ref={listRef}
|
||||
className="flex flex-col gap-0.5 p-1.5"
|
||||
role="listbox"
|
||||
aria-label="Серверы"
|
||||
aria-activedescendant={resolvedId ? `server-tile-${resolvedId}` : undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleListKeyDown}
|
||||
>
|
||||
{showAllTile ? (
|
||||
<ServerTileButton
|
||||
item={allItem}
|
||||
selected={resolvedId === ALL_SERVERS_ID}
|
||||
onSelect={() => onSelect(ALL_SERVERS_ID)}
|
||||
showCount={showCount}
|
||||
showType={false}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
<LayersIcon className="text-muted-foreground" />
|
||||
</IconTile>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<div key={group.site || "__flat__"} className="flex flex-col gap-0.5">
|
||||
{groupBySite && group.site ? (
|
||||
<p className="sticky top-0 z-[1] bg-background/95 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground backdrop-blur-sm">
|
||||
{group.site}
|
||||
</p>
|
||||
) : null}
|
||||
{group.items.map((item) => (
|
||||
<ServerTileButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
selected={resolvedId === item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
showCount={showCount}
|
||||
showType={showType}
|
||||
showSite={!groupBySite}
|
||||
icon={
|
||||
<IconTile variant="elevated" size="sm" aria-hidden="true">
|
||||
{item.country ? (
|
||||
<Flag code={item.country} size={16} />
|
||||
) : (
|
||||
<ServerIcon className="text-muted-foreground" />
|
||||
)}
|
||||
</IconTile>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && !showAllTile ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "Загрузка серверов…"
|
||||
: items.length === 0
|
||||
? "Нет доступных серверов"
|
||||
: "Ничего не найдено"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function ServerTileButton({
|
||||
item,
|
||||
selected,
|
||||
onSelect,
|
||||
showCount,
|
||||
showType,
|
||||
showSite = true,
|
||||
icon,
|
||||
}: {
|
||||
item: ServerTileItem
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
showCount: boolean
|
||||
showType: boolean
|
||||
showSite?: boolean
|
||||
icon: ReactNode
|
||||
}) {
|
||||
const isAll = item.id === ALL_SERVERS_ID
|
||||
const selectable = isSelectable(item)
|
||||
const hostname = isAll ? "Все серверы" : hostnameOf(item)
|
||||
const meta = item.meta ?? (showCount && item.count != null ? String(item.count) : undefined)
|
||||
const showStatus = Boolean(item.status && item.status !== "online")
|
||||
|
||||
return (
|
||||
<Item
|
||||
id={`server-tile-${item.id}`}
|
||||
size="xs"
|
||||
variant={selected ? "muted" : "default"}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={item.title ?? hostname}
|
||||
disabled={!selectable}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
}
|
||||
className={cn(
|
||||
"h-11 min-h-11 flex-nowrap rounded-md py-0",
|
||||
selected && "ring-1 ring-border",
|
||||
item.enabled === false && !selected && "opacity-40",
|
||||
!selectable && "cursor-not-allowed opacity-40 hover:bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ItemMedia>{icon}</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle className="max-w-full min-w-0 gap-1.5 font-mono text-[11px]">
|
||||
{showStatus ? <StatusDot status={item.status!} /> : null}
|
||||
<span className="min-w-0 truncate">{hostname}</span>
|
||||
{!isAll && showSite && item.site ? (
|
||||
<Badge variant="outline" size="xs" className="shrink-0 font-mono uppercase">
|
||||
{item.site}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!isAll && showType && item.type ? <ServerTypeBadge type={item.type} /> : null}
|
||||
</ItemTitle>
|
||||
</ItemContent>
|
||||
{meta ? (
|
||||
<ItemActions>
|
||||
<Badge
|
||||
variant={selected ? "secondary" : "outline"}
|
||||
size="xs"
|
||||
className="tabular-nums"
|
||||
>
|
||||
{meta}
|
||||
</Badge>
|
||||
</ItemActions>
|
||||
) : null}
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ServerTileRail, ServerTypeBadge, hostnameOf }
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { filters, pingProbes, servers } from "@/lib/data"
|
||||
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
|
||||
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
||||
export function SystemMonitorPopover() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsHydrated) return
|
||||
if (mode !== "live") {
|
||||
setHealthOk(true)
|
||||
setCounts({
|
||||
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
|
||||
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
const base = backendUrl.replace(/\/$/, "")
|
||||
try {
|
||||
const [hRes, cRes] = await Promise.all([
|
||||
fetch(`${base}/health`),
|
||||
fetch(`${base}/api/sidebar-counts`),
|
||||
const [hRes, counts] = await Promise.all([
|
||||
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
|
||||
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (hRes.ok) {
|
||||
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
|
||||
} else {
|
||||
setHealthOk(false)
|
||||
}
|
||||
if (cRes.ok) {
|
||||
setCounts((await cRes.json()) as SidebarCountsDto)
|
||||
} else {
|
||||
setCounts(null)
|
||||
}
|
||||
setCounts(counts)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHealthOk(false)
|
||||
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendUrl])
|
||||
}, [mode, backendUrl, prefsHydrated])
|
||||
|
||||
const serversCount = counts?.servers ?? 0
|
||||
const filtersCount = counts?.filterRules ?? 0
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
import type { TooltipValueType } from "recharts"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
|
||||
type TooltipNameType = number | string
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
>
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
initialDimension?: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer
|
||||
initialDimension={initialDimension}
|
||||
>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme ?? config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>,
|
||||
"accessibilityLayer"
|
||||
>) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? (config[label]?.label ?? label)
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label ?? item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number"
|
||||
? item.value.toLocaleString()
|
||||
: String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
export type WgCreateFormState = {
|
||||
serverId: string
|
||||
name: string
|
||||
listenPort: string
|
||||
mtu: string
|
||||
comment: string
|
||||
address: string
|
||||
enabled: boolean
|
||||
showAdvanced: boolean
|
||||
peerEnabled: boolean
|
||||
peerPublicKey: string
|
||||
peerAllowedIps: string
|
||||
peerEndpoint: string
|
||||
peerKeepalive: string
|
||||
peerComment: string
|
||||
}
|
||||
|
||||
export const defaultWgCreateForm = (): WgCreateFormState => ({
|
||||
serverId: "",
|
||||
name: "",
|
||||
listenPort: "13231",
|
||||
mtu: "1420",
|
||||
comment: "",
|
||||
address: "",
|
||||
enabled: true,
|
||||
showAdvanced: false,
|
||||
peerEnabled: false,
|
||||
peerPublicKey: "",
|
||||
peerAllowedIps: "",
|
||||
peerEndpoint: "",
|
||||
peerKeepalive: "25",
|
||||
peerComment: "",
|
||||
})
|
||||
|
||||
type ServerOption = { id: string; name: string; host: string }
|
||||
|
||||
function WgCreateSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
busy,
|
||||
defaultServerId,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerOption[]
|
||||
busy?: boolean
|
||||
defaultServerId?: string
|
||||
onSubmit: (form: WgCreateFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
|
||||
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setForm({ ...defaultWgCreateForm(), serverId: defaultServerId ?? "" })
|
||||
}, [open, defaultServerId])
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return Boolean(form.serverId && form.name.trim() && form.listenPort)
|
||||
}, [form.serverId, form.name, form.listenPort])
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
|
||||
<SheetDescription>
|
||||
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
value={form.serverId}
|
||||
onChange={(e) => set("serverId", e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Имя интерфейса" required hint="Например wg-msk-spb">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="wg0"
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="Listen port" required>
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.listenPort}
|
||||
onChange={(e) => set("listenPort", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="MTU">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.mtu}
|
||||
onChange={(e) => set("mtu", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField label="Комментарий">
|
||||
<Input
|
||||
value={form.comment}
|
||||
onChange={(e) => set("comment", e.target.value)}
|
||||
placeholder="MSK → SPB overlay"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Включён</p>
|
||||
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
|
||||
</div>
|
||||
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => set("showAdvanced", !form.showAdvanced)}
|
||||
>
|
||||
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
|
||||
Дополнительно
|
||||
</button>
|
||||
|
||||
{form.showAdvanced && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="IP на интерфейсе" hint="/ip address add, например 10.210.0.1/30">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.210.0.1/30"
|
||||
value={form.address}
|
||||
onChange={(e) => set("address", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Добавить первого пира</p>
|
||||
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
|
||||
</div>
|
||||
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
|
||||
</div>
|
||||
|
||||
{form.peerEnabled && (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
|
||||
<FormField label="Public key пира" required>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={form.peerPublicKey}
|
||||
onChange={(e) => set("peerPublicKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Allowed IPs" required hint="Через запятую">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.210.0.2/32"
|
||||
value={form.peerAllowedIps}
|
||||
onChange={(e) => set("peerAllowedIps", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" hint="host:port">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="1.2.3.4:13231"
|
||||
value={form.peerEndpoint}
|
||||
onChange={(e) => set("peerEndpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Keepalive (сек)">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.peerKeepalive}
|
||||
onChange={(e) => set("peerKeepalive", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Комментарий пира">
|
||||
<Input
|
||||
value={form.peerComment}
|
||||
onChange={(e) => set("peerComment", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => void onSubmit(form)}
|
||||
>
|
||||
{busy ? "Создание…" : "Создать туннель"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgCreateSheet }
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
|
||||
import {
|
||||
CodeExportSheet,
|
||||
type CodeExportFormat,
|
||||
} from "@/components/reui-kit/code-export-sheet"
|
||||
import {
|
||||
generateMikrotikRsc,
|
||||
generateNativeConf,
|
||||
generatePeerClientConf,
|
||||
} from "@/lib/wg-config"
|
||||
|
||||
function WgExportSheet({
|
||||
open,
|
||||
iface,
|
||||
onClose,
|
||||
liveContent,
|
||||
liveBusy,
|
||||
onRequestLiveExport,
|
||||
initialTab = "rsc",
|
||||
peerId,
|
||||
}: {
|
||||
open: boolean
|
||||
iface: WgIfaceWithServer | null
|
||||
onClose: () => void
|
||||
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
|
||||
liveBusy?: boolean
|
||||
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void
|
||||
initialTab?: "rsc" | "conf" | "peer"
|
||||
/** Selected peer for Peer .conf (defaults to first peer) */
|
||||
peerId?: string | null
|
||||
}) {
|
||||
const formats = useMemo((): CodeExportFormat[] => {
|
||||
if (!iface) {
|
||||
return [
|
||||
{ id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
|
||||
{ id: "conf", label: ".conf", filename: "wg.conf", code: "" },
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer",
|
||||
filename: "wg-peer.conf",
|
||||
code: "",
|
||||
emptyMessage: "Интерфейс не выбран",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: iface.name,
|
||||
listenPort: iface.listenPort,
|
||||
mtu: iface.mtu,
|
||||
comment: iface.comment,
|
||||
enabled: iface.enabled,
|
||||
privateKey: iface.privateKey,
|
||||
publicKey: iface.publicKey,
|
||||
address: iface.address,
|
||||
serverName: iface.serverName,
|
||||
peers: iface.peers.map((p) => ({
|
||||
publicKey: p.publicKey,
|
||||
allowedIps: p.allowedIps,
|
||||
endpoint: p.endpoint,
|
||||
persistentKeepalive: p.persistentKeepalive,
|
||||
persistent: p.persistent,
|
||||
comment: p.comment,
|
||||
name: p.name,
|
||||
clientAddress: p.clientAddress,
|
||||
clientDns: p.clientDns,
|
||||
clientEndpoint: p.clientEndpoint,
|
||||
})),
|
||||
}
|
||||
|
||||
const peer =
|
||||
(peerId
|
||||
? iface.peers.find((p) => p.id === peerId || p.rosId === peerId)
|
||||
: undefined) ?? iface.peers[0]
|
||||
|
||||
const localRsc = generateMikrotikRsc(base)
|
||||
const localConf = generateNativeConf(base)
|
||||
|
||||
let peerConf = ""
|
||||
let peerEmpty: string | undefined
|
||||
if (!iface.publicKey) {
|
||||
peerEmpty = "Нет public-key интерфейса для клиентского .conf"
|
||||
} else if (!peer) {
|
||||
peerEmpty = "Нет пиров для экспорта Peer .conf"
|
||||
} else {
|
||||
peerConf = generatePeerClientConf({
|
||||
peerAddress: peer.clientAddress,
|
||||
peerDns: peer.clientDns,
|
||||
serverPublicKey: iface.publicKey,
|
||||
allowedIps: peer.allowedIps,
|
||||
endpoint: peer.clientEndpoint || peer.endpoint || undefined,
|
||||
persistentKeepalive: peer.persistentKeepalive ?? 25,
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "rsc",
|
||||
label: ".rsc",
|
||||
filename: `${iface.name}.rsc`,
|
||||
code: liveContent?.rsc ?? localRsc,
|
||||
isLive: Boolean(liveContent?.rsc),
|
||||
},
|
||||
{
|
||||
id: "conf",
|
||||
label: ".conf",
|
||||
filename: `${iface.name}.conf`,
|
||||
code: liveContent?.conf ?? localConf,
|
||||
isLive: Boolean(liveContent?.conf),
|
||||
},
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer",
|
||||
filename: `${iface.name}-peer.conf`,
|
||||
code: liveContent?.peerConf ?? peerConf,
|
||||
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
|
||||
isLive: Boolean(liveContent?.peerConf),
|
||||
},
|
||||
]
|
||||
}, [iface, liveContent, peerId])
|
||||
|
||||
return (
|
||||
<CodeExportSheet
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Экспорт WireGuard"
|
||||
description={iface ? `${iface.name} · ${iface.serverName}` : "—"}
|
||||
formats={formats}
|
||||
initialFormatId={initialTab}
|
||||
liveBusy={liveBusy}
|
||||
liveLabel="С роутера"
|
||||
onLiveFetch={
|
||||
onRequestLiveExport
|
||||
? (formatId) =>
|
||||
onRequestLiveExport(
|
||||
formatId === "peer" ? "peer-conf" : formatId === "conf" ? "conf" : "rsc",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgExportSheet }
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { detectWgConfigFormat, parseWgConfig, type WgParsedConfig } from "@/lib/wg-config"
|
||||
import { UploadIcon } from "lucide-react"
|
||||
|
||||
type ServerOption = { id: string; name: string; host: string }
|
||||
|
||||
function WgImportSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
busy,
|
||||
defaultServerId,
|
||||
onImport,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerOption[]
|
||||
busy?: boolean
|
||||
defaultServerId?: string
|
||||
onImport: (args: {
|
||||
serverId: string
|
||||
content: string
|
||||
format: "auto" | "rsc" | "conf"
|
||||
dryRun: boolean
|
||||
}) => Promise<void>
|
||||
}) {
|
||||
const [serverId, setServerId] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto")
|
||||
const [preview, setPreview] = useState<WgParsedConfig | null>(null)
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
|
||||
const detected = useMemo(
|
||||
() => (content.trim() ? detectWgConfigFormat(content) : null),
|
||||
[content],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setServerId(defaultServerId ?? "")
|
||||
}, [open, defaultServerId])
|
||||
|
||||
function runPreview() {
|
||||
setParseError(null)
|
||||
setPreview(null)
|
||||
try {
|
||||
setPreview(parseWgConfig(content, format))
|
||||
} catch (e) {
|
||||
setParseError(e instanceof Error ? e.message : "Ошибка разбора")
|
||||
}
|
||||
}
|
||||
|
||||
function onFile(file: File | null) {
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setContent(String(reader.result ?? ""))
|
||||
setPreview(null)
|
||||
setParseError(null)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (v) {
|
||||
setServerId(defaultServerId ?? "")
|
||||
} else {
|
||||
setContent("")
|
||||
setPreview(null)
|
||||
setParseError(null)
|
||||
setServerId("")
|
||||
}
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
|
||||
<SheetDescription>
|
||||
Native .conf или MikroTik .rsc → применить на выбранный роутер
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<FormField label="Сервер" required>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
value={serverId}
|
||||
onChange={(e) => setServerId(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер…</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} ({s.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Формат">
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
|
||||
>
|
||||
<option value="auto">Авто{detected ? ` (${detected})` : ""}</option>
|
||||
<option value="conf">Native WireGuard (.conf)</option>
|
||||
<option value="rsc">MikroTik (.rsc)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionTitle>Содержимое</SectionTitle>
|
||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer w-fit">
|
||||
<UploadIcon className="size-4" />
|
||||
Загрузить файл
|
||||
<input
|
||||
type="file"
|
||||
accept=".conf,.rsc,.txt,text/plain"
|
||||
className="sr-only"
|
||||
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
|
||||
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value)
|
||||
setPreview(null)
|
||||
setParseError(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={runPreview} disabled={!content.trim()}>
|
||||
Предпросмотр
|
||||
</Button>
|
||||
|
||||
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
|
||||
|
||||
{preview && (
|
||||
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
|
||||
<p className="font-medium">
|
||||
{preview.format.toUpperCase()} · {preview.interface.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
port={preview.interface.listenPort ?? "—"} · mtu={preview.interface.mtu ?? "—"}
|
||||
{preview.interface.address ? ` · ${preview.interface.address}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Пиров: {preview.peers.length}</p>
|
||||
{preview.peers.slice(0, 5).map((p, i) => (
|
||||
<p key={i} className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{p.publicKey.slice(0, 16)}… → {p.allowedAddresses.join(", ")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
|
||||
<div className="flex w-full gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!serverId || !content.trim() || busy}
|
||||
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
|
||||
>
|
||||
{busy ? "Импорт…" : "Применить на роутер"}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgImportSheet }
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
|
||||
|
||||
export type WgPeerFormState = {
|
||||
publicKey: string
|
||||
allowedIps: string
|
||||
endpoint: string
|
||||
keepalive: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
const emptyPeerForm = (): WgPeerFormState => ({
|
||||
publicKey: "",
|
||||
allowedIps: "",
|
||||
endpoint: "",
|
||||
keepalive: "25",
|
||||
comment: "",
|
||||
})
|
||||
|
||||
function WgPeerSheet({
|
||||
open,
|
||||
iface,
|
||||
busy,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
iface: WgIfaceWithServer | null
|
||||
busy?: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
onSubmit: (form: WgPeerFormState) => void | Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<WgPeerFormState>(emptyPeerForm)
|
||||
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (v) setForm(emptyPeerForm())
|
||||
onOpenChange(v)
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Добавить пира</SheetTitle>
|
||||
<SheetDescription>
|
||||
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
|
||||
<SectionTitle>Параметры пира</SectionTitle>
|
||||
<FormField label="Public key" required>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={form.publicKey}
|
||||
onChange={(e) => set("publicKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Allowed IPs" required hint="Через запятую">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="10.210.0.2/32"
|
||||
value={form.allowedIps}
|
||||
onChange={(e) => set("allowedIps", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" hint="host:port">
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="1.2.3.4:13231"
|
||||
value={form.endpoint}
|
||||
onChange={(e) => set("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Keepalive (сек)">
|
||||
<Input
|
||||
className="font-mono"
|
||||
value={form.keepalive}
|
||||
onChange={(e) => set("keepalive", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Комментарий">
|
||||
<Input value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
|
||||
Отмена
|
||||
</SheetClose>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
|
||||
onClick={() => void onSubmit(form)}
|
||||
>
|
||||
{busy ? "Сохранение…" : "Добавить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { WgPeerSheet }
|
||||
@@ -0,0 +1,242 @@
|
||||
# CDN Manager + MikrotikManager + one Traefik (production).
|
||||
#
|
||||
# Hosts:
|
||||
# https://cdn.shnt.top → cdnmanager:8080
|
||||
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
|
||||
#
|
||||
# On server:
|
||||
# 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 # fill secrets
|
||||
# # targets.json:
|
||||
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
|
||||
# docker login git.shx.one
|
||||
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||
#
|
||||
# DNS (Cloudflare DNS only, grey cloud):
|
||||
# A/AAAA cdn.shnt.top → VPS
|
||||
# A/AAAA mm.shnt.top → VPS
|
||||
#
|
||||
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
|
||||
# on the same host ports while this stack is up.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: cdn-mm-traefik
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||
# Optional if DNS token lacks Zone:Read:
|
||||
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||
command:
|
||||
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=edge
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- edge
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- CDN Manager -----------------------------------------------------------
|
||||
cdnmanager:
|
||||
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
|
||||
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: cdnmanager
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
STATIC_DIR: /app/static
|
||||
SERVER_PORT: "8080"
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||
volumes:
|
||||
- ./data/cdn:/data
|
||||
networks:
|
||||
- edge
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
|
||||
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||
- traefik.http.routers.cdnmanager.tls=true
|
||||
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- MikrotikManager -------------------------------------------------------
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data/mm:/app/data
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- backend
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- frontend
|
||||
edge: {}
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: cdn_mm_traefik_letsencrypt
|
||||
|
||||
networks:
|
||||
edge:
|
||||
name: edge
|
||||
mmapp:
|
||||
name: mmapp
|
||||
@@ -0,0 +1,138 @@
|
||||
# MikrotikManager behind an existing CDNManager Traefik (no second Traefik).
|
||||
#
|
||||
# Prerequisite: CDNManager stack is up — network `cdnmanager` and container
|
||||
# `cdnmanager-traefik` already publish :80/:443 and watch Docker labels.
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/mmapp/data /opt/mmapp/state /opt/mmapp/updater
|
||||
# cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
|
||||
# cp deploy/env.traefik.example /opt/mmapp/.env
|
||||
# # set MM_DOMAIN / CORS_ORIGIN; CF_* / LETSENCRYPT_* not required here
|
||||
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||
# docker login git.shx.one
|
||||
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||
#
|
||||
# Equivalent plain CLI: deploy/run-beside-cdn-traefik.sh
|
||||
#
|
||||
# Traffic:
|
||||
# Internet → CDNManager Traefik (:80/:443) → mmapp-frontend:3000 (network cdnmanager)
|
||||
# └─ rewrite /api,/health → backend:8000 (network mmapp)
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- backend
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- frontend
|
||||
cdnmanager: {}
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=cdnmanager
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
mmapp:
|
||||
name: mmapp
|
||||
cdnmanager:
|
||||
external: true
|
||||
name: cdnmanager
|
||||
@@ -0,0 +1,173 @@
|
||||
# MikrotikManager + Traefik in one Compose stack (production).
|
||||
# Docs: README.md (раздел «Прод-развёртывание Docker»)
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/mmapp/data /opt/mmapp/state
|
||||
# cp deploy/docker-compose.traefik.yml /opt/mmapp/docker-compose.yml
|
||||
# cp deploy/env.traefik.example /opt/mmapp/.env # fill secrets
|
||||
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
|
||||
# docker login git.shx.one
|
||||
# cd /opt/mmapp && docker compose pull && docker compose up -d
|
||||
#
|
||||
# Traffic:
|
||||
# Internet → :80/:443 (Traefik) → frontend:3000
|
||||
# └─ rewrite /api,/health → backend:8000 (internal)
|
||||
# Backend is not published on the host — only Traefik exposes 80/443.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: mmapp-traefik
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||
# Optional if DNS token lacks Zone:Read:
|
||||
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||
command:
|
||||
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=mmapp
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
# No host ports — frontend reaches backend on the Compose network.
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- mmapp
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
# No host ports — only Traefik publishes 80/443.
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
- mmapp
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=mmapp
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: mmapp_traefik_letsencrypt
|
||||
|
||||
networks:
|
||||
mmapp:
|
||||
name: mmapp
|
||||
@@ -0,0 +1,52 @@
|
||||
# Production .env for deploy/docker-compose.cdn-mm.yml
|
||||
# (CDN Manager + MikrotikManager + one Traefik).
|
||||
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
|
||||
|
||||
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
|
||||
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
|
||||
CF_DNS_API_TOKEN=
|
||||
LETSENCRYPT_EMAIL=admin@shnt.top
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- Public hosts ---
|
||||
CDN_DOMAIN=cdn.shnt.top
|
||||
MM_DOMAIN=mm.shnt.top
|
||||
# Must match MM UI origin (https:// + MM_DOMAIN).
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
|
||||
# --- Images ---
|
||||
CDN_IMAGE_TAG=latest
|
||||
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||
MM_BACKEND_IMAGE_TAG=latest
|
||||
MM_FRONTEND_IMAGE_TAG=latest
|
||||
MM_UPDATER_IMAGE_TAG=latest
|
||||
|
||||
# --- CDN Manager ---
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
# Portal SSO — used by CDN Manager and MikrotikManager backend
|
||||
AUTH_REQUIRED=true
|
||||
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||
AUTH_JWT_SECRET=
|
||||
# Optional alias — CDN Manager also reads JWT_SECRET
|
||||
JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
|
||||
AUTH_AUDIT_INGEST_SECRET=
|
||||
|
||||
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# --- MikrotikManager updater (optional; private registry pull) ---
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# POLL_INTERVAL_SECONDS=300
|
||||
# HEALTH_TIMEOUT_SECONDS=120
|
||||
# STOP_TIMEOUT_SECONDS=30
|
||||
@@ -0,0 +1,37 @@
|
||||
# Production .env for MikrotikManager Traefik deploys. Do not commit.
|
||||
# Use with:
|
||||
# deploy/docker-compose.traefik.yml — own Traefik (standalone)
|
||||
# deploy/docker-compose.traefik-cdn.yml — reuse CDNManager Traefik (network cdnmanager)
|
||||
# deploy/run-beside-cdn-traefik.sh — plain docker CLI beside CDNManager
|
||||
|
||||
# --- Public host ---
|
||||
MM_DOMAIN=mm.shnt.top
|
||||
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
|
||||
# --- Portal SSO (MM backend) ---
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
|
||||
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
|
||||
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
|
||||
CF_DNS_API_TOKEN=
|
||||
LETSENCRYPT_EMAIL=admin@shnt.top
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- Images ---
|
||||
MM_BACKEND_IMAGE_TAG=latest
|
||||
MM_FRONTEND_IMAGE_TAG=latest
|
||||
MM_UPDATER_IMAGE_TAG=latest
|
||||
|
||||
# --- Updater (optional; needed for private registry pull) ---
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# POLL_INTERVAL_SECONDS=300
|
||||
# HEALTH_TIMEOUT_SECONDS=120
|
||||
# STOP_TIMEOUT_SECONDS=30
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run MikrotikManager beside an already-running CDNManager Traefik stack.
|
||||
# Does NOT start a second Traefik — attaches frontend to network `cdnmanager`.
|
||||
#
|
||||
# Usage (on the VPS):
|
||||
# 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 /tmp/run-beside-cdn-traefik.sh
|
||||
#
|
||||
# Or copy this file to the server and run it.
|
||||
#
|
||||
# Env overrides:
|
||||
# MM_DOMAIN=mm.shnt.top
|
||||
# CORS_ORIGIN=https://mm.shnt.top
|
||||
# MM_ROOT=/opt/mmapp
|
||||
# MM_BACKEND_IMAGE_TAG=latest
|
||||
# MM_FRONTEND_IMAGE_TAG=latest
|
||||
# MM_UPDATER_IMAGE_TAG=latest
|
||||
# REGISTRY_USERNAME=… REGISTRY_PASSWORD=… # optional private pull
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MM_DOMAIN="${MM_DOMAIN:-mm.shnt.top}"
|
||||
CORS_ORIGIN="${CORS_ORIGIN:-https://${MM_DOMAIN}}"
|
||||
MM_ROOT="${MM_ROOT:-/opt/mmapp}"
|
||||
MM_BACKEND_IMAGE_TAG="${MM_BACKEND_IMAGE_TAG:-latest}"
|
||||
MM_FRONTEND_IMAGE_TAG="${MM_FRONTEND_IMAGE_TAG:-latest}"
|
||||
MM_UPDATER_IMAGE_TAG="${MM_UPDATER_IMAGE_TAG:-latest}"
|
||||
REGISTRY="${REGISTRY:-git.shx.one}"
|
||||
|
||||
BACKEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG}"
|
||||
FRONTEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG}"
|
||||
UPDATER_IMAGE="${REGISTRY}/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG}"
|
||||
|
||||
echo "==> Check CDNManager Traefik network"
|
||||
if ! docker network inspect cdnmanager >/dev/null 2>&1; then
|
||||
echo "ERROR: Docker network 'cdnmanager' not found." >&2
|
||||
echo "Start CDNManager Traefik stack first (deploy/docker-compose.traefik.yml)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! docker inspect cdnmanager-traefik >/dev/null 2>&1; then
|
||||
echo "WARN: container 'cdnmanager-traefik' not found — labels may not be routed." >&2
|
||||
fi
|
||||
|
||||
echo "==> Prepare dirs under ${MM_ROOT}"
|
||||
mkdir -p "${MM_ROOT}/data" "${MM_ROOT}/state" "${MM_ROOT}/updater"
|
||||
|
||||
TARGETS="${MM_ROOT}/updater/targets.json"
|
||||
if [[ ! -f "${TARGETS}" ]]; then
|
||||
cat >"${TARGETS}" <<'EOF'
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"id": "backend",
|
||||
"container_name": "mmapp-backend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://backend:8000/health",
|
||||
"expect_status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "frontend",
|
||||
"container_name": "mmapp-frontend",
|
||||
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
|
||||
"health": {
|
||||
"type": "http",
|
||||
"url": "http://frontend:3000/dashboard",
|
||||
"expect_status": 200
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
echo " wrote ${TARGETS}"
|
||||
fi
|
||||
|
||||
echo "==> Ensure internal network mmapp"
|
||||
docker network inspect mmapp >/dev/null 2>&1 || docker network create mmapp >/dev/null
|
||||
|
||||
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
|
||||
echo "==> docker login ${REGISTRY}"
|
||||
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USERNAME}" --password-stdin
|
||||
fi
|
||||
|
||||
echo "==> Pull images"
|
||||
docker pull "${BACKEND_IMAGE}"
|
||||
docker pull "${FRONTEND_IMAGE}"
|
||||
docker pull "${UPDATER_IMAGE}"
|
||||
|
||||
stop_rm() {
|
||||
local name="$1"
|
||||
if docker inspect "${name}" >/dev/null 2>&1; then
|
||||
docker stop "${name}" >/dev/null || true
|
||||
docker rm "${name}" >/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> Recreate mmapp-backend"
|
||||
stop_rm mmapp-backend
|
||||
docker run -d \
|
||||
--name mmapp-backend \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
--network-alias backend \
|
||||
-e NODE_ENV=production \
|
||||
-e PORT=8000 \
|
||||
-e DATABASE_PATH=/app/data/mikrotik.db \
|
||||
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
|
||||
-e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
|
||||
-e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
|
||||
-e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
|
||||
-e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
|
||||
-v "${MM_ROOT}/data:/app/data" \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=backend \
|
||||
--label "mmapp.updater.image=${BACKEND_IMAGE}" \
|
||||
--health-cmd="node -e \"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||
--health-interval=30s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=3 \
|
||||
--health-start-period=15s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${BACKEND_IMAGE}"
|
||||
|
||||
echo "==> Recreate mmapp-frontend (mmapp + cdnmanager)"
|
||||
stop_rm mmapp-frontend
|
||||
docker run -d \
|
||||
--name mmapp-frontend \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
--network-alias frontend \
|
||||
-e BACKEND_INTERNAL_URL=http://backend:8000 \
|
||||
--label mmapp.updater.managed=true \
|
||||
--label mmapp.updater.target=frontend \
|
||||
--label "mmapp.updater.image=${FRONTEND_IMAGE}" \
|
||||
--label traefik.enable=true \
|
||||
--label traefik.docker.network=cdnmanager \
|
||||
--label "traefik.http.routers.mmapp.rule=Host(\`${MM_DOMAIN}\`)" \
|
||||
--label traefik.http.routers.mmapp.entrypoints=websecure \
|
||||
--label traefik.http.routers.mmapp.tls=true \
|
||||
--label traefik.http.routers.mmapp.tls.certresolver=letsencrypt \
|
||||
--label traefik.http.services.mmapp.loadbalancer.server.port=3000 \
|
||||
--health-cmd="node -e \"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
|
||||
--health-interval=30s \
|
||||
--health-timeout=5s \
|
||||
--health-retries=3 \
|
||||
--health-start-period=25s \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${FRONTEND_IMAGE}"
|
||||
|
||||
docker network connect cdnmanager mmapp-frontend
|
||||
|
||||
echo "==> Recreate mmapp-updater"
|
||||
stop_rm mmapp-updater
|
||||
docker run -d \
|
||||
--name mmapp-updater \
|
||||
--restart unless-stopped \
|
||||
--network mmapp \
|
||||
-e "REGISTRY=${REGISTRY}" \
|
||||
-e "REGISTRY_USERNAME=${REGISTRY_USERNAME:-}" \
|
||||
-e "REGISTRY_PASSWORD=${REGISTRY_PASSWORD:-}" \
|
||||
-e "POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-300}" \
|
||||
-e "HEALTH_TIMEOUT_SECONDS=${HEALTH_TIMEOUT_SECONDS:-120}" \
|
||||
-e "STOP_TIMEOUT_SECONDS=${STOP_TIMEOUT_SECONDS:-30}" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "${MM_ROOT}/state:/state" \
|
||||
-v "${TARGETS}:/etc/updater/targets.json:ro" \
|
||||
--log-driver json-file \
|
||||
--log-opt max-size=10m \
|
||||
--log-opt max-file=3 \
|
||||
"${UPDATER_IMAGE}"
|
||||
|
||||
echo
|
||||
echo "OK. UI: https://${MM_DOMAIN}"
|
||||
echo "DNS: A/AAAA for ${MM_DOMAIN} → this VPS, Cloudflare proxy OFF (DNS only)."
|
||||
echo "Check: curl -fsS https://${MM_DOMAIN}/health"
|
||||
echo " docker ps --filter name=mmapp-"
|
||||
echo " docker network inspect cdnmanager --format '{{range .Containers}}{{.Name}} {{end}}'"
|
||||
@@ -0,0 +1,60 @@
|
||||
# Интеграция auth-portal ↔ MikrotikManager
|
||||
|
||||
App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md).
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Browser → MikrotikManager UI (нет token)
|
||||
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
|
||||
→ login
|
||||
→ redirect return_to#access_token=…
|
||||
→ /auth/callback сохраняет token (localStorage: mmapp_token)
|
||||
→ API Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | UI / API |
|
||||
|------------|----------|
|
||||
| `mm:dashboard:read` | `/dashboard`, sidebar-counts |
|
||||
| `mm:servers:read` / `write` | `/servers` |
|
||||
| `mm:filters:read` / `write` | filters / GRE |
|
||||
| `mm:bgp:read` / `write` | BGP |
|
||||
| `mm:uptime:read` / `write` | `/uptime` |
|
||||
| `mm:traffic:read` / `write` | `/traffic` |
|
||||
| `mm:alerts:read` / `write` | `/alerts` |
|
||||
| `mm:backups:read` / `write` | `/backups` |
|
||||
| `mm:certificates:read` / `write` | certificates |
|
||||
| `mm:network:read` / `write` | network / OSPF |
|
||||
| `mm:settings:admin` | `/settings`, system DB, scheduler |
|
||||
|
||||
## Env
|
||||
|
||||
```env
|
||||
# backend
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
```
|
||||
|
||||
```env
|
||||
# frontend build (Docker)
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
```
|
||||
|
||||
UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`.
|
||||
|
||||
В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
|
||||
2. Origin MM в `RETURN_TO_ALLOWLIST` портала
|
||||
3. Пользователю выдан app `mm`
|
||||
4. `AUTH_REQUIRED=true` на backend
|
||||
5. Logout → `{AUTH_PORTAL_URL}/logout`
|
||||
|
||||
Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose.
|
||||
@@ -0,0 +1,83 @@
|
||||
# UI Design Contract (MikrotikManager)
|
||||
|
||||
Surface: **ReUI Frame**. Kit: `components/reui-kit/`.
|
||||
Иерархия: **ReUI PRO > shadcn primitives**.
|
||||
Стек: Next.js 16 App Router + Turbopack + shadcn **base-nova** + ReUI `@reui`.
|
||||
|
||||
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
|
||||
|
||||
## Surface
|
||||
|
||||
Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как page shell. Не смешивать Card и Frame на одном ops-экране.
|
||||
|
||||
Эталон CRUD: [`app/(main)/servers`](../app/(main)/servers).
|
||||
Оболочка списков: `DataPageCard` → Frame. Панели: `OpsPanel` → Frame.
|
||||
|
||||
## Canonical PRO references
|
||||
|
||||
| Зона | Preview |
|
||||
|------|---------|
|
||||
| Shell | https://reui.io/preview/base/app-shell-12 |
|
||||
| KPI | https://reui.io/preview/base/stats-12 · https://reui.io/docs/components/base/icon-tile |
|
||||
| Lists | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Server rail | https://reui.io/preview/base/list-9 · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile |
|
||||
| Settings | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 |
|
||||
| Forms / Sheet | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/sheet-8 |
|
||||
| Alert / Badge | https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge |
|
||||
| Empty | https://reui.io/preview/base/empty-state-12 |
|
||||
|
||||
## Kit (`components/reui-kit/`)
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
KPI: только `KpiStatGrid` (hybrid IconTile elevated `size-10.5`, эталон `/wireguard`). Не hand-roll Frame KPI.
|
||||
| `CodeExportSheet` | Экспорт кода (.rsc / .conf) — Sheet + Frame + ScrollArea |
|
||||
|
||||
Переключение сервера (ops-контекст): **`ServerTileRail` + `ServerRailLayout`** (`w-60` = `--sidebar-width`). Desktop rail сворачивается (persist `mm:server-rail-collapsed`); mobile — Sheet. Не Combobox. Страницы: OSPF, Filters, GRE, Recursive Routes, WireGuard, Terminal, Certificates, Probes, **Firewall** (live `/api/firewall`). Combobox — generic primitive, не доменный селектор хостов.
|
||||
|
||||
Слои:
|
||||
|
||||
| Слой | Путь |
|
||||
|------|------|
|
||||
| shadcn | `components/ui/` |
|
||||
| ReUI CLI | `components/reui/` |
|
||||
| Kit | `components/reui-kit/` |
|
||||
| Domain grids | `components/data-grids/` |
|
||||
|
||||
## Shared App Shell chrome
|
||||
|
||||
| Токен / зона | Значение |
|
||||
|--------------|----------|
|
||||
| `--sidebar-width` | `240px` |
|
||||
| Header right | AppsMenu → SystemMonitorPopover (тема — в NavUser) |
|
||||
| Search | ⌘K / Ctrl+K only |
|
||||
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
||||
|
||||
## Spacing & tokens
|
||||
|
||||
- `flex` + `gap-*` — не `space-y-*` / `space-x-*`
|
||||
- Semantic tokens / ReUI Badge variants — не raw `bg-emerald-*` / hex
|
||||
- Max 1 primary CTA на экран
|
||||
|
||||
## Exceptions (не Frame ops shell)
|
||||
|
||||
- `network-map` — canvas / topology UX
|
||||
- `terminal` — terminal chrome
|
||||
|
||||
## Forbidden
|
||||
|
||||
- Card как ops list/dashboard shell
|
||||
- Hand-roll data-grid / KPI / code-export при наличии kit
|
||||
- Дубль Copy в header + footer export sheet
|
||||
- Mixing Card и Frame на одном ops-экране
|
||||
- Radix-варианты docs — только Base UI (`base-nova`)
|
||||
- Combobox / toolbar-фильтр как переключатель сервера (SoT — `ServerTileRail`)
|
||||
|
||||
## License
|
||||
|
||||
```env
|
||||
# .env.local (gitignored)
|
||||
REUI_LICENSE_KEY=
|
||||
```
|
||||
|
||||
`components.json` → `@reui` с `Authorization: Bearer ${REUI_LICENSE_KEY}`.
|
||||
+46
-19
@@ -8,6 +8,21 @@ import {
|
||||
parseAppSwitcherConfig,
|
||||
type AppSwitcherConfig,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import {
|
||||
ensureAuthConfig,
|
||||
getAuthConfigSync,
|
||||
getClaims,
|
||||
} from "@/lib/auth"
|
||||
|
||||
function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
|
||||
const claims = getClaims()
|
||||
if (!claims?.apps?.length) return config
|
||||
const allowed = new Set(claims.apps)
|
||||
const apps = config.apps.filter(
|
||||
(a) => a.id === "mm" || allowed.has(a.id),
|
||||
)
|
||||
return { ...config, apps: apps.length > 0 ? apps : config.apps }
|
||||
}
|
||||
|
||||
export function useAppSwitcherConfig(): {
|
||||
config: AppSwitcherConfig
|
||||
@@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): {
|
||||
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
|
||||
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const base = authPortalBaseUrl()
|
||||
if (!base) {
|
||||
setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
fetch(`${base}/api/v1/app-switcher`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||
.then((raw: unknown) => {
|
||||
|
||||
void (async () => {
|
||||
await ensureAuthConfig()
|
||||
if (cancelled) return
|
||||
|
||||
const portal =
|
||||
getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
|
||||
if (!portal) {
|
||||
setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${portal}/api/v1/app-switcher`)
|
||||
if (!res.ok) throw new Error("switcher fetch failed")
|
||||
const raw: unknown = await res.json()
|
||||
if (cancelled) return
|
||||
const parsed = parseAppSwitcherConfig(raw)
|
||||
setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
|
||||
})
|
||||
.finally(() => {
|
||||
setConfig(
|
||||
filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
|
||||
)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setConfig(
|
||||
filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { resolveApiUrl, withAuthHeaders } from "@/shared/api/http-client"
|
||||
|
||||
export interface TrafficLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string): { event: string; data: string } {
|
||||
let event = "message"
|
||||
const dataLines: string[] = []
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) event = line.slice(6).trim()
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
return { event, data: dataLines.join("\n") }
|
||||
}
|
||||
|
||||
export function useTrafficLive(opts: {
|
||||
enabled: boolean
|
||||
backendUrl: string
|
||||
serverId: string
|
||||
iface: string
|
||||
}): { sample: TrafficLiveSample | null; error: string | null } {
|
||||
const [sample, setSample] = useState<TrafficLiveSample | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!opts.enabled || !opts.serverId) {
|
||||
setSample(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
const ac = new AbortController()
|
||||
setSample(null)
|
||||
setError(null)
|
||||
const ifaceQ =
|
||||
opts.iface && opts.iface !== "__all__"
|
||||
? `?iface=${encodeURIComponent(opts.iface)}`
|
||||
: ""
|
||||
const path = `/api/traffic/servers/${encodeURIComponent(opts.serverId)}/live${ifaceQ}`
|
||||
const url = resolveApiUrl(opts.backendUrl, path)
|
||||
|
||||
let buf = ""
|
||||
setError(null)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: withAuthHeaders({ Accept: "text/event-stream" }),
|
||||
signal: ac.signal,
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
setError(`live HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
while (!ac.signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const parts = buf.split("\n\n")
|
||||
buf = parts.pop() ?? ""
|
||||
for (const raw of parts) {
|
||||
if (!raw.trim() || raw.trim().startsWith(":")) continue
|
||||
const ev = parseSseBlock(raw)
|
||||
if (ev.event === "sample" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as TrafficLiveSample
|
||||
setSample(parsed)
|
||||
setError(null)
|
||||
} else if (ev.event === "error" && ev.data) {
|
||||
const parsed = JSON.parse(ev.data) as { error?: string }
|
||||
setError(parsed.error ?? "live error")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (ac.signal.aborted) return
|
||||
setError(e instanceof Error ? e.message : "live error")
|
||||
}
|
||||
})()
|
||||
|
||||
return () => ac.abort()
|
||||
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
|
||||
|
||||
return { sample, error }
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/** Portal JWT storage + claims helpers for MikrotikManager. */
|
||||
|
||||
const TOKEN_KEY = "mmapp_token"
|
||||
const HANDOFF_KEY = "mmapp_auth_401_handoff"
|
||||
const HANDOFF_AT_KEY = "mmapp_portal_handoff_at"
|
||||
const HANDOFF_COOLDOWN_MS = 12_000
|
||||
|
||||
export type AccessClaims = {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
is_admin?: boolean
|
||||
iss?: string
|
||||
exp?: number
|
||||
}
|
||||
|
||||
export type RuntimeAuthConfig = {
|
||||
required: boolean
|
||||
portalUrl: string
|
||||
}
|
||||
|
||||
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||
|
||||
function envPortalUrl(): string {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175"
|
||||
).replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function envAuthEnabled(): boolean {
|
||||
const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase()
|
||||
return v === "true" || v === "1"
|
||||
}
|
||||
|
||||
/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */
|
||||
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||
if (runtimeConfig) return runtimeConfig
|
||||
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||
|
||||
runtimeConfigPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/config")
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
required?: boolean
|
||||
portal_url?: string
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: Boolean(data.required) || envAuthEnabled(),
|
||||
portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""),
|
||||
}
|
||||
return runtimeConfig
|
||||
}
|
||||
} catch {
|
||||
/* use env defaults */
|
||||
}
|
||||
runtimeConfig = {
|
||||
required: envAuthEnabled(),
|
||||
portalUrl: envPortalUrl(),
|
||||
}
|
||||
return runtimeConfig
|
||||
})().finally(() => {
|
||||
runtimeConfigPromise = null
|
||||
})
|
||||
|
||||
return runtimeConfigPromise
|
||||
}
|
||||
|
||||
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||
return runtimeConfig
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function isAuthEnabled(): boolean {
|
||||
if (runtimeConfig) return runtimeConfig.required
|
||||
return envAuthEnabled()
|
||||
}
|
||||
|
||||
export function authPortalUrl(): string {
|
||||
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||
return envPortalUrl()
|
||||
}
|
||||
|
||||
export function isPortalHandoffCoolingDown(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||
if (!raw) return false
|
||||
const at = Number(raw)
|
||||
if (!Number.isFinite(at)) return false
|
||||
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||
}
|
||||
|
||||
export function markPortalHandoff(): void {
|
||||
sessionStorage.setItem(HANDOFF_KEY, "1")
|
||||
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function clearPortalHandoffFlag(): void {
|
||||
sessionStorage.removeItem(HANDOFF_KEY)
|
||||
}
|
||||
|
||||
export function resetPortalHandoff(): void {
|
||||
sessionStorage.removeItem(HANDOFF_KEY)
|
||||
sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||
if (isPortalHandoffCoolingDown()) {
|
||||
clearToken()
|
||||
return false
|
||||
}
|
||||
markPortalHandoff()
|
||||
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||
const url = new URL(authPortalUrl())
|
||||
url.searchParams.set("return_to", callback)
|
||||
window.location.assign(url.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
export function redirectToPortalLoginInteractive(): void {
|
||||
clearToken()
|
||||
resetPortalHandoff()
|
||||
window.location.assign(authPortalUrl())
|
||||
}
|
||||
|
||||
export function redirectToPortalLogout(): void {
|
||||
clearToken()
|
||||
resetPortalHandoff()
|
||||
window.location.assign(`${authPortalUrl()}/logout`)
|
||||
}
|
||||
|
||||
export function parseHashToken(hash: string): {
|
||||
accessToken: string | null
|
||||
expiresAt: string | null
|
||||
} {
|
||||
const raw = hash.startsWith("#") ? hash.slice(1) : hash
|
||||
const params = new URLSearchParams(raw)
|
||||
return {
|
||||
accessToken: params.get("access_token"),
|
||||
expiresAt: params.get("expires_at"),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeClaims(token: string): AccessClaims | null {
|
||||
try {
|
||||
const parts = token.split(".")
|
||||
if (parts.length < 2) return null
|
||||
const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/"))
|
||||
const payload = JSON.parse(json) as Record<string, unknown>
|
||||
return {
|
||||
sub: String(payload.sub ?? ""),
|
||||
email: String(payload.email ?? ""),
|
||||
name: String(payload.name ?? ""),
|
||||
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||
permissions: Array.isArray(payload.permissions)
|
||||
? payload.permissions.map(String)
|
||||
: [],
|
||||
is_admin: Boolean(payload.is_admin),
|
||||
iss: payload.iss ? String(payload.iss) : undefined,
|
||||
exp: typeof payload.exp === "number" ? payload.exp : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaims(): AccessClaims | null {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
const claims = decodeClaims(token)
|
||||
if (!claims) return null
|
||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||
clearToken()
|
||||
resetPortalHandoff()
|
||||
return null
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
granted: readonly string[],
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes(required)) 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
|
||||
}
|
||||
|
||||
export function can(required: string): boolean {
|
||||
if (!isAuthEnabled()) return true
|
||||
const claims = getClaims()
|
||||
if (!claims) return false
|
||||
if (!claims.apps.includes("mm")) return false
|
||||
return hasPermission(claims.permissions, required)
|
||||
}
|
||||
|
||||
export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === "/" || pathname.startsWith("/dashboard")) {
|
||||
return "mm:dashboard:read"
|
||||
}
|
||||
if (pathname.startsWith("/servers")) return "mm:servers:read"
|
||||
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
|
||||
return "mm:filters:read"
|
||||
}
|
||||
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
|
||||
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
|
||||
if (pathname.startsWith("/traffic")) return "mm:traffic:read"
|
||||
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
|
||||
if (pathname.startsWith("/backups")) return "mm:backups:read"
|
||||
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
|
||||
if (
|
||||
pathname.startsWith("/network") ||
|
||||
pathname.startsWith("/ospf") ||
|
||||
pathname.startsWith("/route-optimizer")
|
||||
) {
|
||||
return "mm:network:read"
|
||||
}
|
||||
if (pathname.startsWith("/settings")) return "mm:settings:admin"
|
||||
return "mm:dashboard:read"
|
||||
}
|
||||
|
||||
export function firstAllowedPath(): string {
|
||||
const candidates = [
|
||||
"/dashboard",
|
||||
"/servers",
|
||||
"/filters",
|
||||
"/uptime",
|
||||
"/traffic",
|
||||
"/alerts",
|
||||
"/backups",
|
||||
"/settings",
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
}
|
||||
return "/access-denied"
|
||||
}
|
||||
+15
-1
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
|
||||
return cfg.kind === "same-origin" || cfg.kind === "fixed"
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1"
|
||||
}
|
||||
|
||||
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
|
||||
export function resolveStoredBackendUrl(stored: string | null): string {
|
||||
const cfg = configuredBackendUrl()
|
||||
if (cfg.kind === "fixed") return cfg.url
|
||||
if (cfg.kind === "same-origin" && typeof window !== "undefined") {
|
||||
if (cfg.kind === "same-origin") {
|
||||
if (typeof window !== "undefined") return window.location.origin
|
||||
return ""
|
||||
}
|
||||
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
|
||||
return window.location.origin
|
||||
}
|
||||
const trimmed = stored?.trim().replace(/\/$/, "")
|
||||
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
|
||||
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
|
||||
return window.location.origin
|
||||
}
|
||||
}
|
||||
return trimmed || LOCAL_DEFAULT_BACKEND_URL
|
||||
}
|
||||
|
||||
+11
-11
@@ -9,6 +9,7 @@ import {
|
||||
LOCAL_DEFAULT_BACKEND_URL,
|
||||
resolveStoredBackendUrl,
|
||||
} from "@/lib/backend-url"
|
||||
import { resolveApiUrl } from "@/shared/api/http-client"
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
|
||||
return defaultDataSourceMode()
|
||||
}
|
||||
|
||||
function readStoredBackendUrl(): string {
|
||||
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
|
||||
function initialBackendUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
const cfg = configuredBackendUrl()
|
||||
if (cfg.kind === "same-origin") return ""
|
||||
if (cfg.kind === "fixed") return cfg.url
|
||||
return LOCAL_DEFAULT_BACKEND_URL
|
||||
}
|
||||
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||
}
|
||||
|
||||
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
|
||||
|
||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
||||
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
|
||||
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
|
||||
const [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||
const backendUrlLocked = isBackendUrlLocked()
|
||||
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
useEffect(() => {
|
||||
const storedMode = readStoredMode()
|
||||
let url = readStoredBackendUrl()
|
||||
if (configuredBackendUrl().kind === "same-origin") {
|
||||
url = window.location.origin
|
||||
}
|
||||
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||
setModeState(storedMode)
|
||||
setBackendUrlState(url)
|
||||
setPrefsHydrated(true)
|
||||
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
}, [backendUrlLocked])
|
||||
|
||||
const checkBackend = useCallback(async () => {
|
||||
const healthUrl =
|
||||
configuredBackendUrl().kind === "same-origin"
|
||||
? "/health"
|
||||
: `${normalizeBackendUrl(backendUrl)}/health`
|
||||
const healthUrl = resolveApiUrl(backendUrl, "/health")
|
||||
try {
|
||||
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
|
||||
setBackendStatus(res.ok)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user